fix(web): paste clipboard text into the TEXT / MText editors

Ctrl+V was globally bound to the entity paste command, so it never pasted
into an open text editor, and even when routed there iced's clipboard read
is a no-op on the web. Route Ctrl+V to the focused editor and, on the web,
read the system clipboard via the browser's async Clipboard API (the
keypress is a user gesture, so the read is permitted):

- MText editor: insert at the caret (web + native).
- TEXT field: append on the web; native lets the text_input paste itself.
- No editor open: falls through to the entity paste command as before.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-20 17:05:02 +03:00
1 parent 54106ca9b0
commit e1c5917644
5 files changed
+77 -1

No files matched your search

+3
View File
@@ -106,6 +106,9 @@ web-sys = { version = "0.3", features = [
"Blob",
"Url",
"Response",
# Async clipboard read for paste into the text/MText editors (iced's own
# clipboard read is a no-op on the web).
"Clipboard",
] }
# Browsers without WebGPU (e.g. Firefox by default) need wgpu's WebGL2 backend;
# `webgl` enables it. `fira-sans` embeds the default UI font — the web has no
+8
View File
@@ -799,6 +799,14 @@ pub enum Message {
PollWebFonts,
/// Web: a per-script font finished fetching — `Ok(bytes)` or `Err(reason)`.
WebFontLoaded(crate::scene::text::web_font::Script, Result<Vec<u8>, String>),
/// Ctrl+V. Routed by `update`: into the open text/MText editor (via an async
/// system-clipboard read, which is the only paste path that works on the
/// web) or, with no editor open, the entity paste command.
PasteShortcut,
/// System-clipboard text read for the MText editor (`None` = empty/denied).
MTextPasteClip(Option<String>),
/// System-clipboard text read for the single-line TEXT editor.
TextInlinePasteClip(Option<String>),
OpenFile,
/// File picker returned. `Some((path, size_in_bytes))` → start loading;
/// `None` → user cancelled the dialog (no overlay shown).
+51
View File
@@ -4840,6 +4840,57 @@ impl OpenCADStudio {
}
Task::none()
}
// Ctrl+V. The MText editor and (on the web) the TEXT editor read the
// system clipboard asynchronously — the only paste path that works
// in the browser, where the synchronous clipboard the iced
// text_input expects is empty. With no editor open it falls through
// to the entity paste command.
Message::PasteShortcut => {
if self.mtext_editor.is_some() {
// Web reads via the browser's async Clipboard API (iced's
// sync clipboard read returns nothing there); native uses
// iced's clipboard.
#[cfg(target_arch = "wasm32")]
return Task::perform(
crate::sys::read_clipboard_text(),
Message::MTextPasteClip,
);
#[cfg(not(target_arch = "wasm32"))]
return iced::clipboard::read().map(Message::MTextPasteClip);
}
if self.text_inline.is_some() {
// Web: the iced text_input can't reach the async clipboard,
// so paste it ourselves. Native: the focused text_input
// already handled Ctrl+V — doing it here would duplicate.
#[cfg(target_arch = "wasm32")]
return Task::perform(
crate::sys::read_clipboard_text(),
Message::TextInlinePasteClip,
);
#[cfg(not(target_arch = "wasm32"))]
return Task::none();
}
Task::done(Message::Command("PASTECLIP".to_string()))
}
Message::MTextPasteClip(text) => {
if let Some(text) = text.filter(|t| !t.is_empty()) {
// CR/LF arrive as line breaks; MText keeps "\n", drop "\r".
self.mtext_type(&text.replace('\r', ""));
self.rebuild_mtext_preview();
}
Task::none()
}
Message::TextInlinePasteClip(text) => {
if let Some(text) = text.filter(|t| !t.is_empty()) {
// Single-line field: collapse newlines, append at the end.
let flat = text.replace(['\r', '\n'], " ");
if let Some(ed) = self.text_inline.as_mut() {
ed.value.push_str(&flat);
}
}
Task::none()
}
Message::TextInlineOk => {
self.text_inline_commit();
Task::none()
+1 -1
View File
@@ -1769,7 +1769,7 @@ impl OpenCADStudio {
"y" => Some(Message::Redo),
"c" => Some(Message::Command("COPYCLIP".to_string())),
"x" => Some(Message::Command("CUTCLIP".to_string())),
"v" => Some(Message::Command("PASTECLIP".to_string())),
"v" => Some(Message::PasteShortcut),
_ => None,
},
// Printable glyphs are already handled by the
+14
View File
@@ -17,6 +17,20 @@ pub fn open_url(url: &str) {
}
}
/// Web: read text from the system clipboard via the async Clipboard API.
/// iced's own `clipboard::read` is a no-op on the web (the browser clipboard is
/// async + permission-gated), so the editor paste paths use this instead. The
/// Ctrl+V keypress that drives it is a user gesture, so the read is permitted.
/// Returns `None` when denied, empty, or unsupported.
#[cfg(target_arch = "wasm32")]
pub async fn read_clipboard_text() -> Option<String> {
let clipboard = web_sys::window()?.navigator().clipboard();
let value = wasm_bindgen_futures::JsFuture::from(clipboard.read_text())
.await
.ok()?;
value.as_string()
}
/// 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