forked from 1b-agent/opencadstudio-upstream
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
dd40e1f632
commit
5f08b77ebc
5 files changed
+147
No files matched your search
@@ -136,6 +136,13 @@ web-sys = { version = "0.3", features = [
|
|||||||
# Async clipboard read for paste into the text/MText editors (iced's own
|
# Async clipboard read for paste into the text/MText editors (iced's own
|
||||||
# clipboard read is a no-op on the web).
|
# clipboard read is a no-op on the web).
|
||||||
"Clipboard",
|
"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
|
# Error banner (web_diag): forward wgpu/naga errors to the browser console
|
||||||
# and inject a copyable DOM overlay (document.body → append_child).
|
# and inject a copyable DOM overlay (document.body → append_child).
|
||||||
"console",
|
"console",
|
||||||
|
|||||||
@@ -1265,6 +1265,17 @@ pub enum Message {
|
|||||||
OpenPathPicked(Option<(PathBuf, u64)>),
|
OpenPathPicked(Option<(PathBuf, u64)>),
|
||||||
/// A file was dragged from the desktop and dropped on the window (#344).
|
/// A file was dragged from the desktop and dropped on the window (#344).
|
||||||
FileDropped(PathBuf),
|
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
|
/// Pick from the one-shot snap override menu (Shift+RMB): only this snap
|
||||||
/// applies to the next point pick, then the configuration restores (#337).
|
/// applies to the next point pick, then the configuration restores (#337).
|
||||||
SnapOverridePick(crate::snap::SnapType),
|
SnapOverridePick(crate::snap::SnapType),
|
||||||
|
|||||||
@@ -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) => {
|
Message::SnapOverridePick(t) => {
|
||||||
self.snap_override_popup = None;
|
self.snap_override_popup = None;
|
||||||
self.snapper.set_override(t);
|
self.snapper.set_override(t);
|
||||||
|
|||||||
@@ -1901,6 +1901,13 @@ impl OpenCADStudio {
|
|||||||
Some(Message::Command("CUTCLIP".to_string()))
|
Some(Message::Command("CUTCLIP".to_string()))
|
||||||
}
|
}
|
||||||
"v" if status == Status::Ignored => Some(Message::PasteShortcut),
|
"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,
|
_ => None,
|
||||||
},
|
},
|
||||||
// Printable glyphs are already handled by the
|
// Printable glyphs are already handled by the
|
||||||
|
|||||||
+38
@@ -31,6 +31,44 @@ pub async fn read_clipboard_text() -> Option<String> {
|
|||||||
value.as_string()
|
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.
|
/// 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
|
/// Desktop returns the real filesystem path. The browser has no path, so we
|
||||||
|
|||||||
Reference in new issue
Block a user