From 7515170103455044caeb25939dea3f1f0cbc3ae9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 03:57:11 -0400 Subject: [PATCH] =?UTF-8?q?M4:=20Cut,=20copy=20and=20paste=20pages=20in=20?= =?UTF-8?q?Organize=20Pages=20(=E2=8C=98C/=E2=8C=98X/=E2=8C=98V=20and=20a?= =?UTF-8?q?=20thumbnail=20context=20menu;=20paste=20after=20the=20selectio?= =?UTF-8?q?n,=20across=20documents;=20the=20last=20page=20can't=20be=20cut?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 3 ++- ATTRIBUTION.toml | 11 ++++++++++ assets/icons/clipboard-paste.svg | 19 +++++++++++++++++ crates/engine/src/commands.rs | 3 +++ crates/ui-egui/src/canvas.rs | 36 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/commands.rs | 3 +++ crates/ui-egui/src/editing.rs | 35 +++++++++++++++++++++++++++++++ crates/ui-egui/src/icon_data.rs | 1 + crates/ui-egui/src/lib.rs | 11 ++++++++++ crates/ui-egui/tests/editing.rs | 26 +++++++++++++++++++++++ parity/acrobat-features.toml | 10 ++++----- 11 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 assets/icons/clipboard-paste.svg diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 9b1a58c5..2159c8b7 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index eaf613c5..eda26046 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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" diff --git a/assets/icons/clipboard-paste.svg b/assets/icons/clipboard-paste.svg new file mode 100644 index 00000000..350c2ca5 --- /dev/null +++ b/assets/icons/clipboard-paste.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 1a8cb6a4..8c671f8a 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -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"), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ae6bdaa8..8248415d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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. diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index d5dd7c04..e348ddd8 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -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; diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 6512ac7d..ccdfca5c 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -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::>()); + } + } + /// Save the active document. Returns `true` if it was written. pub fn save_active(&mut self, target: SaveTarget) -> bool { match self.active { diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index baa3b360..39694d89 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -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")), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 183200b3..2a8e1792 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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>, + pub pages: Vec, +} + /// Files delivered asynchronously: (name, bytes). pub type Inbox = std::sync::Arc)>>>; @@ -245,6 +253,8 @@ pub struct PrintCraftApp { pub sig_expanded: Vec, /// PDF Optimizer choices. pub optimize_draft: OptimizeDraft, + /// Pages copied or cut in Organize Pages, ready to paste (into any document). + pub page_clipboard: Option, /// Where autosaves go (`None`: autosave off, e.g. on the web and in tests). pub recovery: Option, /// 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(), diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 026f79d1..490606e3 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -541,3 +541,29 @@ fn dragging_thumbnails_reorders_pages() { let sel: Vec = 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 = 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); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5aadc451..a148170f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"