mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 14:38:13 -06:00
Ctrl+V pastes a picture from the system clipboard onto the page (fixes #2494)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user