From c110c579a24800bb841b5d643e2e34e161e81f13 Mon Sep 17 00:00:00 2001 From: Max Abramowitz Date: Fri, 9 Oct 2026 20:29:26 -0400 Subject: [PATCH] M4: delete pages from the Pages panel (#607) Right-clicking a thumbnail offers Delete pages, and Delete or Backspace with the pointer over the panel deletes too, as in the organize grid: the selection, or the current page. A document keeps at least one page, so with every page picked the item is off and the keys do nothing. The selection goes and the current page moves to the page after the deleted ones; one undoable step. Parity: view.thumbnail-context-ops notes and evidence include Delete pages. Co-authored-by: William Max & Co. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/panels.rs | 35 ++++++++++++++++++++ crates/ui-egui/tests/editing.rs | 58 +++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index aacc58c0..e8354e03 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -908,7 +908,17 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie { view.clear_page_selection(Some(view.current)); } + // Delete (or Backspace) deletes the selection, or the current page, likewise. + if !modal + && editable + && !ui.ctx().egui_wants_keyboard_input() + && ui.rect_contains_pointer(ui.clip_rect()) + && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace)) + { + delete_pages(view, info.pages.len()); + } let w = (ui.available_width() - 40.0).min(150.0); + let count = info.pages.len(); let mut rows: Vec = Vec::with_capacity(info.pages.len()); let mut dropped = false; for (i, p) in info.pages.iter().enumerate() { @@ -986,6 +996,13 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie view.pending_action = Some(crate::canvas::ViewAction::PastePages); ui.close(); } + ui.separator(); + let some_left = view.target_pages().len() < count; + // Never every page: a document keeps at least one. + if ui.add_enabled(editable && some_left, egui::Button::new(tl!("Delete pages"))).clicked() { + delete_pages(view, count); + ui.close(); + } }); }); ui.add_space(4.0); @@ -993,6 +1010,24 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie page_drag(ui, t, view, &rows, dropped); } +/// Queue deleting the selection (or the current page) of a document of `n` pages, unless that +/// would leave no page. The selection goes; the current page stays on the page it showed, or on +/// the page that follows the deleted ones. +fn delete_pages(view: &mut crate::DocView, n: usize) { + let mut pages = view.target_pages(); + pages.sort_unstable(); + pages.dedup(); + if pages.is_empty() || pages.len() >= n { + return; + } + // Deleted pages before it shift it up; if it was deleted itself, the page after the deleted + // run lands where it was (or the last page, when the deletion ran to the end). + let before = pages.iter().filter(|p| **p < view.current).count(); + view.current = view.current.saturating_sub(before).min(n.saturating_sub(pages.len() + 1)); + view.clear_page_selection(None); + view.pending_edit = Some(pdfcraft_engine::Edit::DeletePages { pages }); +} + /// The gap (0 = before the first page, n = after the last) the pointer points at in the Pages /// panel, whose thumbnails are `rows`, top to bottom. fn panel_gap(rows: &[Rect], y: f32) -> Option { diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 61410be2..b7d96d1d 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2077,3 +2077,61 @@ fn a_thumbnail_drag_that_ends_off_the_panel_or_outlives_the_pages_is_dropped() { h.state_mut().views[0].document_changed(&info); assert!(h.state().views[0].panel_drag.is_none()); } + +/// A Pages panel thumbnail (the rightmost node with that label). +fn panel_thumb(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Pos2 { + h.get_all_by_label(label).map(|n| n.rect()).max_by(|a, b| a.left().total_cmp(&b.left())).expect("the thumbnail").center() +} + +fn right_click_at(h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2) { + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +#[test] +fn deleting_pages_from_the_pages_panel_menu() { + let mut h = pages_panel(4); + let at = panel_thumb(&h, "Page 2"); + right_click_at(&mut h, at); + h.get_by_label("Delete pages").click(); + h.run_steps(4); + assert_eq!(page_texts(h.state()), ["Page 1", "Page 3", "Page 4"]); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Delete page")); + assert!(h.state().views[0].selected.is_empty(), "the deleted pages leave the selection"); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(page_texts(h.state()), ["Page 1", "Page 2", "Page 3", "Page 4"]); +} + +#[test] +fn the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page() { + let mut h = pages_panel(3); + // ⌘-click picks pages 2 and 3 (and the current page, 1: the first ⌘-click keeps it). + h.get_by_label("Page 2").click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + let (p1, p3) = (panel_thumb(&h, "Page 1"), panel_thumb(&h, "Page 3")); + h.hover_at(p3); + h.run_steps(1); + assert_eq!(h.state().views[0].selected.iter().copied().collect::>(), vec![0, 1, 2]); + // Every page picked: Delete does nothing, and the menu item is off. + h.key_press(Key::Delete); + h.run_steps(3); + assert_eq!(page_texts(h.state()).len(), 3, "a document keeps at least one page"); + right_click_at(&mut h, p3); + assert!(h.query_by(|n| n.label().as_deref() == Some("Delete pages") && n.is_disabled()).is_some()); + h.key_press(Key::Escape); + h.run_steps(2); + // Pages 2 and 3 picked (page 2 current): Delete deletes them, and page 1 becomes current. + h.state_mut().views[0].select_pages(&[1, 2]); + h.hover_at(p1); + h.run_steps(1); + h.key_press(Key::Delete); + h.run_steps(4); + assert_eq!(page_texts(h.state()), ["Page 1"]); + assert_eq!(h.state().views[0].current, 0); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index bbe7b097..2a172ae9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1238,8 +1238,8 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/viewer.rs::pages_panel_thumbnails_have_a_context_menu"] -notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Extract pages, Cut, Copy and Paste after. Not yet: Rotate, Delete, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel." +evidence = ["crates/ui-egui/tests/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"] +notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel." [[feature]] id = "view.bookmarks-panel"