mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
1 parent
4991a59479
commit
7515170103
11 files changed
+152
-6
No files matched your search
+2
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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 |
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user