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

+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);