fix(ui): honor current clipboard text and elapsed paste timing

Menu paste refuses cached images when system text supersedes them. Keyboard and menu paste share the text check; image validation reuses Create. Expire shortcut context by elapsed time even when frames are sparse. Regression tests fail with the old checks restored.

Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
This commit is contained in:
FredApps
2026-11-01 12:54:52 +01:00
co-authored by Codex GPT-6.1
parent f62c1cfafe
commit 2c6a0fed48
4 changed files with 75 additions and 32 deletions
+1 -5
View File
@@ -64,11 +64,7 @@ impl PdfCraftApp {
if paste {
let has_override_image = matches!(self.clipboard_override, Some(crate::create_ui::Clip::Image { .. }));
#[cfg(not(target_arch = "wasm32"))]
let system_has_text = if !has_override_image && self.system_clipboard {
crate::os_clipboard::with(|cb| cb.get_text()).map(|t| !t.is_empty()).unwrap_or(false)
} else {
false
};
let system_has_text = if !has_override_image && self.system_clipboard { crate::os_clipboard::has_text() } else { false };
#[cfg(target_arch = "wasm32")]
let system_has_text = false;
+10
View File
@@ -16,3 +16,13 @@ pub(crate) fn with<T>(f: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboa
let mut clipboard = arboard::Clipboard::new()?;
f(&mut clipboard)
}
/// Nonempty text supersedes the app's cached image, even if another clipboard format also exists.
pub(crate) fn has_text() -> bool {
with(|cb| cb.get_text()).is_ok_and(|text| !text.is_empty())
}
/// The system clipboard as image paste sees it; text takes precedence over a cached image.
pub(crate) fn for_image_paste() -> Option<crate::create_ui::Clip> {
if has_text() { Some(crate::create_ui::Clip::Text(String::new())) } else { crate::create_ui::read_clipboard() }
}
+62 -26
View File
@@ -1046,9 +1046,7 @@ impl crate::PdfCraftApp {
if modifiers.command {
self.last_command_frame = Some((self.frame_count, inp.time));
}
let command_recent = self
.last_command_frame
.is_some_and(|(f, t)| self.frame_count.saturating_sub(f) <= 10 || (inp.time > 0.0 && (inp.time - t).abs() < 0.35));
let command_recent = self.last_command_frame.is_some_and(|(_, t)| inp.time >= t && inp.time - t < 0.35);
let command_active = modifiers.command || command_recent;
if !*pressed && !self.v_pressed && command_active {
released = true;
@@ -1071,9 +1069,10 @@ impl crate::PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
{
if self.system_clipboard {
let has_img = crate::os_clipboard::with(|cb| cb.get_image()).is_ok();
if has_img {
return true;
match crate::os_clipboard::for_image_paste() {
Some(crate::create_ui::Clip::Image { .. }) => return true,
Some(crate::create_ui::Clip::Text(_)) => return false,
None => {}
}
}
}
@@ -1091,6 +1090,14 @@ impl crate::PdfCraftApp {
}
fn paste_image_internal(&mut self, page: usize, rect: Option<[f64; 4]>, at: Option<[f64; 2]>) -> bool {
#[cfg(not(target_arch = "wasm32"))]
let system = if self.clipboard_override.is_none() && self.system_clipboard { crate::os_clipboard::for_image_paste() } else { None };
#[cfg(target_arch = "wasm32")]
let system = None;
self.paste_image_with_system(page, rect, at, system)
}
fn paste_image_with_system(&mut self, page: usize, rect: Option<[f64; 4]>, at: Option<[f64; 2]>, system: Option<crate::create_ui::Clip>) -> bool {
let Some((i, id)) = self.active_ids() else {
return false;
};
@@ -1116,27 +1123,17 @@ impl crate::PdfCraftApp {
}
Some(crate::create_ui::Clip::Text(_)) => None,
None => {
#[cfg(not(target_arch = "wasm32"))]
let sys_result = if self.system_clipboard {
crate::os_clipboard::with(|cb| cb.get_image()).ok().and_then(|img| {
let expected = img.width.checked_mul(img.height).and_then(|p| p.checked_mul(4));
if img.width > 0
&& img.height > 0
&& img.width <= crate::create_ui::MAX_IMAGE_SIDE
&& img.height <= crate::create_ui::MAX_IMAGE_SIDE
&& expected == Some(img.bytes.len())
&& img.bytes.len() <= crate::create_ui::MAX_IMAGE_BYTES
{
Some((img.width, img.height, img.bytes.into_owned()))
} else {
None
}
})
} else {
None
if matches!(system, Some(crate::create_ui::Clip::Text(_))) {
self.last_copied_image = None;
self.last_copied_file = None;
self.last_copied_image_rect = None;
self.notify_tr("No image on clipboard to paste");
return false;
}
let sys_result = match system {
Some(crate::create_ui::Clip::Image { width, height, rgba }) => Some((width, height, rgba)),
_ => None,
};
#[cfg(target_arch = "wasm32")]
let sys_result: Option<(usize, usize, Vec<u8>)> = None;
if let Some((img_width, img_height, img_bytes)) = sys_result {
let is_same_as_last = self
@@ -1324,6 +1321,45 @@ mod tests {
use super::*;
#[test]
fn system_text_prevents_context_menu_paste_of_a_cached_image() {
let mut app = crate::PdfCraftApp::new();
app.system_clipboard = false;
app.create_blank();
let id = app.active_ids().unwrap().1;
app.last_copied_image = Some((1, 1, vec![255, 0, 0, 255]));
let undo = app.session.get(id).unwrap().can_undo().map(str::to_owned);
assert!(!app.paste_image_with_system(0, None, Some([20.0, 20.0]), Some(crate::create_ui::Clip::Text("new text".into()))));
assert_eq!(app.session.get(id).unwrap().can_undo().map(str::to_owned), undo);
assert!(app.session.get(id).unwrap().page_images(0).is_empty());
assert!(app.last_copied_image.is_none());
}
#[test]
fn a_plain_v_release_long_after_command_is_not_a_paste() {
let mut app = crate::PdfCraftApp::new();
let ctx = egui::Context::default();
app.frame_count = 1;
app.last_command_frame = Some((1, 1.0));
ctx.run_ui(
egui::RawInput {
time: Some(10.0),
events: vec![egui::Event::Key {
key: egui::Key::V,
physical_key: None,
pressed: false,
repeat: false,
modifiers: egui::Modifiers::NONE,
}],
..Default::default()
},
|ui| {
assert!(!app.paste_key_released(ui.ctx()), "elapsed time expires command even if no frames were drawn");
},
)
.drop_without_applying_deltas();
}
/// The image a flip button leaves behind, as the panel returns it.
fn flipped(tooltip: &str) -> pdfcraft_engine::AddedImage {
let img = pdfcraft_engine::AddedImage::new([0.0, 0.0, 100.0, 50.0], pdfcraft_cos::ObjRef::new(7, 0));
+2 -1
View File
@@ -272,7 +272,8 @@ fn typing_moving_styling_and_deleting_added_content() {
#[test]
fn a_picture_on_the_clipboard_is_pasted_onto_the_page() {
use pdfcraft_ui_egui::Clip;
let mut h = harness();
// Three frames between modifier releases represent a short real key gesture.
let mut h = harness_with_step_dt(1.0 / 60.0);
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.