mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 22:41:47 -06:00
Fix context image placement and canvas selection ownership
Convert context-paste user coordinates into AddImage display coordinates and identify the inserted image through its new owning stream and XObject. Keep legacy added-image metadata aligned with the active image controller, route Delete to the visible selection, and clear hidden image selections when leaving the controller while preserving text selections and drafts. Separate selection-dependent Copy from whole-page Snapshot and document Export permissions. Add synthetic UI regressions covering transformed page placement, identity collisions, shared image references, saved deletion results, tool transitions, and permission controls. Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
This commit is contained in:
co-authored by
Codex GPT-6.1
parent
75e012de9a
commit
6816d14e02
@@ -768,6 +768,7 @@ pub(crate) struct PageMenuPerms {
|
||||
pub can_annotate: bool,
|
||||
pub can_print: bool,
|
||||
pub can_copy: bool,
|
||||
pub can_copy_items: bool,
|
||||
pub can_paste: bool,
|
||||
pub has_annotations: bool,
|
||||
}
|
||||
@@ -796,7 +797,7 @@ pub(crate) fn default_page_context_menu(ui: &mut egui::Ui, page: usize, at: [f64
|
||||
|
||||
pick(
|
||||
ui.add_enabled(
|
||||
perms.can_copy,
|
||||
perms.can_copy_items,
|
||||
egui::Button::new(tl!("Copy")).shortcut_text(crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY)),
|
||||
),
|
||||
ui,
|
||||
@@ -1692,6 +1693,15 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
|
||||
let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view);
|
||||
!others_own_pointer && (text_used || image_used)
|
||||
};
|
||||
if tool == QuickTool::EditText {
|
||||
if others_own_pointer {
|
||||
if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() {
|
||||
view.image_selection = None;
|
||||
}
|
||||
} else {
|
||||
crate::content_ui::sync_image_selection(view, i, &images, added);
|
||||
}
|
||||
}
|
||||
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view);
|
||||
if tool == QuickTool::Select
|
||||
&& resp.secondary_clicked()
|
||||
@@ -2673,7 +2683,8 @@ fn draw_pages(
|
||||
can_assemble: doc.allows_assembly(),
|
||||
can_annotate: allowed,
|
||||
can_print: doc.allows_printing(),
|
||||
can_copy: can_copy_items,
|
||||
can_copy,
|
||||
can_copy_items,
|
||||
can_paste,
|
||||
has_annotations: !info.annotations.is_empty(),
|
||||
};
|
||||
@@ -3744,17 +3755,18 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
if preparing {
|
||||
crate::prepare::keys(ui.ctx(), view);
|
||||
}
|
||||
if tool != QuickTool::EditText {
|
||||
view.image_selection = None;
|
||||
crate::content_ui::release_image_selection(view, &added);
|
||||
}
|
||||
if editing_content {
|
||||
crate::content_ui::keys(ui.ctx(), view);
|
||||
crate::content_ui::keys(ui.ctx(), view, tool == QuickTool::EditText);
|
||||
}
|
||||
if tool == QuickTool::Link {
|
||||
crate::link_ui::keys(ui.ctx(), view);
|
||||
} else {
|
||||
view.links.selected = None;
|
||||
}
|
||||
if tool != QuickTool::EditText {
|
||||
view.image_selection = None;
|
||||
}
|
||||
if stamp_placed {
|
||||
tool = QuickTool::Select;
|
||||
}
|
||||
|
||||
@@ -542,9 +542,52 @@ pub(crate) fn paint_page(
|
||||
}
|
||||
}
|
||||
|
||||
/// The image controller owns image selection; the added-content panel follows the same XObject.
|
||||
pub(crate) fn sync_image_selection(view: &mut DocView, page: usize, images: &[pdfcraft_engine::PageImage], added: &[Added]) {
|
||||
let Some(selection) = view.image_selection.as_ref().filter(|selection| selection.page == page) else { return };
|
||||
if view.content.draft.is_some() {
|
||||
return;
|
||||
}
|
||||
let object = images
|
||||
.get(selection.index)
|
||||
.and_then(|image| image.object)
|
||||
.filter(|object| images.iter().filter(|image| image.object == Some(*object)).count() == 1);
|
||||
view.content.selected = object.and_then(|object| {
|
||||
let mut matches = added
|
||||
.iter()
|
||||
.filter(|item| item.page == page)
|
||||
.enumerate()
|
||||
.filter(|(_, item)| matches!(&item.content, AddedContent::Image(image) if image.image == object));
|
||||
let selected = matches.next().map(|(index, _)| (page, index));
|
||||
if matches.next().is_none() { selected } else { None }
|
||||
});
|
||||
view.content.select_added = None;
|
||||
view.content.grab = None;
|
||||
}
|
||||
|
||||
/// Leaving the image controller cannot leave a hidden added image owning keyboard actions.
|
||||
pub(crate) fn release_image_selection(view: &mut DocView, added: &[Added]) {
|
||||
if view.content.draft.is_some() {
|
||||
return;
|
||||
}
|
||||
let image_selected = view.content.selected.is_some_and(|(page, index)| {
|
||||
added.iter().filter(|item| item.page == page).nth(index).is_some_and(|item| matches!(item.content, AddedContent::Image(_)))
|
||||
});
|
||||
if image_selected {
|
||||
view.content.selected = None;
|
||||
view.content.select_added = None;
|
||||
view.content.grab = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete removes the selected item; Escape clears the selection.
|
||||
pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView) {
|
||||
if view.content.selected.is_none() || view.content.draft.is_some() || ctx.egui_wants_keyboard_input() {
|
||||
pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, image_controller_active: bool) {
|
||||
if image_controller_active && view.image_selection.is_some()
|
||||
|| matches!(view.pending_edit.as_ref(), Some(Edit::EditPageImage { .. }))
|
||||
|| view.content.selected.is_none()
|
||||
|| view.content.draft.is_some()
|
||||
|| ctx.egui_wants_keyboard_input()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let (del, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace), i.key_pressed(egui::Key::Escape)));
|
||||
@@ -1180,35 +1223,34 @@ impl crate::PdfCraftApp {
|
||||
self.session.get(id).and_then(|d| d.info.pages.get(page)).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0));
|
||||
let k = ((pw * 0.8) / raw_w.max(1.0)).min((ph * 0.8) / raw_h.max(1.0)).min(1.0);
|
||||
let (w, h) = (raw_w * k, raw_h * k);
|
||||
Some([at[0] - w * 0.5, at[1] - h * 0.5, at[0] + w * 0.5, at[1] + h * 0.5])
|
||||
let Some(info) = self.session.get(id).and_then(|doc| doc.info.pages.get(page)) else { return false };
|
||||
let view = info.user_to_view(at[0] as f32, at[1] as f32).map(f64::from);
|
||||
// Added content uses the page's bottom-left display origin; rendered view space is y-down.
|
||||
let center = [view[0], f64::from(info.height) - view[1]];
|
||||
Some([center[0] - w * 0.5, center[1] - h * 0.5, center[0] + w * 0.5, center[1] + h * 0.5])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let added_before: std::collections::HashSet<_> =
|
||||
self.session.get(id).into_iter().flat_map(|doc| doc.added()).filter(|item| item.page == page).map(|item| item.obj).collect();
|
||||
if self.apply_edit(pdfcraft_engine::Edit::AddImage { page, rect: target_rect, name, bytes: std::sync::Arc::new(bytes) }) {
|
||||
let added_count = self.session.get(id).map_or(0, |d| d.added().iter().filter(|a| a.page == page).count());
|
||||
let added_idx = added_count.saturating_sub(1);
|
||||
let pasted_idx = self.session.get(id).map_or(0, |d| {
|
||||
let images = d.page_images(page);
|
||||
if let Some(target) = target_rect {
|
||||
images
|
||||
.iter()
|
||||
.rposition(|im| {
|
||||
(im.rect[0] - target[0]).abs() < 1e-2
|
||||
&& (im.rect[1] - target[1]).abs() < 1e-2
|
||||
&& (im.rect[2] - target[2]).abs() < 1e-2
|
||||
&& (im.rect[3] - target[3]).abs() < 1e-2
|
||||
})
|
||||
.unwrap_or(images.len().saturating_sub(1))
|
||||
} else {
|
||||
images.len().saturating_sub(1)
|
||||
let selection = self.session.get(id).and_then(|doc| {
|
||||
let mut inserted =
|
||||
doc.added().iter().filter(|item| item.page == page).enumerate().filter(|(_, item)| !added_before.contains(&item.obj));
|
||||
let (added_idx, item) = inserted.next()?;
|
||||
if inserted.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let AddedContent::Image(image) = &item.content else { return None };
|
||||
let pasted_idx = doc.page_images(page).iter().rposition(|placed| placed.object == Some(image.image))?;
|
||||
Some((added_idx, pasted_idx))
|
||||
});
|
||||
if let Some(v) = self.views.get_mut(i) {
|
||||
v.current = page;
|
||||
v.content.selected = Some((page, added_idx));
|
||||
v.content.select_added = Some((page, added_idx));
|
||||
v.image_selection = Some(crate::edit_text_ui::ImageSelection::at(page, pasted_idx));
|
||||
v.content.selected = selection.map(|(added, _)| (page, added));
|
||||
v.content.select_added = v.content.selected;
|
||||
v.image_selection = selection.map(|(_, image)| crate::edit_text_ui::ImageSelection::at(page, image));
|
||||
}
|
||||
self.quick_tool = crate::QuickTool::EditText;
|
||||
self.notify_tr("Image pasted");
|
||||
|
||||
@@ -57,6 +57,7 @@ mod open_action;
|
||||
mod organize_drag;
|
||||
mod pageboxes;
|
||||
mod panel_snapshots;
|
||||
mod paste_ownership;
|
||||
mod pdf_links;
|
||||
mod pickers;
|
||||
mod pinned_tools;
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Context paste coordinates, insertion identity and visible image ownership.
|
||||
use egui::{Key, Modifiers, PointerButton, Pos2};
|
||||
use egui_kittest::{
|
||||
Harness,
|
||||
kittest::{NodeT, Queryable},
|
||||
};
|
||||
use pdfcraft_engine::{AddedContent, AddedImage, Edit};
|
||||
use pdfcraft_ui_egui::{Clip, PdfCraftApp};
|
||||
|
||||
fn fixture(rotation: u16, cropped: bool, shared: bool) -> Vec<u8> {
|
||||
fixture_at(rotation, cropped, shared, false)
|
||||
}
|
||||
fn fixture_at(rotation: u16, cropped: bool, shared: bool, collision: bool) -> Vec<u8> {
|
||||
let stream = |body: &str, dict: &str| format!("<< {dict} /Length {} >>\nstream\n{body}\nendstream", body.len());
|
||||
let (media, crop, x, y) = if cropped { ("0 0 600 800", "150 250 450 550", 170, 270) } else { ("0 0 300 300", "0 0 300 300", 20, 20) };
|
||||
let (x, y, w, height) = if collision { (60, 80, 40, 20) } else { (x, y, 30, 30) };
|
||||
let extra = if shared { format!(" q 30 0 0 30 {} {y} cm /RedAgain Do Q", x + 50) } else { String::new() };
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [{media}] /CropBox [{crop}] /Rotate {rotation} /Resources << /XObject << /Red 5 0 R /RedAgain 5 0 R >> >> /Contents 4 0 R >>"
|
||||
),
|
||||
stream(&format!("q {w} 0 0 {height} {x} {y} cm /Red Do Q{extra}"), ""),
|
||||
stream("FF0000>", "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode"),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (index, object) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn harness(bytes: Vec<u8>) -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1000.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.system_clipboard = false;
|
||||
app.open_bytes("ownership.pdf", None, bytes.clone()).unwrap();
|
||||
app.clipboard_override = Some(Clip::Image { width: 40, height: 20, rgba: [0, 0, 255, 255].repeat(800) });
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn point(h: &Harness<'static, PdfCraftApp>, user: [f64; 2]) -> Pos2 {
|
||||
let app = h.state();
|
||||
let info = &app.session.get(app.views[0].id).unwrap().info.pages[0];
|
||||
let v = info.user_to_view(user[0] as f32, user[1] as f32);
|
||||
app.views[0].page_xform(0).unwrap().norm_to_screen(v[0] / info.width, v[1] / info.height)
|
||||
}
|
||||
|
||||
fn right_click(h: &mut Harness<'static, PdfCraftApp>, user: [f64; 2]) {
|
||||
let p = point(h, user);
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn reopened(app: &mut PdfCraftApp) -> PdfCraftApp {
|
||||
let id = app.views[0].id;
|
||||
let bytes = app.session.save_bytes(id).unwrap();
|
||||
let mut saved = PdfCraftApp::new();
|
||||
saved.open_bytes("saved.pdf", None, bytes.as_ref().clone()).unwrap();
|
||||
saved
|
||||
}
|
||||
|
||||
fn check_placement(rotation: u16, cropped: bool, view_rotation: u16, shared: bool, existing_added: bool, collision: bool) {
|
||||
let mut h = harness(fixture_at(rotation, cropped, shared, collision));
|
||||
if existing_added {
|
||||
let mut png = Vec::new();
|
||||
image::RgbImage::from_pixel(40, 40, image::Rgb([0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
h.state_mut().add_image("existing.png".into(), png);
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().added().len(), 1);
|
||||
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select;
|
||||
h.state_mut().views[0].image_selection = None;
|
||||
h.state_mut().views[0].content.selected = None;
|
||||
}
|
||||
h.state_mut().views[0].rotation = view_rotation;
|
||||
h.run_steps(3);
|
||||
let id = h.state().views[0].id;
|
||||
let originals: Vec<_> = h.state().session.get(id).unwrap().page_images(0).iter().map(|image| image.object.unwrap()).collect();
|
||||
let at = if cropped { [250.0, 400.0] } else { [210.0, 80.0] };
|
||||
right_click(&mut h, at);
|
||||
h.get_by_label_contains("Paste").click();
|
||||
h.run_steps(3);
|
||||
let doc = h.state().session.get(id).unwrap();
|
||||
let inserted = doc
|
||||
.added()
|
||||
.iter()
|
||||
.find_map(|item| match &item.content {
|
||||
AddedContent::Image(image) if !originals.contains(&image.image) => Some(image.image),
|
||||
_ => None,
|
||||
})
|
||||
.expect("a new image XObject was inserted");
|
||||
assert!(!originals.contains(&inserted), "embedding must allocate a fresh image identity");
|
||||
let selected = &doc.page_images(0)[h.state().views[0].image_selection.as_ref().unwrap().index];
|
||||
assert_eq!(selected.object, Some(inserted), "the visible selection must name the inserted XObject");
|
||||
let center = [(selected.rect[0] + selected.rect[2]) * 0.5, (selected.rect[1] + selected.rect[3]) * 0.5];
|
||||
assert!((center[0] - at[0]).abs() < 0.001 && (center[1] - at[1]).abs() < 0.001, "rotation={rotation}, crop={cropped}: {center:?} vs {at:?}");
|
||||
let saved = reopened(h.state_mut());
|
||||
let placed = saved.session.get(saved.views[0].id).unwrap().page_images(0).into_iter().find(|image| image.object == Some(inserted)).unwrap();
|
||||
assert!(((placed.rect[0] + placed.rect[2]) * 0.5 - at[0]).abs() < 0.001);
|
||||
assert!(((placed.rect[1] + placed.rect[3]) * 0.5 - at[1]).abs() < 0.001);
|
||||
h.key_press(Key::Delete);
|
||||
h.run_steps(3);
|
||||
let saved = reopened(h.state_mut());
|
||||
let doc = saved.session.get(saved.views[0].id).unwrap();
|
||||
assert_eq!(doc.added().len(), usize::from(existing_added));
|
||||
let remaining = doc.page_images(0);
|
||||
assert_eq!(remaining.len(), originals.len());
|
||||
assert!(remaining.iter().any(|image| image.name == "Red"));
|
||||
if shared {
|
||||
assert!(remaining.iter().any(|image| image.name == "RedAgain"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_paste_ordinary_page_control() {
|
||||
check_placement(0, false, 0, false, false, false);
|
||||
}
|
||||
#[test]
|
||||
fn context_paste_rotate90_places_and_selects_inserted_image() {
|
||||
check_placement(90, false, 0, false, false, false);
|
||||
}
|
||||
#[test]
|
||||
fn context_paste_rotate180_places_and_selects_inserted_image() {
|
||||
check_placement(180, false, 0, false, false, false);
|
||||
}
|
||||
#[test]
|
||||
fn context_paste_rotate270_places_and_selects_inserted_image() {
|
||||
check_placement(270, false, 0, false, false, false);
|
||||
}
|
||||
#[test]
|
||||
fn context_paste_crop_offset_places_and_selects_inserted_image() {
|
||||
check_placement(0, true, 0, false, false, false);
|
||||
}
|
||||
#[test]
|
||||
fn context_paste_rotated_crop_places_and_selects_inserted_image() {
|
||||
check_placement(90, true, 0, false, false, false);
|
||||
}
|
||||
#[test]
|
||||
fn context_paste_view_rotation_control() {
|
||||
check_placement(0, false, 90, false, false, false);
|
||||
}
|
||||
#[test]
|
||||
fn context_paste_shared_original_xobject_control() {
|
||||
check_placement(90, false, 0, true, false, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_paste_with_existing_added_image_control() {
|
||||
check_placement(90, false, 0, false, true, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_paste_identity_ignores_original_at_display_rectangle() {
|
||||
check_placement(90, false, 0, false, false, true);
|
||||
}
|
||||
|
||||
fn check_delete_owner(original: bool, shared_added: bool) {
|
||||
let mut h = harness(fixture(0, false, false));
|
||||
let id = h.state().views[0].id;
|
||||
if shared_added {
|
||||
let object = h.state().session.get(id).unwrap().page_images(0)[0].object.unwrap();
|
||||
let (extension, bytes) = h.state().session.get(id).unwrap().page_image_copy_file(0, 0).unwrap();
|
||||
assert!(h.state_mut().apply_edit(Edit::PasteContent {
|
||||
page: 0,
|
||||
content: AddedContent::Image(AddedImage::new([130.0, 130.0, 170.0, 170.0], object)),
|
||||
file: Some((format!("shared.{extension}"), std::sync::Arc::new(bytes)))
|
||||
}));
|
||||
assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), 2);
|
||||
assert!(h.state().session.get(id).unwrap().page_images(0).iter().all(|image| image.object == Some(object)));
|
||||
h.state_mut().execute("edit.edit_text");
|
||||
h.state_mut().views[0].content.selected = Some((0, 0));
|
||||
} else {
|
||||
let mut png = Vec::new();
|
||||
image::RgbImage::from_pixel(40, 40, image::Rgb([0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
h.state_mut().add_image("blue.png".into(), png);
|
||||
}
|
||||
h.run_steps(3);
|
||||
let p = point(&h, [150.0, 150.0]);
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
h.drag_at(p);
|
||||
h.run_steps(1);
|
||||
h.drop_at(p);
|
||||
h.run_steps(2);
|
||||
// The legacy editor can retain the added-image selection while its frame is hidden.
|
||||
h.state_mut().views[0].content.selected = Some((0, 0));
|
||||
let target = if original { [35.0, 35.0] } else { [150.0, 150.0] };
|
||||
right_click(&mut h, target);
|
||||
h.get_by_label("Copy Image").click();
|
||||
h.run_steps(2);
|
||||
let selection = h.state().views[0].image_selection.as_ref().unwrap();
|
||||
let selected = &h.state().session.get(id).unwrap().page_images(0)[selection.index];
|
||||
assert_eq!(selected.name == "Red", original, "the frame belongs to the requested occurrence");
|
||||
h.key_press(Key::Delete);
|
||||
h.run_steps(3);
|
||||
let saved = reopened(h.state_mut());
|
||||
let doc = saved.session.get(saved.views[0].id).unwrap();
|
||||
assert_eq!(doc.page_images(0).len(), 1);
|
||||
assert_eq!(doc.page_images(0).iter().any(|image| image.name == "Red"), !original, "Delete must remove the visibly selected occurrence");
|
||||
assert_eq!(doc.added().len(), usize::from(original), "the other item must survive save/reopen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_image_selection_owns_delete_over_hidden_added_image() {
|
||||
check_delete_owner(true, false);
|
||||
}
|
||||
#[test]
|
||||
fn added_image_selection_delete_control() {
|
||||
check_delete_owner(false, false);
|
||||
}
|
||||
#[test]
|
||||
fn shared_xobject_original_occurrence_owns_delete() {
|
||||
check_delete_owner(true, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_page_actions_do_not_require_a_selection() {
|
||||
let mut h = harness(fixture(0, false, false));
|
||||
right_click(&mut h, [210.0, 80.0]);
|
||||
assert!(h.get_by_label_contains("Copy").accesskit_node().is_disabled());
|
||||
assert!(!h.get_by_label("Take a snapshot").accesskit_node().is_disabled());
|
||||
assert!(!h.get_by_label("Export this PDF").accesskit_node().is_disabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_page_actions_still_require_copy_permission() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.open_bytes("source.pdf", None, fixture(0, false, false)).unwrap();
|
||||
assert!(app.apply_edit(Edit::Protect(pdfcraft_engine::Protection { permissions_password: Some("owner".into()), ..Default::default() })));
|
||||
let bytes = app.session.save_bytes(app.views[0].id).unwrap();
|
||||
let mut h = harness(bytes.as_ref().clone());
|
||||
right_click(&mut h, [210.0, 80.0]);
|
||||
assert!(h.get_by_label("Take a snapshot").accesskit_node().is_disabled());
|
||||
assert!(h.get_by_label("Export this PDF").accesskit_node().is_disabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_text_delete_survives_switching_out_of_the_image_tool() {
|
||||
let mut h = harness(fixture(0, false, false));
|
||||
h.state_mut().execute("edit.edit_text");
|
||||
right_click(&mut h, [35.0, 35.0]);
|
||||
h.get_by_label("Copy Image").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().views[0].image_selection.is_some());
|
||||
assert!(h.state_mut().apply_edit(Edit::AddText {
|
||||
page: 0,
|
||||
text: pdfcraft_engine::AddedText { text: "note".into(), rect: [100.0, 100.0, 180.0, 130.0], ..Default::default() }
|
||||
}));
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().added().len(), 1);
|
||||
h.state_mut().views[0].content.selected = Some((0, 0));
|
||||
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select;
|
||||
h.key_press(Key::Delete);
|
||||
h.run_steps(3);
|
||||
let saved = reopened(h.state_mut());
|
||||
let doc = saved.session.get(saved.views[0].id).unwrap();
|
||||
assert!(doc.added().is_empty());
|
||||
assert_eq!(doc.page_images(0).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_image_controller_cannot_leave_hidden_image_owning_delete() {
|
||||
let mut h = harness(fixture(0, false, false));
|
||||
let mut png = Vec::new();
|
||||
image::RgbImage::from_pixel(40, 40, image::Rgb([0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
h.state_mut().add_image("blue.png".into(), png);
|
||||
h.run_steps(3);
|
||||
// Start from no selection, then let the controller select the added image itself.
|
||||
h.state_mut().views[0].image_selection = None;
|
||||
h.state_mut().views[0].content.selected = None;
|
||||
let p = point(&h, [150.0, 150.0]);
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
h.drag_at(p);
|
||||
h.run_steps(1);
|
||||
h.drop_at(p);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().views[0].image_selection.is_some());
|
||||
h.key_press(Key::V);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, pdfcraft_ui_egui::QuickTool::Select);
|
||||
assert!(h.state().views[0].image_selection.is_none());
|
||||
h.key_press(Key::Delete);
|
||||
h.run_steps(3);
|
||||
let saved = reopened(h.state_mut());
|
||||
let doc = saved.session.get(saved.views[0].id).unwrap();
|
||||
assert_eq!(doc.page_images(0).len(), 2, "there is no visible image selection to delete");
|
||||
assert_eq!(doc.added().len(), 1);
|
||||
}
|
||||
Reference in New Issue
Block a user