M4: cut, copy and paste bookmark subtrees (#3994)

The Bookmarks panel's item menu gets Cut, Copy and Paste, and Bookmark
options gets Paste bookmarks (to the end of the outline, which is how you
paste into a document without one). The clipboard lives in the app
(`PdfCraftApp::bookmark_clipboard`), like the Organize Pages clipboard, so
it works across tabs and on the web build.

- Copy reads the subtree with `outline_tree`; a copy cut at the listing
  depth says so.
- Cut copies first and deletes only if the copy succeeded: one undo step.
- Paste is one `ImportBookmarks` edit after the bookmark it was asked on,
  at its level. A bookmark whose page the document lacks keeps its place
  without a destination, and a notice counts them (plural forms through
  `trn`).

Parity: new `organize.bookmark-clipboard` entry with what is missing
(shortcuts, system clipboard, multi-select, page-ordered insertion).

Test: cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents.
Reverting copy, cut, paste, the paste's parent, the one-step undo or the
notice each fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
KenM76andClaude Opus 5.5 committed 2026-11-04 05:04:49 -05:00
1 parent 5bbcb7a218
commit c16c6b5716
7 files changed
+162

No files matched your search

+1
View File
@@ -260,6 +260,7 @@ impl PdfCraftApp {
fonts_cjk: theme::CjkPreference::Default,
control: None,
bookmark_rename: None,
bookmark_clipboard: None,
last_opened_url: None,
community_card_closed: false,
pending_link: None,
+52
View File
@@ -977,6 +977,20 @@ impl PdfCraftApp {
let at = parent[parent.len() - 1] + 1;
Edit::MoveBookmark { from: path, to_parent: grand, index: at }
}
A::Copy(path) => {
self.copy_bookmark(id, &path);
return;
}
A::Cut(path) => {
if !self.copy_bookmark(id, &path) {
return;
}
Edit::DeleteBookmark { path }
}
A::Paste { parent, index } => {
self.paste_bookmarks(id, parent, index);
return;
}
A::FromStructure => {
self.right = Some(crate::RightPanel::Bookmarks);
Edit::BookmarksFromStructure
@@ -991,6 +1005,44 @@ impl PdfCraftApp {
}
}
impl PdfCraftApp {
/// Put the bookmark at `path` and its subtree on the bookmark clipboard; `false` if it could
/// not be read (the reason is shown).
fn copy_bookmark(&mut self, id: pdfcraft_engine::DocId, path: &[usize]) -> bool {
match self.session.outline_tree(id, path) {
Some(Ok(tree)) => {
if tree.truncated {
self.notify_tr("Bookmarks nested too deep to list were left out of the copy.");
}
self.bookmark_clipboard = Some(tree.nodes);
true
}
Some(Err(e)) => {
self.notify_error(e);
false
}
None => false,
}
}
/// Paste the bookmark clipboard as one undoable edit. Bookmarks whose page this document lacks
/// arrive without a destination, and the toast says how many.
fn paste_bookmarks(&mut self, id: pdfcraft_engine::DocId, parent: Vec<usize>, index: usize) {
let Some(bookmarks) = self.bookmark_clipboard.clone() else { return };
let pages = self.session.get(id).map_or(0, |d| d.info.pages.len());
let unmapped = pdfcraft_engine::outline_unmapped(&bookmarks, pages).len();
if self.apply_edit(Edit::ImportBookmarks { parent, index, bookmarks, replace: false }) && unmapped > 0 {
let n = u64::try_from(unmapped).unwrap_or(u64::MAX);
self.notify(crate::i18n::trn(
crate::i18n::current(),
n,
"{n} pasted bookmark points past the last page of this document and has no destination.",
"{n} pasted bookmarks point past the last page of this document and have no destination.",
));
}
}
}
fn bookmark_at<'a>(items: &'a [pdfcraft_render::OutlineItem], path: &[usize]) -> Option<&'a pdfcraft_render::OutlineItem> {
let (first, rest) = path.split_first()?;
let item = items.get(*first)?;
+3
View File
@@ -611,6 +611,9 @@ pub struct PdfCraftApp {
control: Option<control::Control>,
/// A bookmark being renamed in the Bookmarks panel: (path, text so far).
pub bookmark_rename: Option<(Vec<usize>, String)>,
/// Bookmarks copied or cut in the Bookmarks panel, with their subtrees, ready to paste into
/// any document. Destinations are page indices.
pub bookmark_clipboard: Option<Vec<pdfcraft_engine::OutlineNode>>,
/// Number pages dialog settings (1-based pages).
pub number_draft: NumberDraft,
pub(crate) word_count_draft: word_count_ui::Draft,
+16
View File
@@ -19,6 +19,15 @@ pub enum BmAction {
Indent(Vec<usize>),
/// Move it out to follow its parent.
Outdent(Vec<usize>),
/// Copy the bookmark and everything under it to the bookmark clipboard.
Copy(Vec<usize>),
/// Copy it, then delete it (one undo step: only the delete changes the document).
Cut(Vec<usize>),
/// Paste the bookmark clipboard under `parent` at `index` (past the end appends).
Paste {
parent: Vec<usize>,
index: usize,
},
/// Bookmarks from the tagged headings, under a new first "Untitled" bookmark.
FromStructure,
/// Open New bookmarks from text styles, to choose a style per bookmark level (#1693).
@@ -55,6 +64,8 @@ pub(super) struct OutlineCtx<'a> {
pub(super) action: &'a mut Option<BmAction>,
pub(super) rename: &'a mut Option<(Vec<usize>, String)>,
pub(super) editable: bool,
/// The bookmark clipboard holds something to paste.
pub(super) can_paste: bool,
pub(super) current: usize,
/// Expand all (`Some(usize::MAX)`), collapse all (`Some(0)`) or expand to a depth, this frame.
pub(super) expand: Option<usize>,
@@ -242,6 +253,11 @@ pub(super) fn outline_item(
pick(ui, tl!("Indent"), i > 0, BmAction::Indent(path.to_vec()));
pick(ui, tl!("Outdent"), depth > 0, BmAction::Outdent(path.to_vec()));
ui.separator();
pick(ui, tl!("Cut"), true, BmAction::Cut(path.to_vec()));
pick(ui, tl!("Copy"), true, BmAction::Copy(path.to_vec()));
let parent = path.get(..depth).unwrap_or_default().to_vec();
pick(ui, tl!("Paste"), cx.can_paste, BmAction::Paste { parent, index: i.saturating_add(1) });
ui.separator();
pick(ui, tl!("Delete"), true, BmAction::Delete(path.to_vec()));
if let Some(a) = styled {
*cx.action = Some(a);
+6
View File
@@ -24,6 +24,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let mut compare_action: Option<crate::compare_ui::PanelAction> = None;
let mut bm_rename = app.bookmark_rename.clone();
let bm_editable = app.session.get(id).is_some_and(|d| d.allows_assembly() && d.read_only_reason.is_none());
let bm_can_paste = app.bookmark_clipboard.is_some();
// Prepare a form is open: the Fields panel orders tabs.
let preparing = app.is_preparing();
{
@@ -111,6 +112,10 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
bm_action = Some(BmAction::FromTextStyles);
ui.close();
}
if bm_editable && ui.add_enabled(bm_can_paste, egui::Button::new(tl!("Paste bookmarks"))).clicked() {
bm_action = Some(BmAction::Paste { parent: Vec::new(), index: usize::MAX });
ui.close();
}
if !info.outline.is_empty() {
for (levels, label) in [
(usize::MAX, tl!("Expand all bookmarks")),
@@ -195,6 +200,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
action: &mut bm_action,
rename: &mut bm_rename,
editable: bm_editable,
can_paste: bm_can_paste,
current: view.current,
expand: bm_expand,
matches: &bookmark_matches,
+72
View File
@@ -459,3 +459,75 @@ trailer << /Root 1 0 R >>
h.run_steps(3);
assert!(!shown(&h, "Closed child B") && !shown(&h, "Open child B"), "search leaves the open state alone");
}
/// The active document's outline, flattened like [`outline`].
fn active_outline(h: &Harness<'static, PdfCraftApp>) -> Vec<(String, Option<usize>, usize)> {
let i = h.state().active.unwrap();
let id = h.state().views[i].id;
fn flat(items: &[pdfcraft_render::OutlineItem], depth: usize, out: &mut Vec<(String, Option<usize>, usize)>) {
for i in items {
out.push((i.title.clone(), i.page, depth));
flat(&i.children, depth + 1, out);
}
}
let mut out = Vec::new();
flat(&h.state().session.get(id).unwrap().info.outline, 0, &mut out);
out
}
const ONE_PAGE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents() {
let mut h = harness();
add(&mut h, "Intro");
h.state_mut().set_option("page", "3").unwrap();
h.run_steps(3);
add(&mut h, "Results");
menu(&mut h, "Results", "Indent");
let tree = [("Intro".to_string(), Some(0), 0), ("Results".to_string(), Some(2), 1)];
assert_eq!(outline(&h), tree);
// Copy and paste after itself: the whole subtree, as one undo step.
menu(&mut h, "Intro", "Copy");
menu(&mut h, "Intro", "Paste");
assert_eq!(outline(&h), [tree.clone(), tree.clone()].concat());
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(outline(&h), tree, "a paste is one undo step");
// Pasted on a child, it goes in after that child, at the child's level.
menu(&mut h, "Results", "Paste");
let nested = [("Intro".to_string(), Some(0), 1), ("Results".to_string(), Some(2), 2)];
assert_eq!(outline(&h), [tree.clone(), nested].concat());
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(outline(&h), tree);
// Cut takes the subtree away in one step and keeps it to paste.
menu(&mut h, "Intro", "Cut");
assert!(outline(&h).is_empty());
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(outline(&h), tree, "a cut is one undo step");
// Into a one-page document: Results' page 3 is not there, so it keeps its place without a
// destination rather than pointing at the wrong page, and the toast says so.
h.state_mut().open_bytes("one.pdf", None, ONE_PAGE.to_vec()).unwrap();
h.run_steps(3);
h.get_by_label("Bookmark options").click();
h.run_steps(2);
h.get_by_label("Paste bookmarks").click();
h.run_steps(3);
assert_eq!(active_outline(&h), [("Intro".to_string(), Some(0), 0), ("Results".to_string(), None, 1)]);
let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
assert!(toast.contains("1 pasted bookmark points past the last page"), "{toast}");
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert!(active_outline(&h).is_empty());
assert_eq!(outline(&h), tree, "the source is untouched");
}
+12
View File
@@ -3433,6 +3433,18 @@ tools = ["bookmark_export", "bookmark_import", "bookmark_copy"]
evidence = ["crates/organize/src/tests.rs::an_exported_outline_imports_into_another_document_unchanged", "crates/organize/src/tests.rs::a_page_the_target_lacks_keeps_its_bookmark_without_a_destination", "crates/organize/src/tests.rs::an_import_appends_inserts_or_replaces", "crates/organize/src/tests.rs::a_bad_node_anywhere_refuses_the_whole_import", "crates/automation/tests/it/automation.rs::bookmarks_export_import_and_copy_between_documents", "crates/automation/tests/it/automation.rs::a_bad_bookmark_tree_is_refused_whole_and_names_the_node"]
notes = "#6018, headless only. The bookmark tree (or one bookmark's subtree) goes out as JSON with titles, nesting, open state, colour, bold/italic and destinations as page plus view in PDF user space, and comes back unchanged: into another document, under any bookmark, at any position, or in place of its outline, as one undoable step; bookmark_copy does the same between open documents by page number. A bookmark whose page the target lacks keeps its place without a destination and is reported, never dropped. Not yet: a panel command and preview, CSV/Markdown/pdftk formats, remapping by named destination (named destinations travel resolved to their page), actions other than go-to-page, structure links (/SE)"
[[feature]]
id = "organize.bookmark-clipboard"
title = "Cut, copy and paste bookmarks"
area = "D"
tier = "P2"
milestone = "M4"
status = "shipped"
commands = []
tools = ["bookmark_copy", "bookmark_export", "bookmark_import", "bookmark_delete"]
evidence = ["crates/ui-egui/tests/it/bookmarks.rs::cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents", "crates/automation/tests/it/automation.rs::bookmarks_export_import_and_copy_between_documents"]
notes = "#3994 (the cut/copy/paste part). Cut, Copy and Paste in a bookmark's menu, and Paste bookmarks in Bookmark options, carry a bookmark with its whole subtree within a document or into another one, each as one undo step. A destination is a page index: a bookmark whose page the target lacks keeps its place without a destination and the toast counts them. Headless, bookmark_copy copies between open documents directly (there is no clipboard). Not yet: keyboard shortcuts in the panel, the system clipboard (the bookmarks stay inside the app), several bookmarks at once, copying out of a document that cannot be edited (the bookmark menu is hidden there), page-ordered insertion (the other half of #3994), actions other than go-to-page"
[[feature]]
id = "organize.attachments-add"
title = "Add file attachments"