diff --git a/Cargo.toml b/Cargo.toml index 061e5cce4..858c23de7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/src/app/mod.rs b/src/app/mod.rs index 561507c5c..1bbf3e896 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, 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), + /// System-clipboard text read for the single-line TEXT editor. + TextInlinePasteClip(Option), OpenFile, /// File picker returned. `Some((path, size_in_bytes))` → start loading; /// `None` → user cancelled the dialog (no overlay shown). diff --git a/src/app/update.rs b/src/app/update.rs index 3441ae569..260291048 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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() diff --git a/src/app/view.rs b/src/app/view.rs index 27b91d3ae..ad9b5f9c8 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -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 diff --git a/src/sys.rs b/src/sys.rs index 4443ea925..dbe759801 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -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 { + 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