M4: Cut, copy and paste pages in Organize Pages (⌘C/⌘X/⌘V and a thumbnail context menu; paste after the selection, across documents; the last page can't be cut)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 03:57:11 -04:00
1 parent 4991a59479
commit 7515170103
11 files changed
+152 -6

No files matched your search

+2 -1
View File
@@ -4,7 +4,7 @@
Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
## In this repository (222)
## In this repository (223)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -230,6 +230,7 @@ Every asset PrintCraft includes, bundles or uses to build its published material
| `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PrintCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): a signature PrintCraft did not make |
| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
## Compiled in through dependencies (6)
+11
View File
@@ -2783,3 +2783,14 @@ licence_file = "LICENSE-MIT"
kind = "document"
usage = "Signature validation tests (crates/sign/tests/pdf.rs): a signature PrintCraft did not make"
sha256 = "8b91c3b4e39c4a31717a0dd0685bd7d0468671df32aee5b9818d44dc72c2bc3c"
[[asset]]
path = "assets/icons/clipboard-paste.svg"
title = "Lucide icon \"clipboard-paste\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg)"
licence = "ISC"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "631d24bde604be36b2dcf3b6f86ddf6026879cdcd272c0dfe37ab0cc6b341fda"
+19
View File
@@ -0,0 +1,19 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-clipboard-paste"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11 14h10" />
<path d="M16 4h2a2 2 0 0 1 2 2v1.344" />
<path d="m17 18 4-4-4-4" />
<path d="M8 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 1.793-1.113" />
<rect x="8" y="2" width="8" height="4" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 521 B

+3
View File
@@ -147,6 +147,9 @@ const HELP: Option<&str> = Some("Help");
pub const COMMANDS: &[CommandSpec] = &[
c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"),
c("create.blank", "New blank PDF", FILE, None, Nothing, "file-plus-2"),
c("page.copy", "Copy pages", None, None, Document, "copy"),
c("page.cut", "Cut pages", None, None, Assembly, "scissors"),
c("page.paste", "Paste pages", None, None, Assembly, "clipboard-paste"),
c("create.file", "Create PDF from file…", FILE, None, Nothing, "file-input"),
c("create.images", "Create PDF from images…", FILE, None, Nothing, "image"),
c("create.clipboard", "Create PDF from clipboard", FILE, None, Nothing, "copy-plus"),
+36
View File
@@ -166,6 +166,12 @@ pub enum ViewAction {
InsertFromFile,
Extract,
Split,
/// Copy the selected pages (Cut also deletes them).
CopyPages {
cut: bool,
},
/// Paste copied pages after the selection.
PastePages,
}
impl DocView {
@@ -1875,6 +1881,17 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, ui: &mut
i.consume_key(Modifiers::NONE, Key::Escape),
)
});
// ⌘C / ⌘X / ⌘V copy, cut and paste pages (egui delivers them as clipboard events).
let (copy, cut, paste) = ui.input(|i| {
let any = |f: &dyn Fn(&egui::Event) -> bool| i.events.iter().any(f);
(any(&|e| matches!(e, egui::Event::Copy)), any(&|e| matches!(e, egui::Event::Cut)), any(&|e| matches!(e, egui::Event::Paste(_))))
});
if copy || cut {
view.pending_action = Some(ViewAction::CopyPages { cut });
}
if paste {
view.pending_action = Some(ViewAction::PastePages);
}
if del && targets.len() < n {
view.pending_edit = Some(Edit::DeletePages { pages: targets });
}
@@ -1967,6 +1984,25 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable
if resp.double_clicked() {
open_page = Some(i);
}
// Right-click: 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.add_enabled(editable, egui::Button::new("Cut")).clicked() {
view.pending_action = Some(ViewAction::CopyPages { cut: true });
ui.close();
}
if ui.button("Copy").clicked() {
view.pending_action = Some(ViewAction::CopyPages { cut: false });
ui.close();
}
if ui.add_enabled(editable, egui::Button::new("Paste after")).clicked() {
view.pending_action = Some(ViewAction::PastePages);
ui.close();
}
});
}
}
// While dragging: the gap the pages would go to, drawn as a bar.
+3
View File
@@ -308,6 +308,9 @@ impl PrintCraftApp {
}
"sign.panel" => self.right = Some(RightPanel::Signatures),
"optimize.advanced" => self.dialog = Some(Dialog::Optimize),
"page.copy" => self.copy_pages(false),
"page.cut" => self.copy_pages(true),
"page.paste" => self.paste_pages(),
"comment.hide_all" => {
if let Some(i) = active {
let id = self.views[i].id;
+35
View File
@@ -101,10 +101,45 @@ impl PrintCraftApp {
Some(crate::canvas::ViewAction::InsertFromFile) => self.insert_from_file_dialog(),
Some(crate::canvas::ViewAction::Extract) => self.dialog = Some(crate::Dialog::Extract),
Some(crate::canvas::ViewAction::Split) => self.dialog = Some(crate::Dialog::Split),
Some(crate::canvas::ViewAction::CopyPages { cut }) => self.copy_pages(cut),
Some(crate::canvas::ViewAction::PastePages) => self.paste_pages(),
None => {}
}
}
/// Organize ▸ Copy / Cut: remember the selected pages (the document as it is now); Cut also
/// deletes them (one page always stays).
pub fn copy_pages(&mut self, cut: bool) {
let Some((i, id)) = self.active_ids() else { return };
let Some(doc) = self.session.get(id) else { return };
let pages = self.views[i].target_pages();
let n = doc.info.pages.len();
if cut && pages.len() >= n {
self.notify("A document needs at least one page: copy instead");
return;
}
self.page_clipboard = Some(crate::PageClip { name: doc.name.clone(), bytes: doc.bytes.clone(), pages: pages.clone() });
if cut {
self.apply_edit(Edit::DeletePages { pages: pages.clone() });
}
let what = if pages.len() == 1 { "1 page".to_string() } else { format!("{} pages", pages.len()) };
self.notify(format!("{} {what}", if cut { "Cut" } else { "Copied" }));
}
/// Organize ▸ Paste: insert the copied pages after the selection (or the current page).
pub fn paste_pages(&mut self) {
let Some(clip) = self.page_clipboard.clone() else {
self.notify("Copy or cut pages first");
return;
};
let Some(i) = self.active else { return };
let at = self.views[i].target_pages().into_iter().max().map_or(0, |p| p + 1);
let count = clip.pages.len();
if self.apply_edit(Edit::InsertPagesFrom { name: clip.name.clone(), bytes: clip.bytes.clone(), pages: Some(clip.pages.clone()), at }) {
self.views[i].select_pages(&(at..at + count).collect::<Vec<_>>());
}
}
/// Save the active document. Returns `true` if it was written.
pub fn save_active(&mut self, target: SaveTarget) -> bool {
match self.active {
+1
View File
@@ -36,6 +36,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("circle-dot", include_bytes!("../../../assets/icons/circle-dot.svg")),
("circle-help", include_bytes!("../../../assets/icons/circle-help.svg")),
("circle-x", include_bytes!("../../../assets/icons/circle-x.svg")),
("clipboard-paste", include_bytes!("../../../assets/icons/clipboard-paste.svg")),
("clock", include_bytes!("../../../assets/icons/clock.svg")),
("clock-3", include_bytes!("../../../assets/icons/clock-3.svg")),
("cloud", include_bytes!("../../../assets/icons/cloud.svg")),
+11
View File
@@ -173,6 +173,14 @@ pub enum PropsTab {
Advanced,
}
/// Copied pages: their document's name and bytes (as it was when copied) and the pages.
#[derive(Clone, Debug)]
pub struct PageClip {
pub name: String,
pub bytes: std::sync::Arc<Vec<u8>>,
pub pages: Vec<usize>,
}
/// Files delivered asynchronously: (name, bytes).
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
@@ -245,6 +253,8 @@ pub struct PrintCraftApp {
pub sig_expanded: Vec<String>,
/// PDF Optimizer choices.
pub optimize_draft: OptimizeDraft,
/// Pages copied or cut in Organize Pages, ready to paste (into any document).
pub page_clipboard: Option<PageClip>,
/// Where autosaves go (`None`: autosave off, e.g. on the web and in tests).
pub recovery: Option<RecoveryStore>,
/// Entries left by a previous session, offered in the Recovery dialog.
@@ -349,6 +359,7 @@ impl PrintCraftApp {
digital_ids: Vec::new(),
sig_expanded: Vec::new(),
optimize_draft: OptimizeDraft::default(),
page_clipboard: None,
recovery: None,
recoverable: Vec::new(),
recovery_keys: Default::default(),
+26
View File
@@ -541,3 +541,29 @@ fn dragging_thumbnails_reorders_pages() {
let sel: Vec<usize> = h.state().views[0].selected.iter().copied().collect();
assert_eq!(sel, vec![2], "the moved page stays selected");
}
#[test]
fn copying_cutting_and_pasting_pages() {
let mut h = organize(4);
let before = page_texts(h.state());
h.state_mut().views[0].select_pages(&[0]);
assert!(h.state_mut().execute("page.copy"));
h.state_mut().views[0].select_pages(&[2]);
assert!(h.state_mut().execute("page.paste"));
h.run_steps(2);
let after = page_texts(h.state());
assert_eq!(after, vec![before[0].clone(), before[1].clone(), before[2].clone(), before[0].clone(), before[3].clone()], "pasted after page 3");
let sel: Vec<usize> = h.state().views[0].selected.iter().copied().collect();
assert_eq!(sel, vec![3], "the pasted page is selected");
// Cut page 2 and paste it at the end.
h.state_mut().views[0].select_pages(&[1]);
assert!(h.state_mut().execute("page.cut"));
h.state_mut().views[0].select_pages(&[3]);
assert!(h.state_mut().execute("page.paste"));
let after = page_texts(h.state());
assert_eq!(after, vec![before[0].clone(), before[2].clone(), before[0].clone(), before[3].clone(), before[1].clone()]);
// Every page can be copied but not cut.
h.state_mut().views[0].select_pages(&[0, 1, 2, 3, 4]);
h.state_mut().execute("page.cut");
assert_eq!(page_texts(h.state()).len(), 5);
}
+5 -5
View File
@@ -2743,11 +2743,11 @@ title = "Cut, copy and paste pages"
area = "D"
tier = "P1"
milestone = "M4"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["page.copy", "page.cut", "page.paste"]
tools = ["page_insert_file"]
evidence = ["crates/ui-egui/tests/editing.rs::copying_cutting_and_pasting_pages"]
notes = "⌘C/⌘X/⌘V and the thumbnail menu in Organize Pages; paste works across documents"
[[feature]]
id = "organize.drag-pages-between-documents"