mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 22:41:47 -06:00
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:
co-authored by
Codex GPT-6.1
parent
f62c1cfafe
commit
2c6a0fed48
@@ -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;
|
||||
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user