M4: replace selected pages from organize toolbar and context menus (#786) (#1149)

Add 'Replace pages…' action to the Organize Pages toolbar, organize grid thumbnail context menu, and Pages panel context menu to replace selected pages.

- Add ViewAction::ReplacePages triggering replace_pages_dialog()
- Expose 'Replace pages…' button with 'replace' icon on the organize toolbar
- Expose 'Replace pages…' on the organize grid and Pages panel thumbnail context menus
- Update parity evidence for organize.replace-pages
- Add integration tests for toolbar and context menu replace actions in crates/ui-egui/tests/editing.rs

Co-authored-by: KrzysztofC <krzysztofc@localhost-live.lan>
This commit is contained in:
Krzysztof Cieślik
2026-10-11 18:32:29 -04:00
committed by GitHub
co-authored by KrzysztofC
parent 036e00f2a1
commit 38df401687
7 changed files with 102 additions and 6 deletions
+1 -2
View File
@@ -64,13 +64,12 @@ impl Session {
let (mut ra, mut rb) = (renderer(a), renderer(b));
let scale = dpi.clamp(18.0, 150.0) / 72.0;
let mut out = Vec::new();
for page in 0..a.info.pages.len().min(b.info.pages.len()) {
for (page, info) in b.info.pages.iter().enumerate().take(a.info.pages.len()) {
let req = pdfcraft_render::RenderRequest { page, scale, ..Default::default() };
let (x, y) = (ra.render(req), rb.render(req));
if x.error.is_some() || y.error.is_some() {
continue;
}
let info = &b.info.pages[page];
let s = y.width as f32 / info.width.max(1e-3);
for r in pdfcraft_compare::visual_regions((&y.rgba, y.width, y.height), (&x.rgba, x.width, x.height), 24) {
let p = info.view_to_user(r[0] as f32 / s, r[1] as f32 / s);
+15 -1
View File
@@ -499,6 +499,8 @@ pub enum ViewAction {
InsertFromFile,
/// Insert files at a gap of the page grid (0 = before the first page).
InsertFromFileAt(usize),
/// Replace the selected pages with pages from another file.
ReplacePages,
/// Save the document as the grid shows it.
Save,
Extract,
@@ -3805,6 +3807,9 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: b
if icons::button(ui, "file-input", 30.0, false, tl!("Insert pages from a file…")).clicked() {
view.pending_action = Some(ViewAction::InsertFromFile);
}
if icons::button(ui, "replace", 30.0, false, tl!("Replace pages…")).clicked() {
view.pending_action = Some(ViewAction::ReplacePages);
}
if icons::button(ui, "file-output", 30.0, false, tl!("Extract pages to a new document")).clicked() {
view.pending_action = Some(ViewAction::Extract);
}
@@ -4085,12 +4090,21 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
if resp.double_clicked() {
open_page = Some(i);
}
// Right-click: Cut, Copy, Paste (on the selection, or this page).
// Right-click: Extract, Replace, Cut, Copy, Paste (on the selection, or this page).
resp.context_menu(|ui| {
if !view.selected.contains(&i) {
view.selected = [i].into();
view.current = i;
}
if ui.button(tl!("Extract pages")).clicked() {
view.pending_action = Some(ViewAction::Extract);
ui.close();
}
if ui.add_enabled(editable, egui::Button::new(tl!("Replace pages…"))).clicked() {
view.pending_action = Some(ViewAction::ReplacePages);
ui.close();
}
ui.separator();
if ui.add_enabled(editable, egui::Button::new(tl!("Cut"))).clicked() {
view.pending_action = Some(ViewAction::CopyPages { cut: true });
ui.close();
+1
View File
@@ -204,6 +204,7 @@ impl PdfCraftApp {
match self.views.get_mut(i).and_then(|v| v.pending_action.take()) {
Some(crate::canvas::ViewAction::InsertFromFile) => self.insert_from_file_dialog(),
Some(crate::canvas::ViewAction::InsertFromFileAt(at)) => self.insert_from_file_at(Some(at)),
Some(crate::canvas::ViewAction::ReplacePages) => self.replace_pages_dialog(),
Some(crate::canvas::ViewAction::Save) => {
self.save_active(SaveTarget::InPlace);
}
+4
View File
@@ -1045,6 +1045,10 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie
view.pending_action = Some(crate::canvas::ViewAction::Extract);
ui.close();
}
if ui.add_enabled(editable, egui::Button::new(tl!("Replace pages…"))).clicked() {
view.pending_action = Some(crate::canvas::ViewAction::ReplacePages);
ui.close();
}
ui.separator();
if ui.add_enabled(editable, egui::Button::new(tl!("Cut"))).clicked() {
view.pending_action = Some(crate::canvas::ViewAction::CopyPages { cut: true });
+2 -2
View File
@@ -196,7 +196,7 @@ impl PdfCraftApp {
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config);
let mut edits = Vec::new();
let author = self.comment_prefs.author.clone();
for page in 0..doc.info.pages.len() {
for (page, page_info) in doc.info.pages.iter().enumerate() {
let out = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
let Some(text) = out.text else { continue };
let hits = match d.mode {
@@ -204,7 +204,7 @@ impl PdfCraftApp {
_ => needles.iter().flat_map(|w| text.find(w)).collect(),
};
for h in hits {
let quads: Vec<[f64; 8]> = text.line_rects(h).into_iter().map(|r| doc.info.pages[page].view_rect_to_quad(r)).collect();
let quads: Vec<[f64; 8]> = text.line_rects(h).into_iter().map(|r| page_info.view_rect_to_quad(r)).collect();
if !quads.is_empty() {
edits.push(self.redact_prefs.mark(page, quads, &author));
}
+78
View File
@@ -1324,6 +1324,84 @@ fn extract_button_copies_selected_pages_to_a_new_tab() {
assert!(!app.session.get(app.views[0].id).unwrap().dirty);
}
#[test]
fn replace_button_replaces_selected_pages_in_organize() {
let dir = temp_path("replace-btn");
std::fs::create_dir_all(&dir).unwrap();
let other = dir.join("other.pdf");
std::fs::write(&other, fixture(5)).unwrap();
let mut h = organize(3);
h.get_by_label("Page 2").click();
h.run_steps(1);
h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
h.state_mut().pick_override = Some(vec![other.to_string_lossy().into_owned()]);
h.get_by_label("Replace pages…").click();
h.run_steps(3);
egui_kittest::kittest::Queryable::get_by_label(&h, "Replace Pages");
assert_eq!(h.state().replace_draft.as_ref().unwrap().from, 2);
assert_eq!(h.state().replace_draft.as_ref().unwrap().to, 3);
egui_kittest::kittest::Queryable::get_by_label(&h, "OK").click();
h.run_steps(3);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.info.pages.len(), 3);
assert_eq!(doc.can_undo(), Some("Replace pages"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn replace_pages_from_thumbnail_context_menu() {
let dir = temp_path("replace-ctx");
std::fs::create_dir_all(&dir).unwrap();
let other = dir.join("other.pdf");
std::fs::write(&other, fixture(5)).unwrap();
let mut h = organize(3);
h.state_mut().pick_override = Some(vec![other.to_string_lossy().into_owned()]);
h.get_by_label("Page 2").click_button(egui::PointerButton::Secondary);
h.run_steps(2);
h.get_all_by_label("Replace pages…").last().unwrap().click();
h.run_steps(3);
egui_kittest::kittest::Queryable::get_by_label(&h, "Replace Pages");
assert_eq!(h.state().replace_draft.as_ref().unwrap().from, 2);
assert_eq!(h.state().replace_draft.as_ref().unwrap().to, 2);
egui_kittest::kittest::Queryable::get_by_label(&h, "OK").click();
h.run_steps(3);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.info.pages.len(), 3);
assert_eq!(doc.can_undo(), Some("Replace page"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn replace_pages_from_pages_panel_context_menu() {
let dir = temp_path("replace-panel-ctx");
std::fs::create_dir_all(&dir).unwrap();
let other = dir.join("other.pdf");
std::fs::write(&other, fixture(5)).unwrap();
let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
h.state_mut().pick_override = Some(vec![other.to_string_lossy().into_owned()]);
let thumb = h.get_all_by_label("Page 2").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap();
thumb.click_button(egui::PointerButton::Secondary);
h.run_steps(2);
h.get_all_by_label("Replace pages…").last().unwrap().click();
h.run_steps(3);
egui_kittest::kittest::Queryable::get_by_label(&h, "Replace Pages");
assert_eq!(h.state().replace_draft.as_ref().unwrap().from, 2);
assert_eq!(h.state().replace_draft.as_ref().unwrap().to, 2);
egui_kittest::kittest::Queryable::get_by_label(&h, "OK").click();
h.run_steps(3);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.info.pages.len(), 4);
assert_eq!(doc.can_undo(), Some("Replace page"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn inserting_a_file_goes_after_the_selection_and_undoes() {
let mut h = organize(2);
+1 -1
View File
@@ -2770,7 +2770,7 @@ milestone = "M4"
status = "shipped"
commands = ["page.replace"]
tools = ["page_replace"]
evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/automation/tests/automation.rs::replacing_pages_through_tools"]
evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/automation.rs::replacing_pages_through_tools"]
notes = "content, resources and boxes replaced; links, comments, fields and bookmarks kept"
[[feature]]