···11-//! BeforeInput event handling for the editor.
22-//!
33-//! This module provides the primary input handling via the `beforeinput` event,
44-//! which gives us semantic information about what the browser wants to do
55-//! (insert text, delete backward, etc.) rather than raw key codes.
66-//!
77-//! The core logic is in `weaver_editor_browser::handle_beforeinput`. This module
88-//! adds app-specific concerns like `pending_snap` for cursor snapping direction.
99-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1010-use super::document::SignalEditorDocument;
1111-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1212-use dioxus::prelude::*;
1313-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1414-use weaver_editor_core::SnapDirection;
1515-1616-// Re-export types from extracted crates.
1717-pub use weaver_editor_browser::{BeforeInputContext, BeforeInputResult};
1818-pub use weaver_editor_core::InputType;
1919-2020-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
2121-pub use weaver_editor_browser::StaticRange;
2222-2323-/// Determine the cursor snap direction hint for an input type.
2424-///
2525-/// This is used to hint `dom_sync` which direction to snap the cursor if it
2626-/// lands on invisible content after an edit.
2727-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
2828-fn snap_direction_for_input_type(input_type: &InputType) -> Option<SnapDirection> {
2929- match input_type {
3030- // Forward: cursor should snap toward new/remaining content after the edit.
3131- InputType::InsertLineBreak
3232- | InputType::InsertParagraph
3333- | InputType::DeleteContentForward
3434- | InputType::DeleteWordForward
3535- | InputType::DeleteEntireWordForward
3636- | InputType::DeleteSoftLineForward
3737- | InputType::DeleteHardLineForward => Some(SnapDirection::Forward),
3838-3939- // Backward: cursor should snap toward content before the deleted range.
4040- InputType::DeleteContentBackward
4141- | InputType::DeleteWordBackward
4242- | InputType::DeleteEntireWordBackward
4343- | InputType::DeleteSoftLineBackward
4444- | InputType::DeleteHardLineBackward => Some(SnapDirection::Backward),
4545-4646- // No snap hint for other operations.
4747- _ => None,
4848- }
4949-}
5050-5151-/// Handle a beforeinput event.
5252-///
5353-/// This is the main entry point for beforeinput-based input handling.
5454-/// Sets `pending_snap` for cursor snapping, then delegates to the browser crate.
5555-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
5656-pub fn handle_beforeinput(
5757- doc: &mut SignalEditorDocument,
5858- ctx: BeforeInputContext<'_>,
5959-) -> BeforeInputResult {
6060- // Set pending_snap hint before executing the action.
6161- if let Some(snap) = snap_direction_for_input_type(&ctx.input_type) {
6262- doc.pending_snap.set(Some(snap));
6363- }
6464-6565- // Get current range for the browser handler.
6666- let current_range = weaver_editor_browser::get_current_range(doc);
6767-6868- // Delegate to browser crate's generic handler.
6969- weaver_editor_browser::handle_beforeinput(doc, &ctx, current_range)
7070-}
···11311131 fn set_composition(&mut self, composition: Option<CompositionState>) {
11321132 self.composition.set(composition);
11331133 }
11341134+11351135+ fn undo(&mut self) -> bool {
11361136+ // Sync Loro cursor to current position BEFORE undo
11371137+ // so it tracks through the undo operation.
11381138+ self.sync_loro_cursor();
11391139+11401140+ let result = self.buffer.undo();
11411141+ if result {
11421142+ // After undo, query Loro cursor for new position.
11431143+ self.sync_cursor_from_loro();
11441144+ // Signal content change for re-render.
11451145+ self.content_changed.set(());
11461146+ }
11471147+ result
11481148+ }
11491149+11501150+ fn redo(&mut self) -> bool {
11511151+ // Sync Loro cursor to current position BEFORE redo.
11521152+ self.sync_loro_cursor();
11531153+11541154+ let result = self.buffer.redo();
11551155+ if result {
11561156+ // After redo, query Loro cursor for new position.
11571157+ self.sync_cursor_from_loro();
11581158+ // Signal content change for re-render.
11591159+ self.content_changed.set(());
11601160+ }
11611161+ result
11621162+ }
11631163+11641164+ fn pending_snap(&self) -> Option<weaver_editor_core::SnapDirection> {
11651165+ *self.pending_snap.read()
11661166+ }
11671167+11681168+ fn set_pending_snap(&mut self, snap: Option<weaver_editor_core::SnapDirection>) {
11691169+ self.pending_snap.set(snap);
11701170+ }
11341171}
-601
crates/weaver-app/src/components/editor/input.rs
···11-//! Input handling for the markdown editor.
22-//!
33-//! Keyboard events, clipboard operations, and text manipulation.
44-55-use dioxus::prelude::*;
66-77-use super::document::SignalEditorDocument;
88-use weaver_editor_core::{FormatAction, SnapDirection, apply_formatting};
99-1010-// Re-export ListContext from core - the logic is duplicated below for Loro-specific usage,
1111-// but the type itself comes from core.
1212-pub use weaver_editor_core::ListContext;
1313-1414-// Re-export clipboard helpers from browser crate.
1515-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1616-pub use weaver_editor_browser::{copy_as_html, write_clipboard_with_custom_type};
1717-1818-/// Check if we need to intercept this key event.
1919-/// Returns true for content-modifying operations, false for navigation.
2020-#[allow(unused)]
2121-pub fn should_intercept_key(evt: &Event<KeyboardData>) -> bool {
2222- use dioxus::prelude::keyboard_types::Key;
2323-2424- let key = evt.key();
2525- let mods = evt.modifiers();
2626-2727- // Handle Ctrl/Cmd shortcuts
2828- if mods.ctrl() || mods.meta() {
2929- if let Key::Character(ch) = &key {
3030- // Intercept our shortcuts: formatting (b/i), undo/redo (z/y), HTML export (e)
3131- match ch.as_str() {
3232- "b" | "i" | "z" | "y" => return true,
3333- "e" => return true, // Ctrl+E for HTML export/copy
3434- _ => {}
3535- }
3636- }
3737- // Intercept Cmd+Backspace (delete to start of line) and Cmd+Delete (delete to end)
3838- if matches!(key, Key::Backspace | Key::Delete) {
3939- return true;
4040- }
4141- // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.)
4242- return false;
4343- }
4444-4545- // Intercept content modifications
4646- matches!(
4747- key,
4848- Key::Character(_) | Key::Backspace | Key::Delete | Key::Enter | Key::Tab
4949- )
5050-}
5151-5252-/// Handle keyboard events and update document state.
5353-#[allow(unused)]
5454-pub fn handle_keydown(evt: Event<KeyboardData>, doc: &mut SignalEditorDocument) {
5555- use dioxus::prelude::keyboard_types::Key;
5656-5757- let key = evt.key();
5858- let mods = evt.modifiers();
5959-6060- match key {
6161- Key::Character(ch) => {
6262- // Keyboard shortcuts first
6363- if mods.ctrl() {
6464- match ch.as_str() {
6565- "b" => {
6666- apply_formatting(doc, FormatAction::Bold);
6767- return;
6868- }
6969- "i" => {
7070- apply_formatting(doc, FormatAction::Italic);
7171- return;
7272- }
7373- "z" => {
7474- if mods.shift() {
7575- // Ctrl+Shift+Z = redo
7676- if let Ok(true) = doc.redo() {
7777- let max = doc.len_chars();
7878- doc.cursor.with_mut(|c| c.offset = c.offset.min(max));
7979- }
8080- } else {
8181- // Ctrl+Z = undo
8282- if let Ok(true) = doc.undo() {
8383- let max = doc.len_chars();
8484- doc.cursor.with_mut(|c| c.offset = c.offset.min(max));
8585- }
8686- }
8787- doc.selection.set(None);
8888- return;
8989- }
9090- "y" => {
9191- // Ctrl+Y = redo (alternative)
9292- if let Ok(true) = doc.redo() {
9393- let max = doc.len_chars();
9494- doc.cursor.with_mut(|c| c.offset = c.offset.min(max));
9595- }
9696- doc.selection.set(None);
9797- return;
9898- }
9999- "e" => {
100100- // Ctrl+E = copy as HTML (export)
101101- if let Some(sel) = *doc.selection.read() {
102102- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
103103- if start != end {
104104- if let Some(markdown) = doc.slice(start, end) {
105105- let clean_md =
106106- markdown.replace('\u{200C}', "").replace('\u{200B}', "");
107107- #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
108108- wasm_bindgen_futures::spawn_local(async move {
109109- if let Err(e) = copy_as_html(&clean_md).await {
110110- tracing::warn!("[COPY HTML] Failed: {:?}", e);
111111- }
112112- });
113113- }
114114- }
115115- }
116116- return;
117117- }
118118- _ => {}
119119- }
120120- }
121121-122122- // Insert character at cursor (replacing selection if any)
123123- let sel = doc.selection.write().take();
124124- if let Some(sel) = sel {
125125- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
126126- let _ = doc.replace_tracked(start, end.saturating_sub(start), &ch);
127127- doc.cursor.write().offset = start + ch.chars().count();
128128- } else {
129129- // Clean up any preceding zero-width chars (gap scaffolding)
130130- let cursor_offset = doc.cursor.read().offset;
131131- let mut delete_start = cursor_offset;
132132- while delete_start > 0 {
133133- match get_char_at(doc.loro_text(), delete_start - 1) {
134134- Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1,
135135- _ => break,
136136- }
137137- }
138138-139139- let zw_count = cursor_offset - delete_start;
140140- if zw_count > 0 {
141141- // Splice: delete zero-width chars and insert new char in one op
142142- let _ = doc.replace_tracked(delete_start, zw_count, &ch);
143143- doc.cursor.write().offset = delete_start + ch.chars().count();
144144- } else if cursor_offset == doc.len_chars() {
145145- // Fast path: append at end
146146- let _ = doc.push_tracked(&ch);
147147- doc.cursor.write().offset = cursor_offset + ch.chars().count();
148148- } else {
149149- let _ = doc.insert_tracked(cursor_offset, &ch);
150150- doc.cursor.write().offset = cursor_offset + ch.chars().count();
151151- }
152152- }
153153- }
154154-155155- Key::Backspace => {
156156- // Snap backward after backspace (toward deleted content)
157157- doc.pending_snap.set(Some(SnapDirection::Backward));
158158-159159- let sel = doc.selection.write().take();
160160- if let Some(sel) = sel {
161161- // Delete selection
162162- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
163163- let _ = doc.remove_tracked(start, end.saturating_sub(start));
164164- doc.cursor.write().offset = start;
165165- } else if doc.cursor.read().offset > 0 {
166166- let cursor_offset = doc.cursor.read().offset;
167167-168168- // Cmd+Backspace: delete to start of line
169169- if mods.meta() || mods.ctrl() {
170170- let line_start = find_line_start(doc.loro_text(), cursor_offset);
171171- if line_start < cursor_offset {
172172- let _ = doc.remove_tracked(line_start, cursor_offset - line_start);
173173- doc.cursor.write().offset = line_start;
174174- }
175175- return;
176176- }
177177-178178- // Check if we're about to delete a newline
179179- let prev_char = get_char_at(doc.loro_text(), cursor_offset - 1);
180180-181181- if prev_char == Some('\n') {
182182- let newline_pos = cursor_offset - 1;
183183- let mut delete_start = newline_pos;
184184- let mut delete_end = cursor_offset;
185185-186186- // Check if there's another newline before this one (empty paragraph)
187187- // If so, delete both newlines to merge paragraphs
188188- if newline_pos > 0 {
189189- let prev_prev_char = get_char_at(doc.loro_text(), newline_pos - 1);
190190- if prev_prev_char == Some('\n') {
191191- // Empty paragraph case: delete both newlines
192192- delete_start = newline_pos - 1;
193193- }
194194- }
195195-196196- // Also check if there's a zero-width char after cursor (inserted by Shift+Enter)
197197- if let Some(ch) = get_char_at(doc.loro_text(), delete_end) {
198198- if ch == '\u{200C}' || ch == '\u{200B}' {
199199- delete_end += 1;
200200- }
201201- }
202202-203203- // Scan backwards through whitespace before the newline(s)
204204- while delete_start > 0 {
205205- let ch = get_char_at(doc.loro_text(), delete_start - 1);
206206- match ch {
207207- Some('\u{200C}') | Some('\u{200B}') => {
208208- delete_start -= 1;
209209- }
210210- Some('\n') => break, // stop at another newline
211211- _ => break, // stop at actual content
212212- }
213213- }
214214-215215- // Delete from where we stopped to end (including any trailing zero-width)
216216- let _ =
217217- doc.remove_tracked(delete_start, delete_end.saturating_sub(delete_start));
218218- doc.cursor.write().offset = delete_start;
219219- } else {
220220- // Normal backspace - delete one char
221221- let prev = cursor_offset - 1;
222222- let _ = doc.remove_tracked(prev, 1);
223223- doc.cursor.write().offset = prev;
224224- }
225225- }
226226- }
227227-228228- Key::Delete => {
229229- // Snap forward after delete (toward remaining content)
230230- doc.pending_snap.set(Some(SnapDirection::Forward));
231231-232232- let sel = doc.selection.write().take();
233233- if let Some(sel) = sel {
234234- // Delete selection
235235- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
236236- let _ = doc.remove_tracked(start, end.saturating_sub(start));
237237- doc.cursor.write().offset = start;
238238- } else {
239239- let cursor_offset = doc.cursor.read().offset;
240240- let doc_len = doc.len_chars();
241241-242242- // Cmd+Delete: delete to end of line
243243- if mods.meta() || mods.ctrl() {
244244- let line_end = find_line_end(doc.loro_text(), cursor_offset);
245245- if cursor_offset < line_end {
246246- let _ = doc.remove_tracked(cursor_offset, line_end - cursor_offset);
247247- }
248248- return;
249249- }
250250-251251- if cursor_offset < doc_len {
252252- // Delete next char
253253- let _ = doc.remove_tracked(cursor_offset, 1);
254254- }
255255- }
256256- }
257257-258258- // Arrow keys handled by browser, synced in onkeyup
259259- Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => {
260260- // Browser handles these naturally
261261- }
262262-263263- Key::Enter => {
264264- // Snap forward after enter (into new paragraph/line)
265265- doc.pending_snap.set(Some(SnapDirection::Forward));
266266-267267- let sel = doc.selection.write().take();
268268- if let Some(sel) = sel {
269269- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
270270- let _ = doc.remove_tracked(start, end.saturating_sub(start));
271271- doc.cursor.write().offset = start;
272272- }
273273-274274- let cursor_offset = doc.cursor.read().offset;
275275- if mods.shift() {
276276- // Shift+Enter: hard line break (soft break)
277277- let _ = doc.insert_tracked(cursor_offset, " \n\u{200C}");
278278- doc.cursor.write().offset = cursor_offset + 3;
279279- } else if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) {
280280- // We're in a list item
281281- if is_list_item_empty(doc.loro_text(), cursor_offset, &ctx) {
282282- // Empty item - exit list by removing marker and inserting paragraph break
283283- let line_start = find_line_start(doc.loro_text(), cursor_offset);
284284- let line_end = find_line_end(doc.loro_text(), cursor_offset);
285285-286286- // Delete the empty list item line INCLUDING its trailing newline
287287- // line_end points to the newline, so +1 to include it
288288- let delete_end = (line_end + 1).min(doc.len_chars());
289289-290290- // Use replace_tracked to atomically delete line and insert paragraph break
291291- let _ = doc.replace_tracked(
292292- line_start,
293293- delete_end.saturating_sub(line_start),
294294- "\n\n\u{200C}\n",
295295- );
296296- doc.cursor.write().offset = line_start + 2;
297297- } else {
298298- // Non-empty item - continue list
299299- let continuation = match ctx {
300300- ListContext::Unordered { indent, marker } => {
301301- format!("\n{}{} ", indent, marker)
302302- }
303303- ListContext::Ordered { indent, number } => {
304304- format!("\n{}{}. ", indent, number + 1)
305305- }
306306- };
307307- let len = continuation.chars().count();
308308- let _ = doc.insert_tracked(cursor_offset, &continuation);
309309- doc.cursor.write().offset = cursor_offset + len;
310310- }
311311- } else {
312312- // Not in a list - normal paragraph break
313313- let _ = doc.insert_tracked(cursor_offset, "\n\n");
314314- doc.cursor.write().offset = cursor_offset + 2;
315315- }
316316- }
317317-318318- // Home/End handled by browser, synced in onkeyup
319319- Key::Home | Key::End => {
320320- // Browser handles these naturally
321321- }
322322-323323- _ => {}
324324- }
325325-326326- // Sync Loro cursor when edits affect paragraph boundaries
327327- // This ensures cursor position is tracked correctly through structural changes
328328- if doc.last_edit().is_some_and(|e| e.contains_newline) {
329329- doc.sync_loro_cursor();
330330- }
331331-}
332332-333333-/// Handle paste events and insert text at cursor.
334334-pub fn handle_paste(evt: Event<ClipboardData>, doc: &mut SignalEditorDocument) {
335335- evt.prevent_default();
336336- #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
337337- let _ = doc;
338338-339339- #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
340340- {
341341- use dioxus::web::WebEventExt;
342342- use wasm_bindgen::JsCast;
343343-344344- let base_evt = evt.as_web_event();
345345- if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
346346- if let Some(data_transfer) = clipboard_evt.clipboard_data() {
347347- // Try our custom type first (internal paste), fall back to text/plain
348348- let text = data_transfer
349349- .get_data("text/x-weaver-md")
350350- .ok()
351351- .filter(|s| !s.is_empty())
352352- .or_else(|| data_transfer.get_data("text/plain").ok());
353353-354354- if let Some(text) = text {
355355- // Delete selection if present
356356- let sel = doc.selection.write().take();
357357- if let Some(sel) = sel {
358358- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
359359- let _ = doc.remove_tracked(start, end.saturating_sub(start));
360360- doc.cursor.write().offset = start;
361361- }
362362-363363- // Insert pasted text
364364- let cursor_offset = doc.cursor.read().offset;
365365- let _ = doc.insert_tracked(cursor_offset, &text);
366366- doc.cursor.write().offset = cursor_offset + text.chars().count();
367367- }
368368- }
369369- } else {
370370- tracing::warn!("[PASTE] Failed to cast to ClipboardEvent");
371371- }
372372- }
373373-}
374374-375375-/// Handle cut events - extract text, write to clipboard, then delete.
376376-pub fn handle_cut(evt: Event<ClipboardData>, doc: &mut SignalEditorDocument) {
377377- evt.prevent_default();
378378- #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
379379- let _ = doc;
380380-381381- #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
382382- {
383383- use dioxus::web::WebEventExt;
384384- use wasm_bindgen::JsCast;
385385-386386- let base_evt = evt.as_web_event();
387387- if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
388388- let cut_text = {
389389- let sel = doc.selection.write().take();
390390- if let Some(sel) = sel {
391391- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
392392- if start != end {
393393- // Extract text and strip zero-width chars
394394- let selected_text = doc.slice(start, end).unwrap_or_default();
395395- let clean_text = selected_text
396396- .replace('\u{200C}', "")
397397- .replace('\u{200B}', "");
398398-399399- // Write to clipboard BEFORE deleting (sync fallback)
400400- if let Some(data_transfer) = clipboard_evt.clipboard_data() {
401401- if let Err(e) = data_transfer.set_data("text/plain", &clean_text) {
402402- tracing::warn!("[CUT] Failed to set clipboard data: {:?}", e);
403403- }
404404- }
405405-406406- // Now delete
407407- let _ = doc.remove_tracked(start, end.saturating_sub(start));
408408- doc.cursor.write().offset = start;
409409-410410- Some(clean_text)
411411- } else {
412412- None
413413- }
414414- } else {
415415- None
416416- }
417417- };
418418-419419- // Async: also write custom MIME type for internal paste detection
420420- if let Some(text) = cut_text {
421421- wasm_bindgen_futures::spawn_local(async move {
422422- if let Err(e) = write_clipboard_with_custom_type(&text).await {
423423- tracing::debug!("[CUT] Async clipboard write failed: {:?}", e);
424424- }
425425- });
426426- }
427427- }
428428- }
429429-430430- #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
431431- {
432432- let _ = evt; // suppress unused warning
433433- }
434434-}
435435-436436-/// Handle copy events - extract text, clean it up, write to clipboard.
437437-pub fn handle_copy(evt: Event<ClipboardData>, doc: &SignalEditorDocument) {
438438- #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
439439- {
440440- use dioxus::web::WebEventExt;
441441- use wasm_bindgen::JsCast;
442442-443443- let base_evt = evt.as_web_event();
444444- if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
445445- let sel = *doc.selection.read();
446446- if let Some(sel) = sel {
447447- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
448448- if start != end {
449449- // Extract text
450450- let selected_text = doc.slice(start, end).unwrap_or_default();
451451-452452- // Strip zero-width chars used for gap handling
453453- let clean_text = selected_text
454454- .replace('\u{200C}', "")
455455- .replace('\u{200B}', "");
456456-457457- // Sync fallback: write text/plain via DataTransfer
458458- if let Some(data_transfer) = clipboard_evt.clipboard_data() {
459459- if let Err(e) = data_transfer.set_data("text/plain", &clean_text) {
460460- tracing::warn!("[COPY] Failed to set clipboard data: {:?}", e);
461461- }
462462- }
463463-464464- // Async: also write custom MIME type for internal paste detection
465465- let text_for_async = clean_text.clone();
466466- wasm_bindgen_futures::spawn_local(async move {
467467- if let Err(e) = write_clipboard_with_custom_type(&text_for_async).await {
468468- tracing::debug!("[COPY] Async clipboard write failed: {:?}", e);
469469- }
470470- });
471471-472472- // Prevent browser's default copy (which would copy rendered HTML)
473473- evt.prevent_default();
474474- }
475475- }
476476- }
477477- }
478478-479479- #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
480480- {
481481- let _ = (evt, doc); // suppress unused warnings
482482- }
483483-}
484484-485485-/// Detect if cursor is in a list item and return context for continuation.
486486-///
487487-/// Scans backwards to find start of current line, then checks for list marker.
488488-pub fn detect_list_context(text: &loro::LoroText, cursor_offset: usize) -> Option<ListContext> {
489489- // Find start of current line
490490- let line_start = find_line_start(text, cursor_offset);
491491-492492- // Get the line content from start to cursor
493493- let line_end = find_line_end(text, cursor_offset);
494494- if line_start >= line_end {
495495- return None;
496496- }
497497-498498- // Extract line text
499499- let line = text.slice(line_start, line_end).ok()?;
500500-501501- // Parse indentation
502502- let indent: String = line
503503- .chars()
504504- .take_while(|c| *c == ' ' || *c == '\t')
505505- .collect();
506506- let trimmed = &line[indent.len()..];
507507-508508- // Check for unordered list marker: "- " or "* "
509509- if trimmed.starts_with("- ") {
510510- return Some(ListContext::Unordered {
511511- indent,
512512- marker: '-',
513513- });
514514- }
515515- if trimmed.starts_with("* ") {
516516- return Some(ListContext::Unordered {
517517- indent,
518518- marker: '*',
519519- });
520520- }
521521-522522- // Check for ordered list marker: "1. ", "2. ", "123. ", etc.
523523- if let Some(dot_pos) = trimmed.find(". ") {
524524- let num_part = &trimmed[..dot_pos];
525525- if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) {
526526- if let Ok(number) = num_part.parse::<usize>() {
527527- return Some(ListContext::Ordered { indent, number });
528528- }
529529- }
530530- }
531531-532532- None
533533-}
534534-535535-/// Check if the current list item is empty (just the marker, no content after cursor).
536536-///
537537-/// Used to determine whether Enter should continue the list or exit it.
538538-pub fn is_list_item_empty(text: &loro::LoroText, cursor_offset: usize, ctx: &ListContext) -> bool {
539539- let line_start = find_line_start(text, cursor_offset);
540540- let line_end = find_line_end(text, cursor_offset);
541541-542542- // Get line content
543543- let line = match text.slice(line_start, line_end) {
544544- Ok(s) => s,
545545- Err(_) => return false,
546546- };
547547-548548- // Calculate expected marker length
549549- let marker_len = match ctx {
550550- ListContext::Unordered { indent, .. } => indent.len() + 2, // "- "
551551- ListContext::Ordered { indent, number } => {
552552- indent.len() + number.to_string().len() + 2 // "1. "
553553- }
554554- };
555555-556556- // Item is empty if line length equals marker length (nothing after marker)
557557- line.len() <= marker_len
558558-}
559559-560560-/// Get character at the given offset in LoroText.
561561-pub fn get_char_at(text: &loro::LoroText, offset: usize) -> Option<char> {
562562- text.char_at(offset).ok()
563563-}
564564-565565-/// Find start of line containing offset.
566566-pub fn find_line_start(text: &loro::LoroText, offset: usize) -> usize {
567567- if offset == 0 {
568568- return 0;
569569- }
570570- // Only slice the portion before cursor
571571- let prefix = match text.slice(0, offset) {
572572- Ok(s) => s,
573573- Err(_) => return 0,
574574- };
575575- prefix
576576- .chars()
577577- .enumerate()
578578- .filter(|(_, c)| *c == '\n')
579579- .last()
580580- .map(|(pos, _)| pos + 1)
581581- .unwrap_or(0)
582582-}
583583-584584-/// Find end of line containing offset.
585585-pub fn find_line_end(text: &loro::LoroText, offset: usize) -> usize {
586586- let char_len = text.len_unicode();
587587- if offset >= char_len {
588588- return char_len;
589589- }
590590- // Only slice from cursor to end
591591- let suffix = match text.slice(offset, char_len) {
592592- Ok(s) => s,
593593- Err(_) => return char_len,
594594- };
595595- suffix
596596- .chars()
597597- .enumerate()
598598- .find(|(_, c)| *c == '\n')
599599- .map(|(i, _)| offset + i)
600600- .unwrap_or(char_len)
601601-}
+1-5
crates/weaver-app/src/components/editor/mod.rs
···55//! editing plain markdown text under the hood.
6677mod actions;
88-mod beforeinput;
98mod collab;
109mod component;
1110mod document;
1211mod dom_sync;
1312mod image_upload;
1414-mod input;
1513mod log_buffer;
1614mod publish;
1515+mod remote_cursors;
1716mod report;
1817mod storage;
1918mod sync;
···21202221#[cfg(test)]
2322mod tests;
2424-2525-// Re-export DOM update strategy constant from browser crate.
2626-pub(crate) use weaver_editor_browser::FORCE_INNERHTML_UPDATE;
27232824// Main component
2925pub use component::MarkdownEditor;
···11+//! Browser clipboard implementation.
22+//!
33+//! Implements `ClipboardPlatform` for browser environments using the
44+//! ClipboardEvent's DataTransfer API for sync access and the async
55+//! Clipboard API for custom MIME types.
66+77+use weaver_editor_core::ClipboardPlatform;
88+99+/// Browser clipboard context wrapping a ClipboardEvent's DataTransfer.
1010+///
1111+/// Created from a clipboard event (copy, cut, paste) to provide sync
1212+/// clipboard access. Also spawns async tasks for custom MIME types.
1313+pub struct BrowserClipboard {
1414+ data_transfer: Option<web_sys::DataTransfer>,
1515+}
1616+1717+impl BrowserClipboard {
1818+ /// Create from a ClipboardEvent.
1919+ ///
2020+ /// Call this in your copy/cut/paste event handler.
2121+ pub fn from_event(evt: &web_sys::ClipboardEvent) -> Self {
2222+ Self {
2323+ data_transfer: evt.clipboard_data(),
2424+ }
2525+ }
2626+2727+ /// Create an empty clipboard context (for testing or non-event contexts).
2828+ pub fn empty() -> Self {
2929+ Self {
3030+ data_transfer: None,
3131+ }
3232+ }
3333+}
3434+3535+impl ClipboardPlatform for BrowserClipboard {
3636+ fn write_text(&self, text: &str) {
3737+ // Sync write via DataTransfer (immediate fallback).
3838+ if let Some(dt) = &self.data_transfer {
3939+ if let Err(e) = dt.set_data("text/plain", text) {
4040+ tracing::warn!("Clipboard sync write failed: {:?}", e);
4141+ }
4242+ }
4343+4444+ // Async write for custom MIME type (enables internal paste detection).
4545+ let text = text.to_string();
4646+ wasm_bindgen_futures::spawn_local(async move {
4747+ if let Err(e) = crate::events::write_clipboard_with_custom_type(&text).await {
4848+ tracing::debug!("Clipboard async write failed: {:?}", e);
4949+ }
5050+ });
5151+ }
5252+5353+ fn write_html(&self, html: &str, plain_text: &str) {
5454+ // Sync write of plain text fallback.
5555+ if let Some(dt) = &self.data_transfer {
5656+ if let Err(e) = dt.set_data("text/plain", plain_text) {
5757+ tracing::warn!("Clipboard sync write (plain) failed: {:?}", e);
5858+ }
5959+ }
6060+6161+ // Async write for HTML.
6262+ let html = html.to_string();
6363+ let plain = plain_text.to_string();
6464+ wasm_bindgen_futures::spawn_local(async move {
6565+ if let Err(e) = write_html_to_clipboard(&html, &plain).await {
6666+ tracing::warn!("Clipboard HTML write failed: {:?}", e);
6767+ }
6868+ });
6969+ }
7070+7171+ fn read_text(&self) -> Option<String> {
7272+ let dt = self.data_transfer.as_ref()?;
7373+7474+ // Try our custom MIME type first (internal paste).
7575+ if let Ok(text) = dt.get_data("text/x-weaver-md") {
7676+ if !text.is_empty() {
7777+ return Some(text);
7878+ }
7979+ }
8080+8181+ // Fall back to plain text.
8282+ dt.get_data("text/plain").ok().filter(|s| !s.is_empty())
8383+ }
8484+}
8585+8686+/// Write HTML and plain text to clipboard using the async Clipboard API.
8787+///
8888+/// This uses the navigator.clipboard API and doesn't require a clipboard event.
8989+/// Suitable for keyboard-triggered copy operations like CopyAsHtml.
9090+pub async fn write_html_to_clipboard(
9191+ html: &str,
9292+ plain_text: &str,
9393+) -> Result<(), wasm_bindgen::JsValue> {
9494+ use js_sys::{Array, Object, Reflect};
9595+ use wasm_bindgen::JsValue;
9696+ use web_sys::{Blob, BlobPropertyBag, ClipboardItem};
9797+9898+ let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
9999+ let clipboard = window.navigator().clipboard();
100100+101101+ // Create HTML blob.
102102+ let html_parts = Array::new();
103103+ html_parts.push(&JsValue::from_str(html));
104104+ let html_opts = BlobPropertyBag::new();
105105+ html_opts.set_type("text/html");
106106+ let html_blob = Blob::new_with_str_sequence_and_options(&html_parts, &html_opts)?;
107107+108108+ // Create plain text blob.
109109+ let text_parts = Array::new();
110110+ text_parts.push(&JsValue::from_str(plain_text));
111111+ let text_opts = BlobPropertyBag::new();
112112+ text_opts.set_type("text/plain");
113113+ let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?;
114114+115115+ // Create ClipboardItem with both types.
116116+ let item_data = Object::new();
117117+ Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?;
118118+ Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?;
119119+120120+ let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?;
121121+ let items = Array::new();
122122+ items.push(&clipboard_item);
123123+124124+ wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?;
125125+ tracing::debug!("Wrote {} bytes of HTML to clipboard", html.len());
126126+ Ok(())
127127+}
128128+129129+// === Dioxus event handlers ===
130130+131131+/// Handle a Dioxus paste event.
132132+///
133133+/// Extracts text from the clipboard event and inserts at cursor.
134134+#[cfg(feature = "dioxus")]
135135+pub fn handle_paste<D: weaver_editor_core::EditorDocument>(
136136+ evt: dioxus_core::Event<dioxus_html::ClipboardData>,
137137+ doc: &mut D,
138138+) {
139139+ use dioxus_web::WebEventExt;
140140+ use wasm_bindgen::JsCast;
141141+142142+ evt.prevent_default();
143143+144144+ let base_evt = evt.as_web_event();
145145+ if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
146146+ let clipboard = BrowserClipboard::from_event(clipboard_evt);
147147+ weaver_editor_core::clipboard_paste(doc, &clipboard);
148148+ } else {
149149+ tracing::warn!("[PASTE] Failed to cast to ClipboardEvent");
150150+ }
151151+}
152152+153153+/// Handle a Dioxus cut event.
154154+///
155155+/// Copies selection to clipboard, then deletes it.
156156+#[cfg(feature = "dioxus")]
157157+pub fn handle_cut<D: weaver_editor_core::EditorDocument>(
158158+ evt: dioxus_core::Event<dioxus_html::ClipboardData>,
159159+ doc: &mut D,
160160+) {
161161+ use dioxus_web::WebEventExt;
162162+ use wasm_bindgen::JsCast;
163163+164164+ evt.prevent_default();
165165+166166+ let base_evt = evt.as_web_event();
167167+ if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
168168+ let clipboard = BrowserClipboard::from_event(clipboard_evt);
169169+ weaver_editor_core::clipboard_cut(doc, &clipboard);
170170+ }
171171+}
172172+173173+/// Handle a Dioxus copy event.
174174+///
175175+/// Copies selection to clipboard. Only prevents default if there was a selection.
176176+#[cfg(feature = "dioxus")]
177177+pub fn handle_copy<D: weaver_editor_core::EditorDocument>(
178178+ evt: dioxus_core::Event<dioxus_html::ClipboardData>,
179179+ doc: &D,
180180+) {
181181+ use dioxus_web::WebEventExt;
182182+ use wasm_bindgen::JsCast;
183183+184184+ let base_evt = evt.as_web_event();
185185+ if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
186186+ let clipboard = BrowserClipboard::from_event(clipboard_evt);
187187+ if weaver_editor_core::clipboard_copy(doc, &clipboard) {
188188+ evt.prevent_default();
189189+ }
190190+ }
191191+}
+43-46
crates/weaver-editor-browser/src/events.rs
···254254 Ok(result.as_string())
255255}
256256257257-/// Copy markdown as rendered HTML to clipboard.
258258-///
259259-/// Renders the markdown to HTML and writes both text/html and text/plain
260260-/// representations to the clipboard.
261261-pub async fn copy_as_html(markdown: &str) -> Result<(), JsValue> {
262262- use js_sys::{Array, Object, Reflect};
263263- use web_sys::{Blob, BlobPropertyBag, ClipboardItem};
264264-265265- // Render markdown to HTML using ClientWriter.
266266- let parser = weaver_editor_core::markdown_weaver::Parser::new(markdown).into_offset_iter();
267267- let mut html = String::new();
268268- weaver_editor_core::weaver_renderer::atproto::ClientWriter::<_, _, ()>::new(
269269- parser, &mut html, markdown,
270270- )
271271- .run()
272272- .map_err(|e| JsValue::from_str(&format!("render error: {e}")))?;
273273-274274- let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
275275- let clipboard = window.navigator().clipboard();
276276-277277- // Create blobs for both HTML and plain text.
278278- let parts = Array::new();
279279- parts.push(&JsValue::from_str(&html));
280280-281281- let html_opts = BlobPropertyBag::new();
282282- html_opts.set_type("text/html");
283283- let html_blob = Blob::new_with_str_sequence_and_options(&parts, &html_opts)?;
284284-285285- let text_opts = BlobPropertyBag::new();
286286- text_opts.set_type("text/plain");
287287- let text_blob = Blob::new_with_str_sequence_and_options(&parts, &text_opts)?;
288288-289289- // Create ClipboardItem with both types.
290290- let item_data = Object::new();
291291- Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?;
292292- Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?;
293293-294294- let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?;
295295- let items = Array::new();
296296- items.push(&clipboard_item);
297297-298298- wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?;
299299- tracing::info!("[COPY HTML] Success - {} bytes of HTML", html.len());
300300- Ok(())
301301-}
302302-303257// === BeforeInput handler ===
304258305259use crate::FORCE_INNERHTML_UPDATE;
···564518 | InputType::Unknown(_) => BeforeInputResult::PassThrough,
565519 }
566520}
521521+522522+// === Math click handling ===
523523+524524+use weaver_editor_core::{OffsetMapping, SyntaxSpanInfo};
525525+526526+/// Check if a click target is a math-clickable element.
527527+///
528528+/// Returns the target character offset if the click was on a `.math-clickable`
529529+/// element with a valid `data-char-target` attribute, None otherwise.
530530+pub fn get_math_click_offset(target: &web_sys::EventTarget) -> Option<usize> {
531531+ use wasm_bindgen::JsCast;
532532+533533+ let element = target.dyn_ref::<web_sys::Element>()?;
534534+ let math_el = element.closest(".math-clickable").ok()??;
535535+ let char_target = math_el.get_attribute("data-char-target")?;
536536+ char_target.parse().ok()
537537+}
538538+539539+/// Handle a click that might be on a math element.
540540+///
541541+/// If the click target is a math-clickable element, this updates the cursor,
542542+/// clears selection, updates visibility, and restores the DOM cursor position.
543543+///
544544+/// Returns true if the click was handled (was on a math element), false otherwise.
545545+/// When this returns false, the caller should handle the click normally.
546546+pub fn handle_math_click<D: EditorDocument>(
547547+ target: &web_sys::EventTarget,
548548+ doc: &mut D,
549549+ syntax_spans: &[SyntaxSpanInfo],
550550+ paragraphs: &[ParagraphRender],
551551+ offset_map: &[OffsetMapping],
552552+) -> bool {
553553+ if let Some(offset) = get_math_click_offset(target) {
554554+ tracing::debug!("math-clickable clicked, moving cursor to {}", offset);
555555+ doc.set_cursor_offset(offset);
556556+ doc.set_selection(None);
557557+ crate::update_syntax_visibility(offset, None, syntax_spans, paragraphs);
558558+ let _ = crate::restore_cursor_position(offset, offset_map, None);
559559+ true
560560+ } else {
561561+ false
562562+ }
563563+}
+10-4
crates/weaver-editor-browser/src/lib.rs
···4747/// Set to `true` for maximum control, `false` for smoother typing experience.
4848pub const FORCE_INNERHTML_UPDATE: bool = true;
49495050+pub mod clipboard;
5051pub mod color;
5152pub mod cursor;
5253pub mod dom_sync;
···68696970// Event handling
7071pub use events::{
7171- BeforeInputContext, BeforeInputResult, StaticRange, copy_as_html, get_current_range,
7272- get_data_from_event, get_input_type_from_event, get_target_range_from_event,
7373- handle_beforeinput, is_composing, parse_browser_input_type, read_clipboard_text,
7474- write_clipboard_with_custom_type,
7272+ BeforeInputContext, BeforeInputResult, StaticRange, get_current_range, get_data_from_event,
7373+ get_input_type_from_event, get_math_click_offset, get_target_range_from_event,
7474+ handle_beforeinput, handle_math_click, is_composing, parse_browser_input_type,
7575+ read_clipboard_text, write_clipboard_with_custom_type,
7576};
76777778// Platform detection
···82838384// Color utilities
8485pub use color::{rgba_u32_to_css, rgba_u32_to_css_alpha};
8686+8787+// Clipboard
8888+pub use clipboard::{BrowserClipboard, write_html_to_clipboard};
8989+#[cfg(feature = "dioxus")]
9090+pub use clipboard::{handle_copy, handle_cut, handle_paste};
+7-2
crates/weaver-editor-browser/src/visibility.rs
···1919//! }
2020//! ```
21212222-use weaver_editor_core::{ParagraphRender, Selection, SyntaxSpanInfo, VisibilityState};
2222+use weaver_editor_core::{ParagraphRender, Selection, SyntaxSpanInfo};
23232424/// Update syntax span visibility in the DOM based on cursor position.
2525///
···4343) {
4444 use wasm_bindgen::JsCast;
45454646- let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs);
4646+ let visibility = weaver_editor_core::VisibilityState::calculate(
4747+ cursor_offset,
4848+ selection,
4949+ syntax_spans,
5050+ paragraphs,
5151+ );
47524853 let Some(window) = web_sys::window() else {
4954 return;
+23
crates/weaver-editor-core/src/document.rs
···6464 /// Set the composition state.
6565 fn set_composition(&mut self, composition: Option<CompositionState>);
66666767+ // === Required: Cursor snap hint ===
6868+6969+ /// Get the pending snap direction hint.
7070+ ///
7171+ /// This hints which direction the cursor should snap after an edit
7272+ /// when the cursor lands on invisible syntax. Forward for insertions
7373+ /// (snap toward new content), backward for deletions (snap toward
7474+ /// remaining content).
7575+ fn pending_snap(&self) -> Option<crate::SnapDirection>;
7676+7777+ /// Set the pending snap direction hint.
7878+ fn set_pending_snap(&mut self, snap: Option<crate::SnapDirection>);
7979+6780 // === Provided: Convenience accessors ===
68816982 /// Get the cursor offset.
···266279 selection: Option<Selection>,
267280 last_edit: Option<EditInfo>,
268281 composition: Option<CompositionState>,
282282+ pending_snap: Option<crate::SnapDirection>,
269283}
270284271285impl<T: TextBuffer + UndoManager + Default> Default for PlainEditor<T> {
···283297 selection: None,
284298 last_edit: None,
285299 composition: None,
300300+ pending_snap: None,
286301 }
287302 }
288303···338353339354 fn set_composition(&mut self, composition: Option<CompositionState>) {
340355 self.composition = composition;
356356+ }
357357+358358+ fn pending_snap(&self) -> Option<crate::SnapDirection> {
359359+ self.pending_snap
360360+ }
361361+362362+ fn set_pending_snap(&mut self, snap: Option<crate::SnapDirection>) {
363363+ self.pending_snap = snap;
341364 }
342365}
343366
+122-2
crates/weaver-editor-core/src/execute.rs
···44//! operations to any type implementing `EditorDocument`. The logic is generic
55//! and platform-agnostic.
6677+use crate::SnapDirection;
78use crate::actions::{EditorAction, FormatAction, Range};
89use crate::document::EditorDocument;
1010+use crate::platform::{ClipboardPlatform, clipboard_copy, clipboard_cut, clipboard_paste};
911use crate::text_helpers::{
1012 ListContext, detect_list_context, find_line_end, find_line_start, find_word_boundary_backward,
1113 find_word_boundary_forward, is_list_item_empty,
1214};
1315use crate::types::Selection;
14161717+/// Determine the cursor snap direction hint for an action.
1818+///
1919+/// Forward means cursor should snap toward new/remaining content (insertions).
2020+/// Backward means cursor should snap toward content before edit (deletions).
2121+pub fn snap_direction_for_action(action: &EditorAction) -> Option<SnapDirection> {
2222+ match action {
2323+ // Forward: cursor should snap toward new/remaining content.
2424+ EditorAction::InsertLineBreak { .. }
2525+ | EditorAction::InsertParagraph { .. }
2626+ | EditorAction::DeleteForward { .. }
2727+ | EditorAction::DeleteWordForward { .. }
2828+ | EditorAction::DeleteToLineEnd { .. }
2929+ | EditorAction::DeleteSoftLineForward { .. } => Some(SnapDirection::Forward),
3030+3131+ // Backward: cursor should snap toward content before edit.
3232+ EditorAction::DeleteBackward { .. }
3333+ | EditorAction::DeleteWordBackward { .. }
3434+ | EditorAction::DeleteToLineStart { .. }
3535+ | EditorAction::DeleteSoftLineBackward { .. } => Some(SnapDirection::Backward),
3636+3737+ _ => None,
3838+ }
3939+}
4040+1541/// Execute an editor action on a document.
1642///
1743/// This is the central dispatch point for all editor operations.
4444+/// Sets the appropriate snap direction hint before executing.
1845/// Returns true if the action was handled and the document was modified.
4646+///
4747+/// Note: Clipboard operations (Cut, Copy, CopyAsHtml, Paste) return false here.
4848+/// Use [`execute_action_with_clipboard`] if you have a clipboard platform available.
1949pub fn execute_action<D: EditorDocument>(doc: &mut D, action: &EditorAction) -> bool {
5050+ // Set pending snap direction before executing action.
5151+ if let Some(snap) = snap_direction_for_action(action) {
5252+ doc.set_pending_snap(Some(snap));
5353+ }
5454+2055 match action {
2156 EditorAction::Insert { text, range } => execute_insert(doc, text, *range),
2257 EditorAction::InsertLineBreak { range } => execute_insert_line_break(doc, *range),
···4176 EditorAction::ToggleStrikethrough => execute_toggle_format(doc, "~~"),
4277 EditorAction::InsertLink => execute_insert_link(doc),
4378 EditorAction::Cut | EditorAction::Copy | EditorAction::CopyAsHtml => {
4444- // Clipboard operations are handled by platform layer.
7979+ // Clipboard operations need platform - use execute_action_with_clipboard.
4580 false
4681 }
4782 EditorAction::Paste { range: _ } => {
4848- // Paste is handled by platform layer with clipboard access.
8383+ // Paste needs platform - use execute_action_with_clipboard.
4984 false
5085 }
5186 EditorAction::SelectAll => execute_select_all(doc),
5287 EditorAction::MoveCursor { offset } => execute_move_cursor(doc, *offset),
5388 EditorAction::ExtendSelection { offset } => execute_extend_selection(doc, *offset),
8989+ }
9090+}
9191+9292+/// Execute an editor action with clipboard support.
9393+///
9494+/// Like [`execute_action`], but also handles clipboard operations (Cut, Copy, Paste, CopyAsHtml)
9595+/// using the provided platform implementation.
9696+pub fn execute_action_with_clipboard<D, P>(doc: &mut D, action: &EditorAction, clipboard: &P) -> bool
9797+where
9898+ D: EditorDocument,
9999+ P: ClipboardPlatform,
100100+{
101101+ match action {
102102+ EditorAction::Copy => clipboard_copy(doc, clipboard),
103103+ EditorAction::Cut => clipboard_cut(doc, clipboard),
104104+ EditorAction::Paste { range: _ } => clipboard_paste(doc, clipboard),
105105+ EditorAction::CopyAsHtml => crate::platform::clipboard_copy_as_html(doc, clipboard),
106106+ // Delegate everything else to the regular execute_action.
107107+ _ => execute_action(doc, action),
54108 }
55109}
56110···551605 format!("\n{}{}. ", indent, number + 1)
552606 }
553607 }
608608+}
609609+610610+// === Keydown handling ===
611611+612612+use crate::actions::{KeyCombo, KeybindingConfig, KeydownResult};
613613+614614+/// Handle a keydown event using the keybinding configuration.
615615+///
616616+/// This handles keyboard shortcuts only. Text input and deletion
617617+/// are handled by beforeinput. Navigation (arrows, etc.) is passed
618618+/// through to the browser/platform.
619619+///
620620+/// For clipboard operations, use [`handle_keydown_with_clipboard`] instead.
621621+pub fn handle_keydown<D: EditorDocument>(
622622+ doc: &mut D,
623623+ config: &KeybindingConfig,
624624+ combo: KeyCombo,
625625+ range: Range,
626626+) -> KeydownResult {
627627+ // Look up keybinding (range is applied by lookup).
628628+ if let Some(action) = config.lookup(&combo, range) {
629629+ execute_action(doc, &action);
630630+ return KeydownResult::Handled;
631631+ }
632632+633633+ check_passthrough(&combo)
634634+}
635635+636636+/// Handle a keydown event with clipboard support.
637637+///
638638+/// Like [`handle_keydown`], but uses the provided clipboard platform
639639+/// for clipboard operations (Cut, Copy, Paste, CopyAsHtml).
640640+pub fn handle_keydown_with_clipboard<D, P>(
641641+ doc: &mut D,
642642+ config: &KeybindingConfig,
643643+ combo: KeyCombo,
644644+ range: Range,
645645+ clipboard: &P,
646646+) -> KeydownResult
647647+where
648648+ D: EditorDocument,
649649+ P: ClipboardPlatform,
650650+{
651651+ // Look up keybinding (range is applied by lookup).
652652+ if let Some(action) = config.lookup(&combo, range) {
653653+ execute_action_with_clipboard(doc, &action, clipboard);
654654+ return KeydownResult::Handled;
655655+ }
656656+657657+ check_passthrough(&combo)
658658+}
659659+660660+/// Check if a key combo should pass through to the platform.
661661+fn check_passthrough(combo: &KeyCombo) -> KeydownResult {
662662+ // Navigation keys should pass through.
663663+ if combo.key.is_navigation() {
664664+ return KeydownResult::PassThrough;
665665+ }
666666+667667+ // Modifier-only keypresses should pass through.
668668+ if combo.key.is_modifier() {
669669+ return KeydownResult::PassThrough;
670670+ }
671671+672672+ // Content keys (typing, backspace, etc.) - let beforeinput handle.
673673+ KeydownResult::NotHandled
554674}
555675556676#[cfg(test)]
+9-2
crates/weaver-editor-core/src/lib.rs
···4242pub use undo::{UndoManager, UndoableBuffer};
4343pub use visibility::VisibilityState;
4444pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult};
4545-pub use platform::{CursorPlatform, CursorSync, PlatformError};
4545+pub use platform::{
4646+ ClipboardPlatform, CursorPlatform, CursorSync, PlatformError, clipboard_copy,
4747+ clipboard_copy_as_html, clipboard_cut, clipboard_paste, render_markdown_to_html,
4848+ strip_zero_width,
4949+};
4650pub use actions::{
4751 EditorAction, FormatAction, InputType, Key, KeyCombo, KeybindingConfig, KeydownResult,
4852 Modifiers, Range,
4953};
5050-pub use execute::{apply_formatting, execute_action};
5454+pub use execute::{
5555+ apply_formatting, execute_action, execute_action_with_clipboard, handle_keydown,
5656+ handle_keydown_with_clipboard, snap_direction_for_action,
5757+};
5158pub use text_helpers::{
5259 ListContext, count_leading_zero_width, detect_list_context, find_line_end, find_line_start,
5360 find_word_boundary_backward, find_word_boundary_forward, is_list_item_empty,
+170
crates/weaver-editor-core/src/platform.rs
···103103 F: FnOnce(usize),
104104 G: FnOnce(usize, usize);
105105}
106106+107107+/// Platform-specific clipboard operations.
108108+///
109109+/// Implementations handle the low-level clipboard access (sync and async paths
110110+/// as appropriate for the platform). Document-level operations (selection
111111+/// extraction, cursor updates) are handled by the `clipboard_*` functions
112112+/// in this module.
113113+pub trait ClipboardPlatform {
114114+ /// Write plain text to clipboard.
115115+ ///
116116+ /// For browsers, implementations should use both the sync DataTransfer API
117117+ /// (for immediate fallback) and the async Clipboard API (for custom MIME types).
118118+ fn write_text(&self, text: &str);
119119+120120+ /// Write markdown rendered as HTML to clipboard.
121121+ ///
122122+ /// The `plain_text` is the original markdown, `html` is the rendered output.
123123+ /// Both should be written to clipboard with appropriate MIME types.
124124+ fn write_html(&self, html: &str, plain_text: &str);
125125+126126+ /// Read text from clipboard.
127127+ ///
128128+ /// For browsers, this reads from the paste event's DataTransfer.
129129+ /// Returns None if no text is available.
130130+ fn read_text(&self) -> Option<String>;
131131+}
132132+133133+/// Strip zero-width characters used for formatting gaps.
134134+///
135135+/// The editor uses ZWNJ (U+200C) and ZWSP (U+200B) to create cursor positions
136136+/// within invisible formatting syntax. These should be stripped when copying
137137+/// text to the clipboard.
138138+pub fn strip_zero_width(text: &str) -> String {
139139+ text.replace('\u{200C}', "").replace('\u{200B}', "")
140140+}
141141+142142+/// Copy selected text from document to clipboard.
143143+///
144144+/// Returns true if text was copied, false if no selection.
145145+pub fn clipboard_copy<D: crate::EditorDocument, P: ClipboardPlatform>(
146146+ doc: &D,
147147+ platform: &P,
148148+) -> bool {
149149+ let Some(sel) = doc.selection() else {
150150+ return false;
151151+ };
152152+153153+ let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end()));
154154+ if start == end {
155155+ return false;
156156+ }
157157+158158+ let Some(text) = doc.slice(start..end) else {
159159+ return false;
160160+ };
161161+162162+ let clean_text = strip_zero_width(&text);
163163+ platform.write_text(&clean_text);
164164+ true
165165+}
166166+167167+/// Cut selected text from document to clipboard.
168168+///
169169+/// Copies the selection to clipboard, then deletes it from the document.
170170+/// Returns true if text was cut, false if no selection.
171171+pub fn clipboard_cut<D: crate::EditorDocument, P: ClipboardPlatform>(
172172+ doc: &mut D,
173173+ platform: &P,
174174+) -> bool {
175175+ let Some(sel) = doc.selection() else {
176176+ return false;
177177+ };
178178+179179+ let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end()));
180180+ if start == end {
181181+ return false;
182182+ }
183183+184184+ let Some(text) = doc.slice(start..end) else {
185185+ return false;
186186+ };
187187+188188+ let clean_text = strip_zero_width(&text);
189189+ platform.write_text(&clean_text);
190190+191191+ // Delete selection.
192192+ doc.delete(start..end);
193193+ doc.set_selection(None);
194194+195195+ true
196196+}
197197+198198+/// Paste text from clipboard into document.
199199+///
200200+/// Replaces any selection with the pasted text, or inserts at cursor.
201201+/// Returns true if text was pasted, false if clipboard was empty.
202202+pub fn clipboard_paste<D: crate::EditorDocument, P: ClipboardPlatform>(
203203+ doc: &mut D,
204204+ platform: &P,
205205+) -> bool {
206206+ let Some(text) = platform.read_text() else {
207207+ return false;
208208+ };
209209+210210+ if text.is_empty() {
211211+ return false;
212212+ }
213213+214214+ // Delete selection if present.
215215+ if let Some(sel) = doc.selection() {
216216+ let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end()));
217217+ if start != end {
218218+ doc.delete(start..end);
219219+ doc.set_cursor_offset(start);
220220+ }
221221+ }
222222+ doc.set_selection(None);
223223+224224+ // Insert at cursor.
225225+ let cursor = doc.cursor_offset();
226226+ doc.insert(cursor, &text);
227227+228228+ true
229229+}
230230+231231+/// Render markdown to HTML using the ClientWriter.
232232+///
233233+/// Uses a minimal context with no embed resolution, suitable for clipboard operations.
234234+pub fn render_markdown_to_html(markdown: &str) -> Option<String> {
235235+ use crate::markdown_weaver::Parser;
236236+ use crate::weaver_renderer::atproto::ClientWriter;
237237+238238+ let parser = Parser::new(markdown).into_offset_iter();
239239+ let mut html = String::new();
240240+ ClientWriter::<_, _, ()>::new(parser, &mut html, markdown)
241241+ .run()
242242+ .ok()?;
243243+ Some(html)
244244+}
245245+246246+/// Copy selected text as rendered HTML to clipboard.
247247+///
248248+/// Renders the selected markdown to HTML and writes both representations
249249+/// to the clipboard. Returns true if text was copied, false if no selection.
250250+pub fn clipboard_copy_as_html<D: crate::EditorDocument, P: ClipboardPlatform>(
251251+ doc: &D,
252252+ platform: &P,
253253+) -> bool {
254254+ let Some(sel) = doc.selection() else {
255255+ return false;
256256+ };
257257+258258+ let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end()));
259259+ if start == end {
260260+ return false;
261261+ }
262262+263263+ let Some(text) = doc.slice(start..end) else {
264264+ return false;
265265+ };
266266+267267+ let clean_text = strip_zero_width(&text);
268268+269269+ let Some(html) = render_markdown_to_html(&clean_text) else {
270270+ return false;
271271+ };
272272+273273+ platform.write_html(&html, &clean_text);
274274+ true
275275+}