feat(web): copy/paste in text fields via the browser clipboard (#346)

iced's clipboard rides window_clipboard, which has no wasm backend — a
focused text_input captured Ctrl+C/V on the web and did nothing.

Ctrl+V (captured, wasm only): read the async browser clipboard (the
keypress is a user gesture, so it's permitted) and replay the text as
synthetic KeyboardEvents on the winit canvas — the only route into a
focused text_input, since iced exposes no insert-text operation.
Control characters are dropped and the length is capped. The MText and
inline-TEXT editors keep their dedicated paste paths (no double feed),
and an unfocused Ctrl+V still runs PASTECLIP.

Ctrl+C (captured, wasm only): a widget operation pairs each text_input
with its focus state (iced fires both callbacks back-to-back on the
same widget) to collect the focused field's visible text, then writes
it with navigator.clipboard.writeText. Whole-field text, not the
selection — iced doesn't expose the selected range.

Native behaviour is unchanged.

Closes #346

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Fable 5 committed 2026-07-22 12:47:19 +03:00
1 parent dd40e1f632
commit 5f08b77ebc
5 files changed
+147

No files matched your search

+11
View File
@@ -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<String>),
/// 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<String>),
/// 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),
+84
View File
@@ -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<String>,
found: Option<String>,
}
impl Operation<Option<String>> 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<Option<String>>),
) {
operate(self);
}
fn finish(&self) -> Outcome<Option<String>> {
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);
+7
View File
@@ -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