From 8cc4614afec4abc779c63e92a1963c2d0eb43965 Mon Sep 17 00:00:00 2001 From: Orual Date: Tue, 6 Jan 2026 18:53:42 -0500 Subject: [PATCH] all reasonably generic editor stuff extracted --- Cargo.lock | 3 + crates/weaver-app/Cargo.toml | 2 +- .../src/components/editor/actions.rs | 571 +---------------- .../src/components/editor/beforeinput.rs | 70 -- .../src/components/editor/component.rs | 285 ++------- .../src/components/editor/document.rs | 37 ++ .../weaver-app/src/components/editor/input.rs | 601 ------------------ .../weaver-app/src/components/editor/mod.rs | 6 +- .../src/components/editor/remote_cursors.rs | 145 +++++ .../weaver-app/src/components/editor/sync.rs | 7 +- crates/weaver-editor-browser/Cargo.toml | 14 + crates/weaver-editor-browser/src/clipboard.rs | 191 ++++++ crates/weaver-editor-browser/src/events.rs | 89 ++- crates/weaver-editor-browser/src/lib.rs | 14 +- .../weaver-editor-browser/src/visibility.rs | 9 +- crates/weaver-editor-core/src/document.rs | 23 + crates/weaver-editor-core/src/execute.rs | 124 +++- crates/weaver-editor-core/src/lib.rs | 11 +- crates/weaver-editor-core/src/platform.rs | 170 +++++ docs/graph-data.json | 44 ++ 20 files changed, 921 insertions(+), 1495 deletions(-) delete mode 100644 crates/weaver-app/src/components/editor/beforeinput.rs delete mode 100644 crates/weaver-app/src/components/editor/input.rs create mode 100644 crates/weaver-app/src/components/editor/remote_cursors.rs create mode 100644 crates/weaver-editor-browser/src/clipboard.rs diff --git a/Cargo.lock b/Cargo.lock index 1224f57..46b49da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12207,7 +12207,10 @@ dependencies = [ name = "weaver-editor-browser" version = "0.1.0" dependencies = [ + "dioxus-core 0.7.2", + "dioxus-html 0.7.2", "dioxus-signals 0.7.2", + "dioxus-web 0.7.2", "gloo-events", "gloo-utils", "js-sys", diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index a1d7e5c..a3e46b0 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -49,7 +49,7 @@ dashmap = "6.1.0" dioxus = { version = "0.7.1", features = ["router"] } weaver-common = { path = "../weaver-common", features = ["cache", "perf"] } weaver-editor-core = { path = "../weaver-editor-core" } -weaver-editor-browser = { path = "../weaver-editor-browser" } +weaver-editor-browser = { path = "../weaver-editor-browser", features = ["dioxus"] } weaver-editor-crdt = { path = "../weaver-editor-crdt" } jacquard = { workspace = true}#, features = ["streaming"] } jacquard-lexicon = { workspace = true } diff --git a/crates/weaver-app/src/components/editor/actions.rs b/crates/weaver-app/src/components/editor/actions.rs index f725097..69845dc 100644 --- a/crates/weaver-app/src/components/editor/actions.rs +++ b/crates/weaver-app/src/components/editor/actions.rs @@ -1,41 +1,18 @@ //! Editor actions and keybinding system. //! //! This module re-exports core types and provides Dioxus-specific conversions. -//! Action execution delegates to `weaver_editor_core::execute_action`. +//! Action execution delegates entirely to `weaver_editor_core`. -use dioxus::prelude::*; +use dioxus::prelude::ModifiersInteraction; use super::document::SignalEditorDocument; use weaver_editor_browser::Platform; -use weaver_editor_core::SnapDirection; // Re-export core types. pub use weaver_editor_core::{ - EditorAction, FormatAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Modifiers, Range, - apply_formatting, + EditorAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Modifiers, Range, }; -/// Determine the cursor snap direction hint for an action. -fn snap_direction_for_action(action: &EditorAction) -> Option { - match action { - // Forward: cursor should snap toward new/remaining content. - EditorAction::InsertLineBreak { .. } - | EditorAction::InsertParagraph { .. } - | EditorAction::DeleteForward { .. } - | EditorAction::DeleteWordForward { .. } - | EditorAction::DeleteToLineEnd { .. } - | EditorAction::DeleteSoftLineForward { .. } => Some(SnapDirection::Forward), - - // Backward: cursor should snap toward content before edit. - EditorAction::DeleteBackward { .. } - | EditorAction::DeleteWordBackward { .. } - | EditorAction::DeleteToLineStart { .. } - | EditorAction::DeleteSoftLineBackward { .. } => Some(SnapDirection::Backward), - - _ => None, - } -} - // === Dioxus conversion helpers === /// Convert a dioxus keyboard_types::Key to our Key type. @@ -152,536 +129,44 @@ pub fn default_keybindings(platform: &Platform) -> KeybindingConfig { KeybindingConfig::default_for_platform(platform.mac) } -/// Execute an editor action on a document. -/// -/// This is the central dispatch point for all editor operations. -/// Returns true if the action was handled and the document was modified. +/// Execute an editor action on a document with browser clipboard support. +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] pub fn execute_action(doc: &mut SignalEditorDocument, action: &EditorAction) -> bool { - use super::input::{ - detect_list_context, find_line_end, find_line_start, get_char_at, is_list_item_empty, - }; - use weaver_editor_core::SnapDirection; - - match action { - EditorAction::Insert { text, range } => { - let range = range.normalize(); - if range.is_caret() { - // Simple insert - let offset = range.start; - - // Clean up any preceding zero-width chars - let mut delete_start = offset; - while delete_start > 0 { - match get_char_at(doc.loro_text(), delete_start - 1) { - Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, - _ => break, - } - } - - let zw_count = offset - delete_start; - if zw_count > 0 { - let _ = doc.replace_tracked(delete_start, zw_count, text); - doc.cursor.write().offset = delete_start + text.chars().count(); - } else if offset == doc.len_chars() { - let _ = doc.push_tracked(text); - doc.cursor.write().offset = offset + text.chars().count(); - } else { - let _ = doc.insert_tracked(offset, text); - doc.cursor.write().offset = offset + text.chars().count(); - } - } else { - // Replace range - let _ = doc.replace_tracked(range.start, range.len(), text); - doc.cursor.write().offset = range.start + text.chars().count(); - } - doc.selection.set(None); - true - } - - EditorAction::InsertLineBreak { range } => { - let range = range.normalize(); - doc.pending_snap.set(Some(SnapDirection::Forward)); - - let offset = range.start; - if !range.is_caret() { - let _ = doc.remove_tracked(offset, range.len()); - } - - // Check if we're right after a soft break (newline + zero-width char). - // If so, convert to paragraph break by replacing the zero-width char - // with a newline. - let is_double_enter = if offset >= 2 { - let prev_char = get_char_at(doc.loro_text(), offset - 1); - let prev_prev_char = get_char_at(doc.loro_text(), offset - 2); - if prev_char == Some('\u{200C}') && prev_prev_char == Some('\n') { - true - } else { - false - } - } else { - false - }; - - if !is_double_enter { - // Check for list context - if let Some(ctx) = detect_list_context(doc.loro_text(), offset) { - tracing::debug!("List context detected: {:?}", ctx); - if is_list_item_empty(doc.loro_text(), offset, &ctx) { - // Empty item - exit list - let line_start = find_line_start(doc.loro_text(), offset); - let line_end = find_line_end(doc.loro_text(), offset); - let delete_end = (line_end + 1).min(doc.len_chars()); - - let _ = doc.replace_tracked( - line_start, - delete_end.saturating_sub(line_start), - "\n\n\u{200C}\n", - ); - doc.cursor.write().offset = line_start + 2; - tracing::debug!("empty list"); - } else { - // Continue list - let continuation = match ctx { - super::input::ListContext::Unordered { indent, marker } => { - format!("\n{}{} ", indent, marker) - } - super::input::ListContext::Ordered { indent, number } => { - format!("\n{}{}. ", indent, number + 1) - } - }; - let len = continuation.chars().count(); - let _ = doc.insert_tracked(offset, &continuation); - doc.cursor.write().offset = offset + len; - tracing::debug!("continuation {}", continuation); - } - } else { - // Normal soft break: insert newline + zero-width char for cursor positioning. - let _ = doc.insert_tracked(offset, "\n\u{200C}"); - doc.cursor.write().offset = offset + 2; - } - } else { - // Replace zero-width char with newline - let _ = doc.replace_tracked(offset - 1, 1, "\n"); - doc.cursor.write().offset = offset; - } - - doc.selection.set(None); - true - } - - EditorAction::InsertParagraph { range } => { - let range = range.normalize(); - doc.pending_snap.set(Some(SnapDirection::Forward)); - - let cursor_offset = range.start; - if !range.is_caret() { - let _ = doc.remove_tracked(cursor_offset, range.len()); - } - - // Check for list context - if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) { - if is_list_item_empty(doc.loro_text(), cursor_offset, &ctx) { - // Empty item - exit list - let line_start = find_line_start(doc.loro_text(), cursor_offset); - let line_end = find_line_end(doc.loro_text(), cursor_offset); - let delete_end = (line_end + 1).min(doc.len_chars()); - - let _ = doc.replace_tracked( - line_start, - delete_end.saturating_sub(line_start), - "\n\n\u{200C}\n", - ); - doc.cursor.write().offset = line_start + 2; - } else { - // Continue list - let continuation = match ctx { - super::input::ListContext::Unordered { indent, marker } => { - format!("\n{}{} ", indent, marker) - } - super::input::ListContext::Ordered { indent, number } => { - format!("\n{}{}. ", indent, number + 1) - } - }; - let len = continuation.chars().count(); - let _ = doc.insert_tracked(cursor_offset, &continuation); - doc.cursor.write().offset = cursor_offset + len; - } - } else { - // Normal paragraph break - let _ = doc.insert_tracked(cursor_offset, "\n\n"); - doc.cursor.write().offset = cursor_offset + 2; - } - - doc.selection.set(None); - true - } - - EditorAction::DeleteBackward { range } => { - let range = range.normalize(); - doc.pending_snap.set(Some(SnapDirection::Backward)); - - if !range.is_caret() { - // Delete selection - let _ = doc.remove_tracked(range.start, range.len()); - doc.cursor.write().offset = range.start; - } else if range.start > 0 { - let cursor_offset = range.start; - let prev_char = get_char_at(doc.loro_text(), cursor_offset - 1); - - if prev_char == Some('\n') { - // Deleting a newline - handle paragraph merging - let newline_pos = cursor_offset - 1; - let mut delete_start = newline_pos; - let mut delete_end = cursor_offset; - - // Check for empty paragraph (double newline) - if newline_pos > 0 { - if get_char_at(doc.loro_text(), newline_pos - 1) == Some('\n') { - delete_start = newline_pos - 1; - } - } - - // Check for trailing zero-width char - if let Some(ch) = get_char_at(doc.loro_text(), delete_end) { - if ch == '\u{200C}' || ch == '\u{200B}' { - delete_end += 1; - } - } - - // Scan backwards through zero-width chars - while delete_start > 0 { - match get_char_at(doc.loro_text(), delete_start - 1) { - Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, - Some('\n') | _ => break, - } - } - - let _ = - doc.remove_tracked(delete_start, delete_end.saturating_sub(delete_start)); - doc.cursor.write().offset = delete_start; - } else { - // Normal single char delete - let _ = doc.remove_tracked(cursor_offset - 1, 1); - doc.cursor.write().offset = cursor_offset - 1; - } - } - - doc.selection.set(None); - true - } - - EditorAction::DeleteForward { range } => { - let range = range.normalize(); - doc.pending_snap.set(Some(SnapDirection::Forward)); - - if !range.is_caret() { - let _ = doc.remove_tracked(range.start, range.len()); - doc.cursor.write().offset = range.start; - } else if range.start < doc.len_chars() { - let _ = doc.remove_tracked(range.start, 1); - // Cursor stays at same position - } - - doc.selection.set(None); - true - } - - EditorAction::DeleteWordBackward { range } => { - let range = range.normalize(); - doc.pending_snap.set(Some(SnapDirection::Backward)); - - if !range.is_caret() { - let _ = doc.remove_tracked(range.start, range.len()); - doc.cursor.write().offset = range.start; - } else { - // Find word boundary backwards - let cursor = range.start; - let word_start = find_word_boundary_backward(doc, cursor); - if word_start < cursor { - let _ = doc.remove_tracked(word_start, cursor - word_start); - doc.cursor.write().offset = word_start; - } - } - - doc.selection.set(None); - true - } - - EditorAction::DeleteWordForward { range } => { - let range = range.normalize(); - doc.pending_snap.set(Some(SnapDirection::Forward)); - - if !range.is_caret() { - let _ = doc.remove_tracked(range.start, range.len()); - doc.cursor.write().offset = range.start; - } else { - // Find word boundary forward - let cursor = range.start; - let word_end = find_word_boundary_forward(doc, cursor); - if word_end > cursor { - let _ = doc.remove_tracked(cursor, word_end - cursor); - } - } - - doc.selection.set(None); - true - } - - EditorAction::DeleteToLineStart { range } => { - let range = range.normalize(); - doc.pending_snap.set(Some(SnapDirection::Backward)); - - let cursor = if range.is_caret() { - range.start - } else { - range.start - }; - let line_start = find_line_start(doc.loro_text(), cursor); - - if line_start < cursor { - let _ = doc.remove_tracked(line_start, cursor - line_start); - doc.cursor.write().offset = line_start; - } - - doc.selection.set(None); - true - } - - EditorAction::DeleteToLineEnd { range } => { - let range = range.normalize(); - doc.pending_snap.set(Some(SnapDirection::Forward)); - - let cursor = if range.is_caret() { - range.start - } else { - range.end - }; - let line_end = find_line_end(doc.loro_text(), cursor); - - if cursor < line_end { - let _ = doc.remove_tracked(cursor, line_end - cursor); - } - - doc.selection.set(None); - true - } + use weaver_editor_browser::BrowserClipboard; + use weaver_editor_core::execute_action_with_clipboard; - EditorAction::DeleteSoftLineBackward { range } => { - // For now, treat same as DeleteToLineStart - // TODO: Handle visual line wrapping if needed - execute_action(doc, &EditorAction::DeleteToLineStart { range: *range }) - } - - EditorAction::DeleteSoftLineForward { range } => { - // For now, treat same as DeleteToLineEnd - execute_action(doc, &EditorAction::DeleteToLineEnd { range: *range }) - } - - EditorAction::Undo => { - if let Ok(true) = doc.undo() { - let max = doc.len_chars(); - doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); - doc.selection.set(None); - true - } else { - false - } - } - - EditorAction::Redo => { - if let Ok(true) = doc.redo() { - let max = doc.len_chars(); - doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); - doc.selection.set(None); - true - } else { - false - } - } - - EditorAction::ToggleBold => { - apply_formatting(doc, FormatAction::Bold); - true - } - - EditorAction::ToggleItalic => { - apply_formatting(doc, FormatAction::Italic); - true - } - - EditorAction::ToggleCode => { - apply_formatting(doc, FormatAction::Code); - true - } - - EditorAction::ToggleStrikethrough => { - apply_formatting(doc, FormatAction::Strikethrough); - true - } - - EditorAction::InsertLink => { - apply_formatting(doc, FormatAction::Link); - true - } - - EditorAction::Cut => { - // Handled separately via clipboard events - false - } - - EditorAction::Copy => { - // Handled separately via clipboard events - false - } - - EditorAction::Paste { range: _ } => { - // Handled separately via clipboard events (needs async clipboard access) - false - } - - EditorAction::CopyAsHtml => { - // Handled in component with async clipboard access - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - { - if let Some(sel) = *doc.selection.read() { - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - if start != end { - if let Some(markdown) = doc.slice(start, end) { - let clean_md = markdown.replace('\u{200C}', "").replace('\u{200B}', ""); - wasm_bindgen_futures::spawn_local(async move { - if let Err(e) = super::input::copy_as_html(&clean_md).await { - tracing::warn!("[COPY HTML] Failed: {:?}", e); - } - }); - return true; - } - } - } - } - false - } - - EditorAction::SelectAll => { - let len = doc.len_chars(); - doc.selection.set(Some(super::document::Selection { - anchor: 0, - head: len, - })); - doc.cursor.write().offset = len; - true - } - - EditorAction::MoveCursor { offset } => { - let offset = (*offset).min(doc.len_chars()); - doc.cursor.write().offset = offset; - doc.selection.set(None); - true - } - - EditorAction::ExtendSelection { offset } => { - let offset = (*offset).min(doc.len_chars()); - let current_sel = *doc.selection.read(); - let anchor = current_sel - .map(|s| s.anchor) - .unwrap_or(doc.cursor.read().offset); - doc.selection.set(Some(super::document::Selection { - anchor, - head: offset, - })); - doc.cursor.write().offset = offset; - true - } - } + let clipboard = BrowserClipboard::empty(); + execute_action_with_clipboard(doc, action, &clipboard) } -/// Find word boundary backward from cursor. -fn find_word_boundary_backward(doc: &SignalEditorDocument, cursor: usize) -> usize { - use super::input::get_char_at; - - if cursor == 0 { - return 0; - } - - let mut pos = cursor; - - // Skip any whitespace/punctuation immediately before cursor - while pos > 0 { - match get_char_at(doc.loro_text(), pos - 1) { - Some(c) if c.is_alphanumeric() || c == '_' => break, - Some(_) => pos -= 1, - None => break, - } - } - - // Skip the word characters - while pos > 0 { - match get_char_at(doc.loro_text(), pos - 1) { - Some(c) if c.is_alphanumeric() || c == '_' => pos -= 1, - _ => break, - } - } - - pos +/// Execute an editor action on a document (non-browser fallback). +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +pub fn execute_action(doc: &mut SignalEditorDocument, action: &EditorAction) -> bool { + weaver_editor_core::execute_action(doc, action) } -/// Find word boundary forward from cursor. -fn find_word_boundary_forward(doc: &SignalEditorDocument, cursor: usize) -> usize { - use super::input::get_char_at; - - let len = doc.len_chars(); - if cursor >= len { - return len; - } - - let mut pos = cursor; - - // Skip word characters first - while pos < len { - match get_char_at(doc.loro_text(), pos) { - Some(c) if c.is_alphanumeric() || c == '_' => pos += 1, - _ => break, - } - } - - // Then skip whitespace/punctuation - while pos < len { - match get_char_at(doc.loro_text(), pos) { - Some(c) if c.is_alphanumeric() || c == '_' => break, - Some(_) => pos += 1, - None => break, - } - } +/// Handle a keydown event with browser clipboard support. +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +pub fn handle_keydown_with_bindings( + doc: &mut SignalEditorDocument, + config: &KeybindingConfig, + combo: KeyCombo, + range: Range, +) -> KeydownResult { + use weaver_editor_browser::BrowserClipboard; + use weaver_editor_core::handle_keydown_with_clipboard; - pos + let clipboard = BrowserClipboard::empty(); + handle_keydown_with_clipboard(doc, config, combo, range, &clipboard) } -/// Handle a keydown event using the keybinding configuration. -/// -/// This handles keyboard shortcuts only. Text input and deletion -/// are handled by beforeinput. Navigation (arrows, etc.) is passed -/// through to the browser. +/// Handle a keydown event (non-browser fallback). +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] pub fn handle_keydown_with_bindings( doc: &mut SignalEditorDocument, config: &KeybindingConfig, combo: KeyCombo, range: Range, ) -> KeydownResult { - // Look up keybinding (range is applied by lookup) - if let Some(action) = config.lookup(&combo, range) { - execute_action(doc, &action); - return KeydownResult::Handled; - } - - // No keybinding matched - check if this is navigation or content - if combo.key.is_navigation() { - return KeydownResult::PassThrough; - } - - // Modifier-only keypresses should pass through - if combo.key.is_modifier() { - return KeydownResult::PassThrough; - } - - // Content keys (typing, backspace, etc.) - let beforeinput handle - KeydownResult::NotHandled + weaver_editor_core::handle_keydown(doc, config, combo, range) } diff --git a/crates/weaver-app/src/components/editor/beforeinput.rs b/crates/weaver-app/src/components/editor/beforeinput.rs deleted file mode 100644 index 0560aa3..0000000 --- a/crates/weaver-app/src/components/editor/beforeinput.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! BeforeInput event handling for the editor. -//! -//! This module provides the primary input handling via the `beforeinput` event, -//! which gives us semantic information about what the browser wants to do -//! (insert text, delete backward, etc.) rather than raw key codes. -//! -//! The core logic is in `weaver_editor_browser::handle_beforeinput`. This module -//! adds app-specific concerns like `pending_snap` for cursor snapping direction. -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -use super::document::SignalEditorDocument; -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -use dioxus::prelude::*; -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -use weaver_editor_core::SnapDirection; - -// Re-export types from extracted crates. -pub use weaver_editor_browser::{BeforeInputContext, BeforeInputResult}; -pub use weaver_editor_core::InputType; - -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -pub use weaver_editor_browser::StaticRange; - -/// Determine the cursor snap direction hint for an input type. -/// -/// This is used to hint `dom_sync` which direction to snap the cursor if it -/// lands on invisible content after an edit. -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -fn snap_direction_for_input_type(input_type: &InputType) -> Option { - match input_type { - // Forward: cursor should snap toward new/remaining content after the edit. - InputType::InsertLineBreak - | InputType::InsertParagraph - | InputType::DeleteContentForward - | InputType::DeleteWordForward - | InputType::DeleteEntireWordForward - | InputType::DeleteSoftLineForward - | InputType::DeleteHardLineForward => Some(SnapDirection::Forward), - - // Backward: cursor should snap toward content before the deleted range. - InputType::DeleteContentBackward - | InputType::DeleteWordBackward - | InputType::DeleteEntireWordBackward - | InputType::DeleteSoftLineBackward - | InputType::DeleteHardLineBackward => Some(SnapDirection::Backward), - - // No snap hint for other operations. - _ => None, - } -} - -/// Handle a beforeinput event. -/// -/// This is the main entry point for beforeinput-based input handling. -/// Sets `pending_snap` for cursor snapping, then delegates to the browser crate. -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -pub fn handle_beforeinput( - doc: &mut SignalEditorDocument, - ctx: BeforeInputContext<'_>, -) -> BeforeInputResult { - // Set pending_snap hint before executing the action. - if let Some(snap) = snap_direction_for_input_type(&ctx.input_type) { - doc.pending_snap.set(Some(snap)); - } - - // Get current range for the browser handler. - let current_range = weaver_editor_browser::get_current_range(doc); - - // Delegate to browser crate's generic handler. - weaver_editor_browser::handle_beforeinput(doc, &ctx, current_range) -} diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs index 8063910..86482bd 100644 --- a/crates/weaver-app/src/components/editor/component.rs +++ b/crates/weaver-app/src/components/editor/component.rs @@ -5,17 +5,13 @@ use super::actions::{ EditorAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Range, execute_action, handle_keydown_with_bindings, }; -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -use super::beforeinput::handle_beforeinput; -#[allow(unused_imports)] -use super::beforeinput::{BeforeInputContext, BeforeInputResult, InputType}; use super::document::{CompositionState, LoadedDocState, SignalEditorDocument}; #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] use super::dom_sync::update_paragraph_dom; use super::dom_sync::{sync_cursor_from_dom, sync_cursor_from_dom_with_direction}; -use super::input::{get_char_at, handle_copy, handle_cut, handle_paste}; #[allow(unused_imports)] use super::publish::{LoadedEntry, PublishButton, load_entry_for_editing}; +use super::remote_cursors::RemoteCursors; use super::storage; use super::sync::{SyncStatus, load_and_merge_document}; use super::toolbar::EditorToolbar; @@ -35,8 +31,14 @@ use jacquard::types::blob::BlobRef; use jacquard::types::ident::AtIdentifier; use weaver_api::sh_weaver::embed::images::Image; use weaver_common::WeaverExt; +#[allow(unused_imports)] +use weaver_editor_browser::{BeforeInputContext, BeforeInputResult}; +use weaver_editor_browser::{handle_copy, handle_cut, handle_paste}; use weaver_editor_browser::{platform, update_syntax_visibility}; +use weaver_editor_core::EditorDocument; use weaver_editor_core::EditorImageResolver; +#[allow(unused_imports)] +use weaver_editor_core::InputType; use weaver_editor_core::ParagraphRender; use weaver_editor_core::SnapDirection; use weaver_editor_core::apply_formatting; @@ -702,10 +704,16 @@ fn MarkdownEditorInner( // Set up beforeinput listener for all text input handling. // This is the primary handler for text insertion, deletion, etc. // Keydown only handles shortcuts now. + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + type BeforeInputClosure = wasm_bindgen::closure::Closure; + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + let mut beforeinput_closure: Signal> = use_signal(|| None); + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] let doc_for_beforeinput = document.clone(); #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] use_effect(move || { + use gloo_timers::callback::Timeout; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; @@ -725,7 +733,7 @@ fn MarkdownEditorInner( let mut doc = doc_for_beforeinput.clone(); let cached_paras = cached_paragraphs; - let closure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { + let closure: BeforeInputClosure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { let input_type_str = evt.input_type(); tracing::debug!(input_type = %input_type_str, "beforeinput"); @@ -746,7 +754,8 @@ fn MarkdownEditorInner( platform: &plat, }; - let result = handle_beforeinput(&mut doc, ctx); + let current_range = weaver_editor_browser::get_current_range(&doc); + let result = weaver_editor_browser::handle_beforeinput(&mut doc, &ctx, current_range); match result { BeforeInputResult::Handled => { @@ -765,35 +774,26 @@ fn MarkdownEditorInner( let mut doc_for_timeout = doc.clone(); let doc_len_before = doc.len_chars(); - let window = web_sys::window(); - if let Some(window) = window { - let closure = Closure::once(move || { - // Check if the document changed - if doc_for_timeout.len_chars() == doc_len_before { - // Nothing happened - execute fallback - tracing::debug!("Android backspace fallback triggered"); - // Refocus to work around virtual keyboard issues - if let Some(window) = web_sys::window() { - if let Some(doc) = window.document() { - if let Some(elem) = doc.get_element_by_id(editor_id) { - if let Some(html_elem) = - elem.dyn_ref::() - { - let _ = html_elem.blur(); - let _ = html_elem.focus(); - } + Timeout::new(50, move || { + if doc_for_timeout.len_chars() == doc_len_before { + tracing::debug!("Android backspace fallback triggered"); + // Refocus to work around virtual keyboard issues + if let Some(window) = web_sys::window() { + if let Some(dom_doc) = window.document() { + if let Some(elem) = dom_doc.get_element_by_id(editor_id) { + if let Some(html_elem) = + elem.dyn_ref::() + { + let _ = html_elem.blur(); + let _ = html_elem.focus(); } } } - execute_action(&mut doc_for_timeout, &fallback_action); } - }); - let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( - closure.as_ref().unchecked_ref(), - 50, - ); - closure.forget(); - } + execute_action(&mut doc_for_timeout, &fallback_action); + } + }) + .forget(); // One-shot timer, runs and cleans up } } @@ -805,26 +805,41 @@ fn MarkdownEditorInner( tracing::debug!("Android: possible suggestion pick, deferring cursor sync"); let paras = cached_paras; let mut doc_for_timeout = doc.clone(); - let window = web_sys::window(); - if let Some(window) = window { - let closure = Closure::once(move || { - let paras = paras(); - sync_cursor_from_dom(&mut doc_for_timeout, editor_id, ¶s); - }); - let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( - closure.as_ref().unchecked_ref(), - 20, - ); - closure.forget(); - } + + Timeout::new(20, move || { + let paras = paras(); + sync_cursor_from_dom(&mut doc_for_timeout, editor_id, ¶s); + }) + .forget(); // One-shot timer, runs and cleans up } } } - }) as Box); + }) + as Box); let _ = editor .add_event_listener_with_callback("beforeinput", closure.as_ref().unchecked_ref()); - closure.forget(); + + // Store closure in signal for proper lifecycle management + beforeinput_closure.set(Some(closure)); + }); + + // Clean up event listener on unmount + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + use_drop(move || { + if let Some(closure) = beforeinput_closure.peek().as_ref() { + if let Some(window) = web_sys::window() { + if let Some(dom_document) = window.document() { + if let Some(editor) = dom_document.get_element_by_id(editor_id) { + use wasm_bindgen::JsCast; + let _ = editor.remove_event_listener_with_callback( + "beforeinput", + closure.as_ref().unchecked_ref(), + ); + } + } + } + } }); rsx! { @@ -1184,53 +1199,30 @@ fn MarkdownEditorInner( move |evt| { tracing::debug!("onclick fired - syncing cursor from DOM"); let paras = cached_paragraphs(); + let spans = syntax_spans(); #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] let _ = evt; - // Check if click target is a math-clickable element + // Check if click target is a math-clickable element. #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] { + let map = offset_map(); use dioxus::web::WebEventExt; - use wasm_bindgen::JsCast; let web_evt = evt.as_web_event(); if let Some(target) = web_evt.target() { - if let Some(element) = target.dyn_ref::() { - // Check element or ancestors for math-clickable - if let Ok(Some(math_el)) = element.closest(".math-clickable") { - if let Some(char_target) = math_el.get_attribute("data-char-target") { - if let Ok(offset) = char_target.parse::() { - tracing::debug!("math-clickable clicked, moving cursor to {}", offset); - doc.cursor.write().offset = offset; - *doc.selection.write() = None; - // Update visibility FIRST so math-source is visible - let spans = syntax_spans(); - update_syntax_visibility(offset, None, &spans, ¶s); - // Then set DOM selection - let map = offset_map(); - let _ = weaver_editor_browser::restore_cursor_position( - offset, - &map, - None, - ); - return; - } - } - } + if weaver_editor_browser::handle_math_click( + &target, &mut doc, &spans, ¶s, &map, + ) { + return; } } } sync_cursor_from_dom(&mut doc, editor_id, ¶s); - let spans = syntax_spans(); let cursor_offset = doc.cursor.read().offset; let selection = *doc.selection.read(); - update_syntax_visibility( - cursor_offset, - selection.as_ref(), - &spans, - ¶s, - ); + update_syntax_visibility(cursor_offset, selection.as_ref(), &spans, ¶s); } }, @@ -1367,7 +1359,7 @@ fn MarkdownEditorInner( if !final_text.is_empty() { let mut delete_start = comp.start_offset; while delete_start > 0 { - match get_char_at(doc.loro_text(), delete_start - 1) { + match doc.char_at(delete_start - 1) { Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, _ => break, } @@ -1569,140 +1561,3 @@ fn MarkdownEditorInner( } } } - -/// Remote collaborator cursors overlay. -/// -/// Renders cursor indicators for each remote collaborator. -/// Uses the same offset mapping as local cursor restoration. -#[component] -fn RemoteCursors( - presence: Signal, - document: SignalEditorDocument, - render_cache: Signal, -) -> Element { - let presence_read = presence.read(); - let cursor_count = presence_read.collaborators.len(); - let cursors: Vec<_> = presence_read - .collaborators - .iter() - .filter_map(|c| { - c.cursor_position - .map(|pos| (c.display_name.clone(), c.color, pos, c.selection)) - }) - .collect(); - - if cursor_count > 0 { - tracing::debug!( - "RemoteCursors: {} collaborators, {} with cursors", - cursor_count, - cursors.len() - ); - } - - if cursors.is_empty() { - return rsx! {}; - } - - // Get flattened offset map from all paragraphs - let cache = render_cache.read(); - let offset_map: Vec<_> = cache - .paragraphs - .iter() - .flat_map(|p| p.offset_map.iter().cloned()) - .collect(); - - rsx! { - div { class: "remote-cursors-overlay", - for (display_name, color, position, selection) in cursors { - RemoteCursorIndicator { - key: "{display_name}-{position}", - display_name, - position, - selection, - color, - offset_map: offset_map.clone(), - } - } - } - } -} - -/// Single remote cursor indicator with DOM-based positioning. -#[component] -fn RemoteCursorIndicator( - display_name: String, - position: usize, - selection: Option<(usize, usize)>, - color: u32, - offset_map: Vec, -) -> Element { - use weaver_editor_browser::{ - get_cursor_rect_relative, get_selection_rects_relative, rgba_u32_to_css, - rgba_u32_to_css_alpha, - }; - - let color_css = rgba_u32_to_css(color); - let selection_color_css = rgba_u32_to_css_alpha(color, 0.25); - - // Get cursor position relative to editor - let rect = get_cursor_rect_relative(position, &offset_map, "markdown-editor"); - - // Get selection rectangles if there's a selection - let selection_rects = if let Some((start, end)) = selection { - let (start, end) = if start <= end { - (start, end) - } else { - (end, start) - }; - get_selection_rects_relative(start, end, &offset_map, "markdown-editor") - } else { - vec![] - }; - - let Some(rect) = rect else { - tracing::debug!( - "RemoteCursorIndicator: no rect for position {} (offset_map len: {})", - position, - offset_map.len() - ); - return rsx! {}; - }; - - tracing::trace!( - "RemoteCursorIndicator: {} at ({}, {}) h={}, selection_rects={}", - display_name, - rect.x, - rect.y, - rect.height, - selection_rects.len() - ); - - let style = format!( - "left: {}px; top: {}px; --cursor-height: {}px; --cursor-color: {};", - rect.x, rect.y, rect.height, color_css - ); - - rsx! { - // Selection highlight rectangles (rendered behind cursor) - for (i, sel_rect) in selection_rects.iter().enumerate() { - div { - key: "sel-{i}", - class: "remote-selection", - 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};", - } - } - - div { - class: "remote-cursor", - style: "{style}", - - // Cursor caret line - div { class: "remote-cursor-caret" } - - // Name label - div { class: "remote-cursor-label", - "{display_name}" - } - } - } -} diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs index 7d6e18c..8972ce4 100644 --- a/crates/weaver-app/src/components/editor/document.rs +++ b/crates/weaver-app/src/components/editor/document.rs @@ -1131,4 +1131,41 @@ impl EditorDocument for SignalEditorDocument { fn set_composition(&mut self, composition: Option) { self.composition.set(composition); } + + fn undo(&mut self) -> bool { + // Sync Loro cursor to current position BEFORE undo + // so it tracks through the undo operation. + self.sync_loro_cursor(); + + let result = self.buffer.undo(); + if result { + // After undo, query Loro cursor for new position. + self.sync_cursor_from_loro(); + // Signal content change for re-render. + self.content_changed.set(()); + } + result + } + + fn redo(&mut self) -> bool { + // Sync Loro cursor to current position BEFORE redo. + self.sync_loro_cursor(); + + let result = self.buffer.redo(); + if result { + // After redo, query Loro cursor for new position. + self.sync_cursor_from_loro(); + // Signal content change for re-render. + self.content_changed.set(()); + } + result + } + + fn pending_snap(&self) -> Option { + *self.pending_snap.read() + } + + fn set_pending_snap(&mut self, snap: Option) { + self.pending_snap.set(snap); + } } diff --git a/crates/weaver-app/src/components/editor/input.rs b/crates/weaver-app/src/components/editor/input.rs deleted file mode 100644 index d059296..0000000 --- a/crates/weaver-app/src/components/editor/input.rs +++ /dev/null @@ -1,601 +0,0 @@ -//! Input handling for the markdown editor. -//! -//! Keyboard events, clipboard operations, and text manipulation. - -use dioxus::prelude::*; - -use super::document::SignalEditorDocument; -use weaver_editor_core::{FormatAction, SnapDirection, apply_formatting}; - -// Re-export ListContext from core - the logic is duplicated below for Loro-specific usage, -// but the type itself comes from core. -pub use weaver_editor_core::ListContext; - -// Re-export clipboard helpers from browser crate. -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -pub use weaver_editor_browser::{copy_as_html, write_clipboard_with_custom_type}; - -/// Check if we need to intercept this key event. -/// Returns true for content-modifying operations, false for navigation. -#[allow(unused)] -pub fn should_intercept_key(evt: &Event) -> bool { - use dioxus::prelude::keyboard_types::Key; - - let key = evt.key(); - let mods = evt.modifiers(); - - // Handle Ctrl/Cmd shortcuts - if mods.ctrl() || mods.meta() { - if let Key::Character(ch) = &key { - // Intercept our shortcuts: formatting (b/i), undo/redo (z/y), HTML export (e) - match ch.as_str() { - "b" | "i" | "z" | "y" => return true, - "e" => return true, // Ctrl+E for HTML export/copy - _ => {} - } - } - // Intercept Cmd+Backspace (delete to start of line) and Cmd+Delete (delete to end) - if matches!(key, Key::Backspace | Key::Delete) { - return true; - } - // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.) - return false; - } - - // Intercept content modifications - matches!( - key, - Key::Character(_) | Key::Backspace | Key::Delete | Key::Enter | Key::Tab - ) -} - -/// Handle keyboard events and update document state. -#[allow(unused)] -pub fn handle_keydown(evt: Event, doc: &mut SignalEditorDocument) { - use dioxus::prelude::keyboard_types::Key; - - let key = evt.key(); - let mods = evt.modifiers(); - - match key { - Key::Character(ch) => { - // Keyboard shortcuts first - if mods.ctrl() { - match ch.as_str() { - "b" => { - apply_formatting(doc, FormatAction::Bold); - return; - } - "i" => { - apply_formatting(doc, FormatAction::Italic); - return; - } - "z" => { - if mods.shift() { - // Ctrl+Shift+Z = redo - if let Ok(true) = doc.redo() { - let max = doc.len_chars(); - doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); - } - } else { - // Ctrl+Z = undo - if let Ok(true) = doc.undo() { - let max = doc.len_chars(); - doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); - } - } - doc.selection.set(None); - return; - } - "y" => { - // Ctrl+Y = redo (alternative) - if let Ok(true) = doc.redo() { - let max = doc.len_chars(); - doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); - } - doc.selection.set(None); - return; - } - "e" => { - // Ctrl+E = copy as HTML (export) - if let Some(sel) = *doc.selection.read() { - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - if start != end { - if let Some(markdown) = doc.slice(start, end) { - let clean_md = - markdown.replace('\u{200C}', "").replace('\u{200B}', ""); - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - wasm_bindgen_futures::spawn_local(async move { - if let Err(e) = copy_as_html(&clean_md).await { - tracing::warn!("[COPY HTML] Failed: {:?}", e); - } - }); - } - } - } - return; - } - _ => {} - } - } - - // Insert character at cursor (replacing selection if any) - let sel = doc.selection.write().take(); - if let Some(sel) = sel { - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - let _ = doc.replace_tracked(start, end.saturating_sub(start), &ch); - doc.cursor.write().offset = start + ch.chars().count(); - } else { - // Clean up any preceding zero-width chars (gap scaffolding) - let cursor_offset = doc.cursor.read().offset; - let mut delete_start = cursor_offset; - while delete_start > 0 { - match get_char_at(doc.loro_text(), delete_start - 1) { - Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, - _ => break, - } - } - - let zw_count = cursor_offset - delete_start; - if zw_count > 0 { - // Splice: delete zero-width chars and insert new char in one op - let _ = doc.replace_tracked(delete_start, zw_count, &ch); - doc.cursor.write().offset = delete_start + ch.chars().count(); - } else if cursor_offset == doc.len_chars() { - // Fast path: append at end - let _ = doc.push_tracked(&ch); - doc.cursor.write().offset = cursor_offset + ch.chars().count(); - } else { - let _ = doc.insert_tracked(cursor_offset, &ch); - doc.cursor.write().offset = cursor_offset + ch.chars().count(); - } - } - } - - Key::Backspace => { - // Snap backward after backspace (toward deleted content) - doc.pending_snap.set(Some(SnapDirection::Backward)); - - let sel = doc.selection.write().take(); - if let Some(sel) = sel { - // Delete selection - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - let _ = doc.remove_tracked(start, end.saturating_sub(start)); - doc.cursor.write().offset = start; - } else if doc.cursor.read().offset > 0 { - let cursor_offset = doc.cursor.read().offset; - - // Cmd+Backspace: delete to start of line - if mods.meta() || mods.ctrl() { - let line_start = find_line_start(doc.loro_text(), cursor_offset); - if line_start < cursor_offset { - let _ = doc.remove_tracked(line_start, cursor_offset - line_start); - doc.cursor.write().offset = line_start; - } - return; - } - - // Check if we're about to delete a newline - let prev_char = get_char_at(doc.loro_text(), cursor_offset - 1); - - if prev_char == Some('\n') { - let newline_pos = cursor_offset - 1; - let mut delete_start = newline_pos; - let mut delete_end = cursor_offset; - - // Check if there's another newline before this one (empty paragraph) - // If so, delete both newlines to merge paragraphs - if newline_pos > 0 { - let prev_prev_char = get_char_at(doc.loro_text(), newline_pos - 1); - if prev_prev_char == Some('\n') { - // Empty paragraph case: delete both newlines - delete_start = newline_pos - 1; - } - } - - // Also check if there's a zero-width char after cursor (inserted by Shift+Enter) - if let Some(ch) = get_char_at(doc.loro_text(), delete_end) { - if ch == '\u{200C}' || ch == '\u{200B}' { - delete_end += 1; - } - } - - // Scan backwards through whitespace before the newline(s) - while delete_start > 0 { - let ch = get_char_at(doc.loro_text(), delete_start - 1); - match ch { - Some('\u{200C}') | Some('\u{200B}') => { - delete_start -= 1; - } - Some('\n') => break, // stop at another newline - _ => break, // stop at actual content - } - } - - // Delete from where we stopped to end (including any trailing zero-width) - let _ = - doc.remove_tracked(delete_start, delete_end.saturating_sub(delete_start)); - doc.cursor.write().offset = delete_start; - } else { - // Normal backspace - delete one char - let prev = cursor_offset - 1; - let _ = doc.remove_tracked(prev, 1); - doc.cursor.write().offset = prev; - } - } - } - - Key::Delete => { - // Snap forward after delete (toward remaining content) - doc.pending_snap.set(Some(SnapDirection::Forward)); - - let sel = doc.selection.write().take(); - if let Some(sel) = sel { - // Delete selection - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - let _ = doc.remove_tracked(start, end.saturating_sub(start)); - doc.cursor.write().offset = start; - } else { - let cursor_offset = doc.cursor.read().offset; - let doc_len = doc.len_chars(); - - // Cmd+Delete: delete to end of line - if mods.meta() || mods.ctrl() { - let line_end = find_line_end(doc.loro_text(), cursor_offset); - if cursor_offset < line_end { - let _ = doc.remove_tracked(cursor_offset, line_end - cursor_offset); - } - return; - } - - if cursor_offset < doc_len { - // Delete next char - let _ = doc.remove_tracked(cursor_offset, 1); - } - } - } - - // Arrow keys handled by browser, synced in onkeyup - Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => { - // Browser handles these naturally - } - - Key::Enter => { - // Snap forward after enter (into new paragraph/line) - doc.pending_snap.set(Some(SnapDirection::Forward)); - - let sel = doc.selection.write().take(); - if let Some(sel) = sel { - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - let _ = doc.remove_tracked(start, end.saturating_sub(start)); - doc.cursor.write().offset = start; - } - - let cursor_offset = doc.cursor.read().offset; - if mods.shift() { - // Shift+Enter: hard line break (soft break) - let _ = doc.insert_tracked(cursor_offset, " \n\u{200C}"); - doc.cursor.write().offset = cursor_offset + 3; - } else if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) { - // We're in a list item - if is_list_item_empty(doc.loro_text(), cursor_offset, &ctx) { - // Empty item - exit list by removing marker and inserting paragraph break - let line_start = find_line_start(doc.loro_text(), cursor_offset); - let line_end = find_line_end(doc.loro_text(), cursor_offset); - - // Delete the empty list item line INCLUDING its trailing newline - // line_end points to the newline, so +1 to include it - let delete_end = (line_end + 1).min(doc.len_chars()); - - // Use replace_tracked to atomically delete line and insert paragraph break - let _ = doc.replace_tracked( - line_start, - delete_end.saturating_sub(line_start), - "\n\n\u{200C}\n", - ); - doc.cursor.write().offset = line_start + 2; - } else { - // Non-empty item - continue list - let continuation = match ctx { - ListContext::Unordered { indent, marker } => { - format!("\n{}{} ", indent, marker) - } - ListContext::Ordered { indent, number } => { - format!("\n{}{}. ", indent, number + 1) - } - }; - let len = continuation.chars().count(); - let _ = doc.insert_tracked(cursor_offset, &continuation); - doc.cursor.write().offset = cursor_offset + len; - } - } else { - // Not in a list - normal paragraph break - let _ = doc.insert_tracked(cursor_offset, "\n\n"); - doc.cursor.write().offset = cursor_offset + 2; - } - } - - // Home/End handled by browser, synced in onkeyup - Key::Home | Key::End => { - // Browser handles these naturally - } - - _ => {} - } - - // Sync Loro cursor when edits affect paragraph boundaries - // This ensures cursor position is tracked correctly through structural changes - if doc.last_edit().is_some_and(|e| e.contains_newline) { - doc.sync_loro_cursor(); - } -} - -/// Handle paste events and insert text at cursor. -pub fn handle_paste(evt: Event, doc: &mut SignalEditorDocument) { - evt.prevent_default(); - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - let _ = doc; - - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - { - use dioxus::web::WebEventExt; - use wasm_bindgen::JsCast; - - let base_evt = evt.as_web_event(); - if let Some(clipboard_evt) = base_evt.dyn_ref::() { - if let Some(data_transfer) = clipboard_evt.clipboard_data() { - // Try our custom type first (internal paste), fall back to text/plain - let text = data_transfer - .get_data("text/x-weaver-md") - .ok() - .filter(|s| !s.is_empty()) - .or_else(|| data_transfer.get_data("text/plain").ok()); - - if let Some(text) = text { - // Delete selection if present - let sel = doc.selection.write().take(); - if let Some(sel) = sel { - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - let _ = doc.remove_tracked(start, end.saturating_sub(start)); - doc.cursor.write().offset = start; - } - - // Insert pasted text - let cursor_offset = doc.cursor.read().offset; - let _ = doc.insert_tracked(cursor_offset, &text); - doc.cursor.write().offset = cursor_offset + text.chars().count(); - } - } - } else { - tracing::warn!("[PASTE] Failed to cast to ClipboardEvent"); - } - } -} - -/// Handle cut events - extract text, write to clipboard, then delete. -pub fn handle_cut(evt: Event, doc: &mut SignalEditorDocument) { - evt.prevent_default(); - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - let _ = doc; - - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - { - use dioxus::web::WebEventExt; - use wasm_bindgen::JsCast; - - let base_evt = evt.as_web_event(); - if let Some(clipboard_evt) = base_evt.dyn_ref::() { - let cut_text = { - let sel = doc.selection.write().take(); - if let Some(sel) = sel { - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - if start != end { - // Extract text and strip zero-width chars - let selected_text = doc.slice(start, end).unwrap_or_default(); - let clean_text = selected_text - .replace('\u{200C}', "") - .replace('\u{200B}', ""); - - // Write to clipboard BEFORE deleting (sync fallback) - if let Some(data_transfer) = clipboard_evt.clipboard_data() { - if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { - tracing::warn!("[CUT] Failed to set clipboard data: {:?}", e); - } - } - - // Now delete - let _ = doc.remove_tracked(start, end.saturating_sub(start)); - doc.cursor.write().offset = start; - - Some(clean_text) - } else { - None - } - } else { - None - } - }; - - // Async: also write custom MIME type for internal paste detection - if let Some(text) = cut_text { - wasm_bindgen_futures::spawn_local(async move { - if let Err(e) = write_clipboard_with_custom_type(&text).await { - tracing::debug!("[CUT] Async clipboard write failed: {:?}", e); - } - }); - } - } - } - - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - { - let _ = evt; // suppress unused warning - } -} - -/// Handle copy events - extract text, clean it up, write to clipboard. -pub fn handle_copy(evt: Event, doc: &SignalEditorDocument) { - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - { - use dioxus::web::WebEventExt; - use wasm_bindgen::JsCast; - - let base_evt = evt.as_web_event(); - if let Some(clipboard_evt) = base_evt.dyn_ref::() { - let sel = *doc.selection.read(); - if let Some(sel) = sel { - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - if start != end { - // Extract text - let selected_text = doc.slice(start, end).unwrap_or_default(); - - // Strip zero-width chars used for gap handling - let clean_text = selected_text - .replace('\u{200C}', "") - .replace('\u{200B}', ""); - - // Sync fallback: write text/plain via DataTransfer - if let Some(data_transfer) = clipboard_evt.clipboard_data() { - if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { - tracing::warn!("[COPY] Failed to set clipboard data: {:?}", e); - } - } - - // Async: also write custom MIME type for internal paste detection - let text_for_async = clean_text.clone(); - wasm_bindgen_futures::spawn_local(async move { - if let Err(e) = write_clipboard_with_custom_type(&text_for_async).await { - tracing::debug!("[COPY] Async clipboard write failed: {:?}", e); - } - }); - - // Prevent browser's default copy (which would copy rendered HTML) - evt.prevent_default(); - } - } - } - } - - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - { - let _ = (evt, doc); // suppress unused warnings - } -} - -/// Detect if cursor is in a list item and return context for continuation. -/// -/// Scans backwards to find start of current line, then checks for list marker. -pub fn detect_list_context(text: &loro::LoroText, cursor_offset: usize) -> Option { - // Find start of current line - let line_start = find_line_start(text, cursor_offset); - - // Get the line content from start to cursor - let line_end = find_line_end(text, cursor_offset); - if line_start >= line_end { - return None; - } - - // Extract line text - let line = text.slice(line_start, line_end).ok()?; - - // Parse indentation - let indent: String = line - .chars() - .take_while(|c| *c == ' ' || *c == '\t') - .collect(); - let trimmed = &line[indent.len()..]; - - // Check for unordered list marker: "- " or "* " - if trimmed.starts_with("- ") { - return Some(ListContext::Unordered { - indent, - marker: '-', - }); - } - if trimmed.starts_with("* ") { - return Some(ListContext::Unordered { - indent, - marker: '*', - }); - } - - // Check for ordered list marker: "1. ", "2. ", "123. ", etc. - if let Some(dot_pos) = trimmed.find(". ") { - let num_part = &trimmed[..dot_pos]; - if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) { - if let Ok(number) = num_part.parse::() { - return Some(ListContext::Ordered { indent, number }); - } - } - } - - None -} - -/// Check if the current list item is empty (just the marker, no content after cursor). -/// -/// Used to determine whether Enter should continue the list or exit it. -pub fn is_list_item_empty(text: &loro::LoroText, cursor_offset: usize, ctx: &ListContext) -> bool { - let line_start = find_line_start(text, cursor_offset); - let line_end = find_line_end(text, cursor_offset); - - // Get line content - let line = match text.slice(line_start, line_end) { - Ok(s) => s, - Err(_) => return false, - }; - - // Calculate expected marker length - let marker_len = match ctx { - ListContext::Unordered { indent, .. } => indent.len() + 2, // "- " - ListContext::Ordered { indent, number } => { - indent.len() + number.to_string().len() + 2 // "1. " - } - }; - - // Item is empty if line length equals marker length (nothing after marker) - line.len() <= marker_len -} - -/// Get character at the given offset in LoroText. -pub fn get_char_at(text: &loro::LoroText, offset: usize) -> Option { - text.char_at(offset).ok() -} - -/// Find start of line containing offset. -pub fn find_line_start(text: &loro::LoroText, offset: usize) -> usize { - if offset == 0 { - return 0; - } - // Only slice the portion before cursor - let prefix = match text.slice(0, offset) { - Ok(s) => s, - Err(_) => return 0, - }; - prefix - .chars() - .enumerate() - .filter(|(_, c)| *c == '\n') - .last() - .map(|(pos, _)| pos + 1) - .unwrap_or(0) -} - -/// Find end of line containing offset. -pub fn find_line_end(text: &loro::LoroText, offset: usize) -> usize { - let char_len = text.len_unicode(); - if offset >= char_len { - return char_len; - } - // Only slice from cursor to end - let suffix = match text.slice(offset, char_len) { - Ok(s) => s, - Err(_) => return char_len, - }; - suffix - .chars() - .enumerate() - .find(|(_, c)| *c == '\n') - .map(|(i, _)| offset + i) - .unwrap_or(char_len) -} diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index 1707051..9687486 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -5,15 +5,14 @@ //! editing plain markdown text under the hood. mod actions; -mod beforeinput; mod collab; mod component; mod document; mod dom_sync; mod image_upload; -mod input; mod log_buffer; mod publish; +mod remote_cursors; mod report; mod storage; mod sync; @@ -22,9 +21,6 @@ mod toolbar; #[cfg(test)] mod tests; -// Re-export DOM update strategy constant from browser crate. -pub(crate) use weaver_editor_browser::FORCE_INNERHTML_UPDATE; - // Main component pub use component::MarkdownEditor; diff --git a/crates/weaver-app/src/components/editor/remote_cursors.rs b/crates/weaver-app/src/components/editor/remote_cursors.rs new file mode 100644 index 0000000..7244a08 --- /dev/null +++ b/crates/weaver-app/src/components/editor/remote_cursors.rs @@ -0,0 +1,145 @@ +//! Remote collaborator cursor overlays. +//! +//! Renders cursor indicators for each remote collaborator in a real-time +//! editing session. Uses the same offset mapping as local cursor restoration. + +use dioxus::prelude::*; + +use super::document::SignalEditorDocument; + +/// Remote collaborator cursors overlay. +/// +/// Renders cursor indicators for each remote collaborator. +/// Uses the same offset mapping as local cursor restoration. +#[component] +pub fn RemoteCursors( + presence: Signal, + document: SignalEditorDocument, + render_cache: Signal, +) -> Element { + let presence_read = presence.read(); + let cursor_count = presence_read.collaborators.len(); + let cursors: Vec<_> = presence_read + .collaborators + .iter() + .filter_map(|c| { + c.cursor_position + .map(|pos| (c.display_name.clone(), c.color, pos, c.selection)) + }) + .collect(); + + if cursor_count > 0 { + tracing::debug!( + "RemoteCursors: {} collaborators, {} with cursors", + cursor_count, + cursors.len() + ); + } + + if cursors.is_empty() { + return rsx! {}; + } + + // Get flattened offset map from all paragraphs. + let cache = render_cache.read(); + let offset_map: Vec<_> = cache + .paragraphs + .iter() + .flat_map(|p| p.offset_map.iter().cloned()) + .collect(); + + rsx! { + div { class: "remote-cursors-overlay", + for (display_name, color, position, selection) in cursors { + RemoteCursorIndicator { + key: "{display_name}-{position}", + display_name, + position, + selection, + color, + offset_map: offset_map.clone(), + } + } + } + } +} + +/// Single remote cursor indicator with DOM-based positioning. +#[component] +fn RemoteCursorIndicator( + display_name: String, + position: usize, + selection: Option<(usize, usize)>, + color: u32, + offset_map: Vec, +) -> Element { + use weaver_editor_browser::{ + get_cursor_rect_relative, get_selection_rects_relative, rgba_u32_to_css, + rgba_u32_to_css_alpha, + }; + + let color_css = rgba_u32_to_css(color); + let selection_color_css = rgba_u32_to_css_alpha(color, 0.25); + + // Get cursor position relative to editor. + let rect = get_cursor_rect_relative(position, &offset_map, "markdown-editor"); + + // Get selection rectangles if there's a selection. + let selection_rects = if let Some((start, end)) = selection { + let (start, end) = if start <= end { + (start, end) + } else { + (end, start) + }; + get_selection_rects_relative(start, end, &offset_map, "markdown-editor") + } else { + vec![] + }; + + let Some(rect) = rect else { + tracing::debug!( + "RemoteCursorIndicator: no rect for position {} (offset_map len: {})", + position, + offset_map.len() + ); + return rsx! {}; + }; + + tracing::trace!( + "RemoteCursorIndicator: {} at ({}, {}) h={}, selection_rects={}", + display_name, + rect.x, + rect.y, + rect.height, + selection_rects.len() + ); + + let style = format!( + "left: {}px; top: {}px; --cursor-height: {}px; --cursor-color: {};", + rect.x, rect.y, rect.height, color_css + ); + + rsx! { + // Selection highlight rectangles (rendered behind cursor). + for (i, sel_rect) in selection_rects.iter().enumerate() { + div { + key: "sel-{i}", + class: "remote-selection", + 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};", + } + } + + div { + class: "remote-cursor", + style: "{style}", + + // Cursor caret line. + div { class: "remote-cursor-caret" } + + // Name label. + div { class: "remote-cursor-label", + "{display_name}" + } + } + } +} diff --git a/crates/weaver-app/src/components/editor/sync.rs b/crates/weaver-app/src/components/editor/sync.rs index 5b6d7b1..841c35e 100644 --- a/crates/weaver-app/src/components/editor/sync.rs +++ b/crates/weaver-app/src/components/editor/sync.rs @@ -14,7 +14,7 @@ use crate::fetch::Fetcher; use jacquard::IntoStatic; use jacquard::prelude::*; use jacquard::types::ident::AtIdentifier; -use jacquard::types::string::{AtUri, Cid, Did}; +use jacquard::types::string::{AtUri, Cid}; use loro::LoroDoc; use loro::ToJson; use weaver_api::com_atproto::repo::strong_ref::StrongRef; @@ -24,9 +24,8 @@ use weaver_common::{WeaverError, WeaverExt}; // Re-export crdt sync types for convenience. pub use weaver_editor_crdt::{ - CreateRootResult, PdsEditState, RemoteDraft, SyncResult, build_draft_uri, find_all_edit_roots, - find_diffs_for_root, find_edit_root_for_draft, list_drafts, load_all_edit_states, - load_edit_state_from_draft, load_edit_state_from_entry, + CreateRootResult, PdsEditState, RemoteDraft, SyncResult, build_draft_uri, list_drafts, + load_all_edit_states, load_edit_state_from_draft, load_edit_state_from_entry, }; /// Extract record embeds from a LoroDoc and pre-fetch their rendered content. diff --git a/crates/weaver-editor-browser/Cargo.toml b/crates/weaver-editor-browser/Cargo.toml index 2367aaf..972fa4b 100644 --- a/crates/weaver-editor-browser/Cargo.toml +++ b/crates/weaver-editor-browser/Cargo.toml @@ -62,10 +62,24 @@ features = [ default = [] # Optional reactive state via dioxus-signals (framework-agnostic) dioxus_signals = ["dep:dioxus-signals"] +# Optional Dioxus event handlers for clipboard +dioxus = ["dep:dioxus-core", "dep:dioxus-html", "dep:dioxus-web"] [dependencies.dioxus-signals] version = "0.7" optional = true +[dependencies.dioxus-core] +version = "0.7" +optional = true + +[dependencies.dioxus-html] +version = "0.7" +optional = true + +[dependencies.dioxus-web] +version = "0.7" +optional = true + [dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/crates/weaver-editor-browser/src/clipboard.rs b/crates/weaver-editor-browser/src/clipboard.rs new file mode 100644 index 0000000..97e6eeb --- /dev/null +++ b/crates/weaver-editor-browser/src/clipboard.rs @@ -0,0 +1,191 @@ +//! Browser clipboard implementation. +//! +//! Implements `ClipboardPlatform` for browser environments using the +//! ClipboardEvent's DataTransfer API for sync access and the async +//! Clipboard API for custom MIME types. + +use weaver_editor_core::ClipboardPlatform; + +/// Browser clipboard context wrapping a ClipboardEvent's DataTransfer. +/// +/// Created from a clipboard event (copy, cut, paste) to provide sync +/// clipboard access. Also spawns async tasks for custom MIME types. +pub struct BrowserClipboard { + data_transfer: Option, +} + +impl BrowserClipboard { + /// Create from a ClipboardEvent. + /// + /// Call this in your copy/cut/paste event handler. + pub fn from_event(evt: &web_sys::ClipboardEvent) -> Self { + Self { + data_transfer: evt.clipboard_data(), + } + } + + /// Create an empty clipboard context (for testing or non-event contexts). + pub fn empty() -> Self { + Self { + data_transfer: None, + } + } +} + +impl ClipboardPlatform for BrowserClipboard { + fn write_text(&self, text: &str) { + // Sync write via DataTransfer (immediate fallback). + if let Some(dt) = &self.data_transfer { + if let Err(e) = dt.set_data("text/plain", text) { + tracing::warn!("Clipboard sync write failed: {:?}", e); + } + } + + // Async write for custom MIME type (enables internal paste detection). + let text = text.to_string(); + wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = crate::events::write_clipboard_with_custom_type(&text).await { + tracing::debug!("Clipboard async write failed: {:?}", e); + } + }); + } + + fn write_html(&self, html: &str, plain_text: &str) { + // Sync write of plain text fallback. + if let Some(dt) = &self.data_transfer { + if let Err(e) = dt.set_data("text/plain", plain_text) { + tracing::warn!("Clipboard sync write (plain) failed: {:?}", e); + } + } + + // Async write for HTML. + let html = html.to_string(); + let plain = plain_text.to_string(); + wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = write_html_to_clipboard(&html, &plain).await { + tracing::warn!("Clipboard HTML write failed: {:?}", e); + } + }); + } + + fn read_text(&self) -> Option { + let dt = self.data_transfer.as_ref()?; + + // Try our custom MIME type first (internal paste). + if let Ok(text) = dt.get_data("text/x-weaver-md") { + if !text.is_empty() { + return Some(text); + } + } + + // Fall back to plain text. + dt.get_data("text/plain").ok().filter(|s| !s.is_empty()) + } +} + +/// Write HTML and plain text to clipboard using the async Clipboard API. +/// +/// This uses the navigator.clipboard API and doesn't require a clipboard event. +/// Suitable for keyboard-triggered copy operations like CopyAsHtml. +pub async fn write_html_to_clipboard( + html: &str, + plain_text: &str, +) -> Result<(), wasm_bindgen::JsValue> { + use js_sys::{Array, Object, Reflect}; + use wasm_bindgen::JsValue; + use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; + + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; + let clipboard = window.navigator().clipboard(); + + // Create HTML blob. + let html_parts = Array::new(); + html_parts.push(&JsValue::from_str(html)); + let html_opts = BlobPropertyBag::new(); + html_opts.set_type("text/html"); + let html_blob = Blob::new_with_str_sequence_and_options(&html_parts, &html_opts)?; + + // Create plain text blob. + let text_parts = Array::new(); + text_parts.push(&JsValue::from_str(plain_text)); + let text_opts = BlobPropertyBag::new(); + text_opts.set_type("text/plain"); + let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?; + + // Create ClipboardItem with both types. + let item_data = Object::new(); + Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?; + Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; + + let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; + let items = Array::new(); + items.push(&clipboard_item); + + wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?; + tracing::debug!("Wrote {} bytes of HTML to clipboard", html.len()); + Ok(()) +} + +// === Dioxus event handlers === + +/// Handle a Dioxus paste event. +/// +/// Extracts text from the clipboard event and inserts at cursor. +#[cfg(feature = "dioxus")] +pub fn handle_paste( + evt: dioxus_core::Event, + doc: &mut D, +) { + use dioxus_web::WebEventExt; + use wasm_bindgen::JsCast; + + evt.prevent_default(); + + let base_evt = evt.as_web_event(); + if let Some(clipboard_evt) = base_evt.dyn_ref::() { + let clipboard = BrowserClipboard::from_event(clipboard_evt); + weaver_editor_core::clipboard_paste(doc, &clipboard); + } else { + tracing::warn!("[PASTE] Failed to cast to ClipboardEvent"); + } +} + +/// Handle a Dioxus cut event. +/// +/// Copies selection to clipboard, then deletes it. +#[cfg(feature = "dioxus")] +pub fn handle_cut( + evt: dioxus_core::Event, + doc: &mut D, +) { + use dioxus_web::WebEventExt; + use wasm_bindgen::JsCast; + + evt.prevent_default(); + + let base_evt = evt.as_web_event(); + if let Some(clipboard_evt) = base_evt.dyn_ref::() { + let clipboard = BrowserClipboard::from_event(clipboard_evt); + weaver_editor_core::clipboard_cut(doc, &clipboard); + } +} + +/// Handle a Dioxus copy event. +/// +/// Copies selection to clipboard. Only prevents default if there was a selection. +#[cfg(feature = "dioxus")] +pub fn handle_copy( + evt: dioxus_core::Event, + doc: &D, +) { + use dioxus_web::WebEventExt; + use wasm_bindgen::JsCast; + + let base_evt = evt.as_web_event(); + if let Some(clipboard_evt) = base_evt.dyn_ref::() { + let clipboard = BrowserClipboard::from_event(clipboard_evt); + if weaver_editor_core::clipboard_copy(doc, &clipboard) { + evt.prevent_default(); + } + } +} diff --git a/crates/weaver-editor-browser/src/events.rs b/crates/weaver-editor-browser/src/events.rs index fa1f489..4d64fff 100644 --- a/crates/weaver-editor-browser/src/events.rs +++ b/crates/weaver-editor-browser/src/events.rs @@ -254,52 +254,6 @@ pub async fn read_clipboard_text() -> Result, JsValue> { Ok(result.as_string()) } -/// Copy markdown as rendered HTML to clipboard. -/// -/// Renders the markdown to HTML and writes both text/html and text/plain -/// representations to the clipboard. -pub async fn copy_as_html(markdown: &str) -> Result<(), JsValue> { - use js_sys::{Array, Object, Reflect}; - use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; - - // Render markdown to HTML using ClientWriter. - let parser = weaver_editor_core::markdown_weaver::Parser::new(markdown).into_offset_iter(); - let mut html = String::new(); - weaver_editor_core::weaver_renderer::atproto::ClientWriter::<_, _, ()>::new( - parser, &mut html, markdown, - ) - .run() - .map_err(|e| JsValue::from_str(&format!("render error: {e}")))?; - - let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; - let clipboard = window.navigator().clipboard(); - - // Create blobs for both HTML and plain text. - let parts = Array::new(); - parts.push(&JsValue::from_str(&html)); - - let html_opts = BlobPropertyBag::new(); - html_opts.set_type("text/html"); - let html_blob = Blob::new_with_str_sequence_and_options(&parts, &html_opts)?; - - let text_opts = BlobPropertyBag::new(); - text_opts.set_type("text/plain"); - let text_blob = Blob::new_with_str_sequence_and_options(&parts, &text_opts)?; - - // Create ClipboardItem with both types. - let item_data = Object::new(); - Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?; - Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; - - let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; - let items = Array::new(); - items.push(&clipboard_item); - - wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?; - tracing::info!("[COPY HTML] Success - {} bytes of HTML", html.len()); - Ok(()) -} - // === BeforeInput handler === use crate::FORCE_INNERHTML_UPDATE; @@ -564,3 +518,46 @@ pub fn handle_beforeinput( | InputType::Unknown(_) => BeforeInputResult::PassThrough, } } + +// === Math click handling === + +use weaver_editor_core::{OffsetMapping, SyntaxSpanInfo}; + +/// Check if a click target is a math-clickable element. +/// +/// Returns the target character offset if the click was on a `.math-clickable` +/// element with a valid `data-char-target` attribute, None otherwise. +pub fn get_math_click_offset(target: &web_sys::EventTarget) -> Option { + use wasm_bindgen::JsCast; + + let element = target.dyn_ref::()?; + let math_el = element.closest(".math-clickable").ok()??; + let char_target = math_el.get_attribute("data-char-target")?; + char_target.parse().ok() +} + +/// Handle a click that might be on a math element. +/// +/// If the click target is a math-clickable element, this updates the cursor, +/// clears selection, updates visibility, and restores the DOM cursor position. +/// +/// Returns true if the click was handled (was on a math element), false otherwise. +/// When this returns false, the caller should handle the click normally. +pub fn handle_math_click( + target: &web_sys::EventTarget, + doc: &mut D, + syntax_spans: &[SyntaxSpanInfo], + paragraphs: &[ParagraphRender], + offset_map: &[OffsetMapping], +) -> bool { + if let Some(offset) = get_math_click_offset(target) { + tracing::debug!("math-clickable clicked, moving cursor to {}", offset); + doc.set_cursor_offset(offset); + doc.set_selection(None); + crate::update_syntax_visibility(offset, None, syntax_spans, paragraphs); + let _ = crate::restore_cursor_position(offset, offset_map, None); + true + } else { + false + } +} diff --git a/crates/weaver-editor-browser/src/lib.rs b/crates/weaver-editor-browser/src/lib.rs index 1c3dda1..3e1f2c3 100644 --- a/crates/weaver-editor-browser/src/lib.rs +++ b/crates/weaver-editor-browser/src/lib.rs @@ -47,6 +47,7 @@ pub use weaver_editor_core::*; /// Set to `true` for maximum control, `false` for smoother typing experience. pub const FORCE_INNERHTML_UPDATE: bool = true; +pub mod clipboard; pub mod color; pub mod cursor; pub mod dom_sync; @@ -68,10 +69,10 @@ pub use dom_sync::{ // Event handling pub use events::{ - BeforeInputContext, BeforeInputResult, StaticRange, copy_as_html, get_current_range, - get_data_from_event, get_input_type_from_event, get_target_range_from_event, - handle_beforeinput, is_composing, parse_browser_input_type, read_clipboard_text, - write_clipboard_with_custom_type, + BeforeInputContext, BeforeInputResult, StaticRange, get_current_range, get_data_from_event, + get_input_type_from_event, get_math_click_offset, get_target_range_from_event, + handle_beforeinput, handle_math_click, is_composing, parse_browser_input_type, + read_clipboard_text, write_clipboard_with_custom_type, }; // Platform detection @@ -82,3 +83,8 @@ pub use visibility::update_syntax_visibility; // Color utilities pub use color::{rgba_u32_to_css, rgba_u32_to_css_alpha}; + +// Clipboard +pub use clipboard::{BrowserClipboard, write_html_to_clipboard}; +#[cfg(feature = "dioxus")] +pub use clipboard::{handle_copy, handle_cut, handle_paste}; diff --git a/crates/weaver-editor-browser/src/visibility.rs b/crates/weaver-editor-browser/src/visibility.rs index 654cb33..b855e27 100644 --- a/crates/weaver-editor-browser/src/visibility.rs +++ b/crates/weaver-editor-browser/src/visibility.rs @@ -19,7 +19,7 @@ //! } //! ``` -use weaver_editor_core::{ParagraphRender, Selection, SyntaxSpanInfo, VisibilityState}; +use weaver_editor_core::{ParagraphRender, Selection, SyntaxSpanInfo}; /// Update syntax span visibility in the DOM based on cursor position. /// @@ -43,7 +43,12 @@ pub fn update_syntax_visibility( ) { use wasm_bindgen::JsCast; - let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs); + let visibility = weaver_editor_core::VisibilityState::calculate( + cursor_offset, + selection, + syntax_spans, + paragraphs, + ); let Some(window) = web_sys::window() else { return; diff --git a/crates/weaver-editor-core/src/document.rs b/crates/weaver-editor-core/src/document.rs index ee79d32..d28bcb0 100644 --- a/crates/weaver-editor-core/src/document.rs +++ b/crates/weaver-editor-core/src/document.rs @@ -64,6 +64,19 @@ pub trait EditorDocument { /// Set the composition state. fn set_composition(&mut self, composition: Option); + // === Required: Cursor snap hint === + + /// Get the pending snap direction hint. + /// + /// This hints which direction the cursor should snap after an edit + /// when the cursor lands on invisible syntax. Forward for insertions + /// (snap toward new content), backward for deletions (snap toward + /// remaining content). + fn pending_snap(&self) -> Option; + + /// Set the pending snap direction hint. + fn set_pending_snap(&mut self, snap: Option); + // === Provided: Convenience accessors === /// Get the cursor offset. @@ -266,6 +279,7 @@ pub struct PlainEditor { selection: Option, last_edit: Option, composition: Option, + pending_snap: Option, } impl Default for PlainEditor { @@ -283,6 +297,7 @@ impl PlainEditor { selection: None, last_edit: None, composition: None, + pending_snap: None, } } @@ -339,6 +354,14 @@ impl EditorDocument for PlainEditor { fn set_composition(&mut self, composition: Option) { self.composition = composition; } + + fn pending_snap(&self) -> Option { + self.pending_snap + } + + fn set_pending_snap(&mut self, snap: Option) { + self.pending_snap = snap; + } } #[cfg(test)] diff --git a/crates/weaver-editor-core/src/execute.rs b/crates/weaver-editor-core/src/execute.rs index 8bf17b6..189fb97 100644 --- a/crates/weaver-editor-core/src/execute.rs +++ b/crates/weaver-editor-core/src/execute.rs @@ -4,19 +4,54 @@ //! operations to any type implementing `EditorDocument`. The logic is generic //! and platform-agnostic. +use crate::SnapDirection; use crate::actions::{EditorAction, FormatAction, Range}; use crate::document::EditorDocument; +use crate::platform::{ClipboardPlatform, clipboard_copy, clipboard_cut, clipboard_paste}; use crate::text_helpers::{ ListContext, detect_list_context, find_line_end, find_line_start, find_word_boundary_backward, find_word_boundary_forward, is_list_item_empty, }; use crate::types::Selection; +/// Determine the cursor snap direction hint for an action. +/// +/// Forward means cursor should snap toward new/remaining content (insertions). +/// Backward means cursor should snap toward content before edit (deletions). +pub fn snap_direction_for_action(action: &EditorAction) -> Option { + match action { + // Forward: cursor should snap toward new/remaining content. + EditorAction::InsertLineBreak { .. } + | EditorAction::InsertParagraph { .. } + | EditorAction::DeleteForward { .. } + | EditorAction::DeleteWordForward { .. } + | EditorAction::DeleteToLineEnd { .. } + | EditorAction::DeleteSoftLineForward { .. } => Some(SnapDirection::Forward), + + // Backward: cursor should snap toward content before edit. + EditorAction::DeleteBackward { .. } + | EditorAction::DeleteWordBackward { .. } + | EditorAction::DeleteToLineStart { .. } + | EditorAction::DeleteSoftLineBackward { .. } => Some(SnapDirection::Backward), + + _ => None, + } +} + /// Execute an editor action on a document. /// /// This is the central dispatch point for all editor operations. +/// Sets the appropriate snap direction hint before executing. /// Returns true if the action was handled and the document was modified. +/// +/// Note: Clipboard operations (Cut, Copy, CopyAsHtml, Paste) return false here. +/// Use [`execute_action_with_clipboard`] if you have a clipboard platform available. pub fn execute_action(doc: &mut D, action: &EditorAction) -> bool { + // Set pending snap direction before executing action. + if let Some(snap) = snap_direction_for_action(action) { + doc.set_pending_snap(Some(snap)); + } + match action { EditorAction::Insert { text, range } => execute_insert(doc, text, *range), EditorAction::InsertLineBreak { range } => execute_insert_line_break(doc, *range), @@ -41,11 +76,11 @@ pub fn execute_action(doc: &mut D, action: &EditorAction) -> EditorAction::ToggleStrikethrough => execute_toggle_format(doc, "~~"), EditorAction::InsertLink => execute_insert_link(doc), EditorAction::Cut | EditorAction::Copy | EditorAction::CopyAsHtml => { - // Clipboard operations are handled by platform layer. + // Clipboard operations need platform - use execute_action_with_clipboard. false } EditorAction::Paste { range: _ } => { - // Paste is handled by platform layer with clipboard access. + // Paste needs platform - use execute_action_with_clipboard. false } EditorAction::SelectAll => execute_select_all(doc), @@ -54,6 +89,25 @@ pub fn execute_action(doc: &mut D, action: &EditorAction) -> } } +/// Execute an editor action with clipboard support. +/// +/// Like [`execute_action`], but also handles clipboard operations (Cut, Copy, Paste, CopyAsHtml) +/// using the provided platform implementation. +pub fn execute_action_with_clipboard(doc: &mut D, action: &EditorAction, clipboard: &P) -> bool +where + D: EditorDocument, + P: ClipboardPlatform, +{ + match action { + EditorAction::Copy => clipboard_copy(doc, clipboard), + EditorAction::Cut => clipboard_cut(doc, clipboard), + EditorAction::Paste { range: _ } => clipboard_paste(doc, clipboard), + EditorAction::CopyAsHtml => crate::platform::clipboard_copy_as_html(doc, clipboard), + // Delegate everything else to the regular execute_action. + _ => execute_action(doc, action), + } +} + fn execute_insert(doc: &mut D, text: &str, range: Range) -> bool { let range = range.normalize(); @@ -553,6 +607,72 @@ fn list_continuation(ctx: &ListContext) -> String { } } +// === Keydown handling === + +use crate::actions::{KeyCombo, KeybindingConfig, KeydownResult}; + +/// Handle a keydown event using the keybinding configuration. +/// +/// This handles keyboard shortcuts only. Text input and deletion +/// are handled by beforeinput. Navigation (arrows, etc.) is passed +/// through to the browser/platform. +/// +/// For clipboard operations, use [`handle_keydown_with_clipboard`] instead. +pub fn handle_keydown( + doc: &mut D, + config: &KeybindingConfig, + combo: KeyCombo, + range: Range, +) -> KeydownResult { + // Look up keybinding (range is applied by lookup). + if let Some(action) = config.lookup(&combo, range) { + execute_action(doc, &action); + return KeydownResult::Handled; + } + + check_passthrough(&combo) +} + +/// Handle a keydown event with clipboard support. +/// +/// Like [`handle_keydown`], but uses the provided clipboard platform +/// for clipboard operations (Cut, Copy, Paste, CopyAsHtml). +pub fn handle_keydown_with_clipboard( + doc: &mut D, + config: &KeybindingConfig, + combo: KeyCombo, + range: Range, + clipboard: &P, +) -> KeydownResult +where + D: EditorDocument, + P: ClipboardPlatform, +{ + // Look up keybinding (range is applied by lookup). + if let Some(action) = config.lookup(&combo, range) { + execute_action_with_clipboard(doc, &action, clipboard); + return KeydownResult::Handled; + } + + check_passthrough(&combo) +} + +/// Check if a key combo should pass through to the platform. +fn check_passthrough(combo: &KeyCombo) -> KeydownResult { + // Navigation keys should pass through. + if combo.key.is_navigation() { + return KeydownResult::PassThrough; + } + + // Modifier-only keypresses should pass through. + if combo.key.is_modifier() { + return KeydownResult::PassThrough; + } + + // Content keys (typing, backspace, etc.) - let beforeinput handle. + KeydownResult::NotHandled +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/weaver-editor-core/src/lib.rs b/crates/weaver-editor-core/src/lib.rs index c1898d1..68ede1f 100644 --- a/crates/weaver-editor-core/src/lib.rs +++ b/crates/weaver-editor-core/src/lib.rs @@ -42,12 +42,19 @@ pub use render::{EmbedContentProvider, ImageResolver, WikilinkValidator}; pub use undo::{UndoManager, UndoableBuffer}; pub use visibility::VisibilityState; pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult}; -pub use platform::{CursorPlatform, CursorSync, PlatformError}; +pub use platform::{ + ClipboardPlatform, CursorPlatform, CursorSync, PlatformError, clipboard_copy, + clipboard_copy_as_html, clipboard_cut, clipboard_paste, render_markdown_to_html, + strip_zero_width, +}; pub use actions::{ EditorAction, FormatAction, InputType, Key, KeyCombo, KeybindingConfig, KeydownResult, Modifiers, Range, }; -pub use execute::{apply_formatting, execute_action}; +pub use execute::{ + apply_formatting, execute_action, execute_action_with_clipboard, handle_keydown, + handle_keydown_with_clipboard, snap_direction_for_action, +}; pub use text_helpers::{ ListContext, count_leading_zero_width, detect_list_context, find_line_end, find_line_start, find_word_boundary_backward, find_word_boundary_forward, is_list_item_empty, diff --git a/crates/weaver-editor-core/src/platform.rs b/crates/weaver-editor-core/src/platform.rs index a01ffe2..3dcf149 100644 --- a/crates/weaver-editor-core/src/platform.rs +++ b/crates/weaver-editor-core/src/platform.rs @@ -103,3 +103,173 @@ pub trait CursorSync { F: FnOnce(usize), G: FnOnce(usize, usize); } + +/// Platform-specific clipboard operations. +/// +/// Implementations handle the low-level clipboard access (sync and async paths +/// as appropriate for the platform). Document-level operations (selection +/// extraction, cursor updates) are handled by the `clipboard_*` functions +/// in this module. +pub trait ClipboardPlatform { + /// Write plain text to clipboard. + /// + /// For browsers, implementations should use both the sync DataTransfer API + /// (for immediate fallback) and the async Clipboard API (for custom MIME types). + fn write_text(&self, text: &str); + + /// Write markdown rendered as HTML to clipboard. + /// + /// The `plain_text` is the original markdown, `html` is the rendered output. + /// Both should be written to clipboard with appropriate MIME types. + fn write_html(&self, html: &str, plain_text: &str); + + /// Read text from clipboard. + /// + /// For browsers, this reads from the paste event's DataTransfer. + /// Returns None if no text is available. + fn read_text(&self) -> Option; +} + +/// Strip zero-width characters used for formatting gaps. +/// +/// The editor uses ZWNJ (U+200C) and ZWSP (U+200B) to create cursor positions +/// within invisible formatting syntax. These should be stripped when copying +/// text to the clipboard. +pub fn strip_zero_width(text: &str) -> String { + text.replace('\u{200C}', "").replace('\u{200B}', "") +} + +/// Copy selected text from document to clipboard. +/// +/// Returns true if text was copied, false if no selection. +pub fn clipboard_copy( + doc: &D, + platform: &P, +) -> bool { + let Some(sel) = doc.selection() else { + return false; + }; + + let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end())); + if start == end { + return false; + } + + let Some(text) = doc.slice(start..end) else { + return false; + }; + + let clean_text = strip_zero_width(&text); + platform.write_text(&clean_text); + true +} + +/// Cut selected text from document to clipboard. +/// +/// Copies the selection to clipboard, then deletes it from the document. +/// Returns true if text was cut, false if no selection. +pub fn clipboard_cut( + doc: &mut D, + platform: &P, +) -> bool { + let Some(sel) = doc.selection() else { + return false; + }; + + let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end())); + if start == end { + return false; + } + + let Some(text) = doc.slice(start..end) else { + return false; + }; + + let clean_text = strip_zero_width(&text); + platform.write_text(&clean_text); + + // Delete selection. + doc.delete(start..end); + doc.set_selection(None); + + true +} + +/// Paste text from clipboard into document. +/// +/// Replaces any selection with the pasted text, or inserts at cursor. +/// Returns true if text was pasted, false if clipboard was empty. +pub fn clipboard_paste( + doc: &mut D, + platform: &P, +) -> bool { + let Some(text) = platform.read_text() else { + return false; + }; + + if text.is_empty() { + return false; + } + + // Delete selection if present. + if let Some(sel) = doc.selection() { + let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end())); + if start != end { + doc.delete(start..end); + doc.set_cursor_offset(start); + } + } + doc.set_selection(None); + + // Insert at cursor. + let cursor = doc.cursor_offset(); + doc.insert(cursor, &text); + + true +} + +/// Render markdown to HTML using the ClientWriter. +/// +/// Uses a minimal context with no embed resolution, suitable for clipboard operations. +pub fn render_markdown_to_html(markdown: &str) -> Option { + use crate::markdown_weaver::Parser; + use crate::weaver_renderer::atproto::ClientWriter; + + let parser = Parser::new(markdown).into_offset_iter(); + let mut html = String::new(); + ClientWriter::<_, _, ()>::new(parser, &mut html, markdown) + .run() + .ok()?; + Some(html) +} + +/// Copy selected text as rendered HTML to clipboard. +/// +/// Renders the selected markdown to HTML and writes both representations +/// to the clipboard. Returns true if text was copied, false if no selection. +pub fn clipboard_copy_as_html( + doc: &D, + platform: &P, +) -> bool { + let Some(sel) = doc.selection() else { + return false; + }; + + let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end())); + if start == end { + return false; + } + + let Some(text) = doc.slice(start..end) else { + return false; + }; + + let clean_text = strip_zero_width(&text); + + let Some(html) = render_markdown_to_html(&clean_text) else { + return false; + }; + + platform.write_html(&html, &clean_text); + true +} diff --git a/docs/graph-data.json b/docs/graph-data.json index d24a0fc..6d8a0f6 100644 --- a/docs/graph-data.json +++ b/docs/graph-data.json @@ -1979,6 +1979,28 @@ "created_at": "2026-01-06T17:45:55.076540964-05:00", "updated_at": "2026-01-06T17:45:55.076540964-05:00", "metadata_json": "{\"confidence\":95}" + }, + { + "id": 182, + "change_id": "7a9e3b35-e3c2-4081-8e62-74da292b1c08", + "node_type": "action", + "title": "Split component.rs - extract remote_cursors.rs and platform input handlers to browser crate", + "description": null, + "status": "pending", + "created_at": "2026-01-06T17:48:09.576571460-05:00", + "updated_at": "2026-01-06T17:48:09.576571460-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 183, + "change_id": "9f5f3214-ed3e-4a5b-94bf-3a24ed61b05c", + "node_type": "outcome", + "title": "RemoteCursors extracted to remote_cursors.rs, beforeinput closure lifecycle fixed (proper storage + cleanup), component.rs 1708→1586 lines, 88 tests pass", + "description": null, + "status": "pending", + "created_at": "2026-01-06T17:56:50.444969376-05:00", + "updated_at": "2026-01-06T17:56:50.444969376-05:00", + "metadata_json": "{\"confidence\":95}" } ], "edges": [ @@ -3994,6 +4016,28 @@ "weight": 1.0, "rationale": "Action completed", "created_at": "2026-01-06T17:46:00.638988346-05:00" + }, + { + "id": 185, + "from_node_id": 176, + "to_node_id": 182, + "from_change_id": "f1cb9bd9-9102-44b8-9d99-bae1ef085723", + "to_change_id": "7a9e3b35-e3c2-4081-8e62-74da292b1c08", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Part of editor organization", + "created_at": "2026-01-06T17:48:13.609765445-05:00" + }, + { + "id": 186, + "from_node_id": 182, + "to_node_id": 183, + "from_change_id": "7a9e3b35-e3c2-4081-8e62-74da292b1c08", + "to_change_id": "9f5f3214-ed3e-4a5b-94bf-3a24ed61b05c", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Action completed", + "created_at": "2026-01-06T17:56:55.250053755-05:00" } ] } \ No newline at end of file -- 2.51.2