diff --git a/Cargo.toml b/Cargo.toml index 0bbbf9e8e..c5a4434fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -136,6 +136,13 @@ web-sys = { version = "0.3", features = [ # Async clipboard read for paste into the text/MText editors (iced's own # clipboard read is a no-op on the web). "Clipboard", + # Web copy/paste in plain text_input fields (#346): Ctrl+C writes the + # focused field's text; Ctrl+V replays the clipboard as synthetic + # KeyboardEvents on the canvas (the only route into a focused text_input). + "KeyboardEvent", + "KeyboardEventInit", + "EventInit", + "EventTarget", # Error banner (web_diag): forward wgpu/naga errors to the browser console # and inject a copyable DOM overlay (document.body → append_child). "console", diff --git a/src/app/mod.rs b/src/app/mod.rs index e3e7cba39..670a3bbb0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1265,6 +1265,17 @@ pub enum Message { OpenPathPicked(Option<(PathBuf, u64)>), /// A file was dragged from the desktop and dropped on the window (#344). FileDropped(PathBuf), + /// Web only: Ctrl+V captured by a focused text field, whose iced-internal + /// paste is inert there — read the async clipboard instead (#346). + WebFieldPaste, + /// Web only: clipboard text arrived for a focused field — replay it as + /// synthetic keystrokes on the canvas. + WebFieldPasteText(Option), + /// Web only: Ctrl+C captured by a focused text field — collect the + /// focused input's text via a widget operation. + WebFieldCopy, + /// Web only: the focused field's text — write it to the clipboard. + WebFieldCopyText(Option), /// Pick from the one-shot snap override menu (Shift+RMB): only this snap /// applies to the next point pick, then the configuration restores (#337). SnapOverridePick(crate::snap::SnapType), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 4fd089b68..e6c999d61 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -292,6 +292,90 @@ impl OpenCADStudio { } } + Message::WebFieldPaste => { + // The MText / inline-TEXT editors have their own web paste + // paths — don't double-feed them. + if self.mtext_editor.is_some() || self.text_inline.is_some() { + return self.on_paste_shortcut(); + } + #[cfg(target_arch = "wasm32")] + return Task::perform( + crate::sys::read_clipboard_text(), + Message::WebFieldPasteText, + ); + #[cfg(not(target_arch = "wasm32"))] + Task::none() + } + + Message::WebFieldPasteText(text) => { + #[cfg(target_arch = "wasm32")] + if let Some(t) = &text { + crate::sys::synthesize_typing(t); + } + let _ = text; + Task::none() + } + + Message::WebFieldCopy => { + // Walk the widget tree for the focused text input's visible + // text. iced calls `text_input` then `focusable` back-to-back + // on the same widget, so remembering the last text seen pairs + // it with the focus check. (An empty field reports its + // placeholder — that's iced's "visible text" contract.) + use iced::advanced::widget::operation::{ + Focusable, Outcome, TextInput, + }; + use iced::advanced::widget::{Id, Operation}; + #[derive(Default)] + struct FocusedText { + last_text: Option, + found: Option, + } + impl Operation> for FocusedText { + fn text_input( + &mut self, + _id: Option<&Id>, + _bounds: iced::Rectangle, + state: &mut dyn TextInput, + ) { + self.last_text = Some(state.text().to_owned()); + } + fn focusable( + &mut self, + _id: Option<&Id>, + _bounds: iced::Rectangle, + state: &mut dyn Focusable, + ) { + let text = self.last_text.take(); + if state.is_focused() && self.found.is_none() { + self.found = text; + } + } + fn traverse( + &mut self, + operate: &mut dyn FnMut(&mut dyn Operation>), + ) { + operate(self); + } + fn finish(&self) -> Outcome> { + Outcome::Some(self.found.clone()) + } + } + iced::advanced::widget::operate(FocusedText::default()) + .map(Message::WebFieldCopyText) + } + + Message::WebFieldCopyText(text) => { + #[cfg(target_arch = "wasm32")] + if let Some(t) = &text { + if !t.is_empty() { + crate::sys::write_clipboard_text(t); + } + } + let _ = text; + Task::none() + } + Message::SnapOverridePick(t) => { self.snap_override_popup = None; self.snapper.set_override(t); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 608f57b46..1c09aeeb0 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1901,6 +1901,13 @@ impl OpenCADStudio { Some(Message::Command("CUTCLIP".to_string())) } "v" if status == Status::Ignored => Some(Message::PasteShortcut), + // Web: a focused text field captured Ctrl+C/V, + // but iced's clipboard is a no-op there — route + // through the async browser clipboard (#346). + #[cfg(target_arch = "wasm32")] + "v" => Some(Message::WebFieldPaste), + #[cfg(target_arch = "wasm32")] + "c" => Some(Message::WebFieldCopy), _ => None, }, // Printable glyphs are already handled by the diff --git a/src/sys.rs b/src/sys.rs index 203246d63..eadfc27af 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -31,6 +31,44 @@ pub async fn read_clipboard_text() -> Option { value.as_string() } +/// Web: write text to the system clipboard (fire-and-forget). Backs Ctrl+C in +/// plain text_input fields, whose iced-internal copy is a no-op on the web +/// (#346). The triggering keypress is a user gesture, so the write is allowed. +#[cfg(target_arch = "wasm32")] +pub fn write_clipboard_text(text: &str) { + if let Some(window) = web_sys::window() { + let _ = window.navigator().clipboard().write_text(text); + } +} + +/// Web: replay `text` into the focused iced widget as synthetic KeyboardEvents +/// dispatched on the winit canvas. This is the only route INTO a focused +/// text_input on the web — iced's clipboard read is a no-op there and it +/// exposes no insert-text operation (#346). Control characters are dropped +/// and the length capped so a runaway clipboard can't wedge the event loop. +#[cfg(target_arch = "wasm32")] +pub fn synthesize_typing(text: &str) { + let Some(canvas) = web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.query_selector("canvas").ok().flatten()) + else { + return; + }; + for ch in text.chars().filter(|c| !c.is_control()).take(1024) { + let init = web_sys::KeyboardEventInit::new(); + init.set_key(&ch.to_string()); + init.set_bubbles(true); + init.set_cancelable(true); + for kind in ["keydown", "keyup"] { + if let Ok(ev) = + web_sys::KeyboardEvent::new_with_keyboard_event_init_dict(kind, &init) + { + let _ = canvas.dispatch_event(&ev); + } + } + } +} + /// Turn an `rfd` file handle into a `PathBuf` the rest of the app keys on. /// /// Desktop returns the real filesystem path. The browser has no path, so we