Ctrl+V pastes a picture from the system clipboard onto the page (fixes #2494)

This commit is contained in:
Marco Arnulfo
2026-10-15 13:00:32 +02:00
parent afa8e1843c
commit 6882ce3436
5 changed files with 106 additions and 4 deletions
+39
View File
@@ -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.
+2 -2
View File
@@ -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<Clip> {
pub(crate) fn read_clipboard() -> Option<Clip> {
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<Clip> {
cb.get_text().ok().filter(|t| !t.trim().is_empty()).map(Clip::Text)
}
fn png_from_rgba(width: usize, height: usize, rgba: &[u8]) -> Result<Vec<u8>, String> {
pub(crate) fn png_from_rgba(width: usize, height: usize, rgba: &[u8]) -> Result<Vec<u8>, 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());
+8
View File
@@ -722,6 +722,10 @@ pub struct PdfCraftApp {
/// system clipboard as a file when `system_clipboard` is on.
pub last_copied_file: Option<(String, Vec<u8>)>,
pub system_clipboard: bool,
/// What ⌘V reads instead of the system clipboard (tests).
pub clipboard_override: Option<create_ui::Clip>,
/// 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<u8>)>,
/// 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);
}
}
+55
View File
@@ -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]
+2 -2
View File
@@ -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"