diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 5f5ef32f77..44d4f08c47 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -838,6 +838,45 @@ impl crate::PdfCraftApp { self.left_open = true; } } + + /// Whether ⌘V / Ctrl+V was let go this frame. egui-winit swallows that press (with a Paste + /// event only if the clipboard holds text), while a plain V press always comes through: a + /// release of V with no press of V since the last one is the shortcut, even when ⌘ or Ctrl + /// was let go first. Read every frame, dialogs or not, so a press is never missed. + pub(crate) fn paste_key_released(&mut self, ctx: &egui::Context) -> bool { + let mut released = false; + ctx.input(|inp| { + for e in &inp.events { + if let egui::Event::Key { key: egui::Key::V, pressed, .. } = e { + released |= !*pressed && !self.v_pressed; + self.v_pressed = *pressed; + } + } + }); + released + } + + /// ⌘V / Ctrl+V with a picture on the system clipboard (a screenshot, a picture copied in a + /// browser) places it on the current page, as Add Image does. + /// Organize Pages keeps ⌘V for pages, and text fields for text. + pub(crate) fn paste_image_key(&mut self, i: usize, ctx: &egui::Context, pasted: bool) { + let free = self.dialog.is_none() && !self.palette_open && !ctx.egui_wants_keyboard_input(); + let Some(view) = self.views.get(i) else { return }; + if !pasted || !free || view.organize || view.forms.focus.is_some() { + return; + } + let clip = match self.clipboard_override.clone() { + Some(c) => Some(c), + #[cfg(not(target_arch = "wasm32"))] + None if self.system_clipboard => crate::create_ui::read_clipboard(), + None => None, + }; + let Some(crate::create_ui::Clip::Image { width, height, rgba }) = clip else { return }; + match crate::create_ui::png_from_rgba(width, height, &rgba) { + Ok(png) => self.add_image("Pasted image.png".into(), png), + Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", tl!("Clipboard")), ("e", &e)]), + } + } } /// What the image tools ask for. diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index 63f8035251..cdd783a627 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -58,7 +58,7 @@ pub enum Clip { /// An image on the system clipboard, or else its text. #[cfg(not(target_arch = "wasm32"))] -fn read_clipboard() -> Option { +pub(crate) fn read_clipboard() -> Option { let mut cb = arboard::Clipboard::new().ok()?; if let Ok(img) = cb.get_image() { return Some(Clip::Image { width: img.width, height: img.height, rgba: img.bytes.into_owned() }); @@ -66,7 +66,7 @@ fn read_clipboard() -> Option { cb.get_text().ok().filter(|t| !t.trim().is_empty()).map(Clip::Text) } -fn png_from_rgba(width: usize, height: usize, rgba: &[u8]) -> Result, String> { +pub(crate) fn png_from_rgba(width: usize, height: usize, rgba: &[u8]) -> Result, String> { let (w, h) = (u32::try_from(width).map_err(|e| e.to_string())?, u32::try_from(height).map_err(|e| e.to_string())?); if w == 0 || h == 0 || rgba.len() != width * height * 4 { return Err("the clipboard image is empty or malformed".into()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d101d812e0..bd435f375f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -722,6 +722,10 @@ pub struct PdfCraftApp { /// system clipboard as a file when `system_clipboard` is on. pub last_copied_file: Option<(String, Vec)>, pub system_clipboard: bool, + /// What ⌘V reads instead of the system clipboard (tests). + pub clipboard_override: Option, + /// V is down as a plain key press (see [`Self::paste_key_released`]). + v_pressed: bool, /// Attach file: the file to attach instead of asking (tests, automation). pub attach_override: Option<(String, Vec)>, /// Duplicate Field: which field and onto which pages. @@ -1020,6 +1024,8 @@ impl PdfCraftApp { last_copied_image: None, last_copied_file: None, system_clipboard: true, + clipboard_override: None, + v_pressed: false, attach_override: None, duplicate_draft: None, form_preview: false, @@ -2767,6 +2773,7 @@ impl PdfCraftApp { fn shortcuts(&mut self, ctx: &egui::Context) { use egui::Key; + let pasted = self.paste_key_released(ctx); // "Save changes?" is modal: its keys are its own (⌘D is Don't save there, not Document // properties; Escape cancels it rather than clearing a selection), and nothing may run // underneath it. They are read here, before the canvas can consume them. @@ -2815,6 +2822,7 @@ impl PdfCraftApp { } canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed); self.copy_key(i, ctx); + self.paste_image_key(i, ctx, pasted); self.image_pick_keys(i, ctx); } } diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs index dd2c032eff..c8f5270a1b 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/content.rs @@ -275,6 +275,61 @@ fn typing_moving_styling_and_deleting_added_content() { assert!(img.flip_h); } +/// #2494: ⌘V / Ctrl+V with a picture on the system clipboard (a screenshot, a picture copied in a +/// browser) places it on the current page, as Add Image does. +#[test] +fn a_picture_on_the_clipboard_is_pasted_onto_the_page() { + use pdfcraft_ui_egui::Clip; + let mut h = harness(); + let v = |pressed, modifiers| egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers }; + let images = |h: &Harness<'static, PdfCraftApp>| added(h).iter().filter(|a| matches!(a.content, pdfcraft_engine::AddedContent::Image(_))).count(); + // Text on the clipboard: egui gets a Paste event instead of the press; nothing lands on the page. + h.state_mut().clipboard_override = Some(Clip::Text("hello".into())); + h.event(egui::Event::Paste("hello".into())); + h.event(v(false, egui::Modifiers::COMMAND)); + h.run_steps(3); + assert_eq!(images(&h), 0); + h.state_mut().clipboard_override = Some(Clip::Image { width: 30, height: 20, rgba: [90u8, 90, 90, 255].repeat(30 * 20) }); + // A plain V comes through pressed and released: it picks the Select tool, it doesn't paste. + h.event(v(true, egui::Modifiers::NONE)); + h.run_steps(1); + h.event(v(false, egui::Modifiers::NONE)); + h.run_steps(3); + assert_eq!(images(&h), 0, "a plain V doesn't paste"); + // A picture alone: egui-winit sends no Paste event and swallows the press, so only the + // release of V arrives. + h.event(v(false, egui::Modifiers::COMMAND)); + h.run_steps(3); + let a = added(&h); + assert_eq!(a.len(), 1); + let pdfcraft_engine::AddedContent::Image(img) = &a[0].content else { panic!("an image was added") }; + assert_eq!(img.rect, [135.0, 190.0, 165.0, 210.0], "in the middle of the page, at its own size"); + assert_eq!(h.state().views[0].content.selected, Some((0, 0))); + { + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Add image")); + } + // Ctrl let go a moment before V: the release comes without the modifier, and still pastes. + h.event(v(false, egui::Modifiers::NONE)); + h.run_steps(3); + assert_eq!(images(&h), 2, "Ctrl released first"); + // A text field with the keyboard keeps ⌘V for itself. + assert!(h.state_mut().execute("edit.find")); + h.run_steps(3); + h.event(v(false, egui::Modifiers::COMMAND)); + h.run_steps(3); + assert_eq!(images(&h), 2, "the Find field has the keyboard"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + // Organize Pages keeps ⌘V for pages. + assert!(h.state_mut().execute("page.organize")); + h.run_steps(4); + assert!(h.state().views[0].organize); + h.event(v(false, egui::Modifiers::COMMAND)); + h.run_steps(3); + assert_eq!(images(&h), 2, "Organize Pages pastes pages"); +} + /// #125: text the standard fonts can't draw stays in the editor with a warning instead of being /// written to the page as `?` (or lost). #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e841dec082..f5602c6982 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2070,8 +2070,8 @@ milestone = "M7" status = "shipped" commands = ["edit.image"] tools = ["page_add_image"] -evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable"] -notes = "PNG, JPEG, TIFF, GIF, BMP; centred at natural size or in a rect" +evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/content.rs::a_picture_on_the_clipboard_is_pasted_onto_the_page", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable"] +notes = "PNG, JPEG, TIFF, GIF, BMP; centred at natural size or in a rect. ⌘V / Ctrl+V pastes a picture from the system clipboard onto the current page the same way (desktop only)" [[feature]] id = "edit.image-replace"