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 <[email protected]>
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
|
||||
# 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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
+38
@@ -31,6 +31,44 @@ pub async fn read_clipboard_text() -> Option<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.
|
||||
///
|
||||
/// Desktop returns the real filesystem path. The browser has no path, so we
|
||||
|
||||
Reference in new issue
Block a user