Merge pull request #6125 from KenM76/feat/bookmark-clipboard

feat(ui): cut, copy and paste bookmark subtrees (#3994)
This commit is contained in:
FredApps authored and GitHub committed 2026-11-04 19:33:28 +01:00
commit 60894af44d
7 files changed
+203

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,
+56
View File
@@ -977,6 +977,23 @@ 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) => match self.copy_bookmark(id, &path) {
Some(false) => Edit::DeleteBookmark { path },
// Paste could not bring back what the copy left out, so the bookmarks stay.
Some(true) => {
self.notify_tr("Bookmarks nested too deep to list could not be copied, so nothing was cut.");
return;
}
None => return,
},
A::Paste { parent, index } => {
self.paste_bookmarks(id, parent, index);
return;
}
A::FromStructure => {
self.right = Some(crate::RightPanel::Bookmarks);
Edit::BookmarksFromStructure
@@ -991,6 +1008,45 @@ impl PdfCraftApp {
}
}
impl PdfCraftApp {
/// Put the bookmark at `path` and its subtree on the bookmark clipboard. `Some(true)` when
/// bookmarks nested too deep to list were left out; `None` if it could not be read (the reason
/// is shown).
fn copy_bookmark(&mut self, id: pdfcraft_engine::DocId, path: &[usize]) -> Option<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);
Some(tree.truncated)
}
Some(Err(e)) => {
self.notify_error(e);
None
}
None => None,
}
}
/// 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
@@ -612,6 +612,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,
+109
View File
@@ -459,3 +459,112 @@ 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");
}
/// A one-page document whose single bookmark "Top" heads a chain `depth` bookmarks deep.
fn deep_outline_pdf(depth: usize) -> Vec<u8> {
let mut pdf = String::from(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 4 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
4 0 obj << /Type /Outlines /First 10 0 R /Last 10 0 R /Count 1 >> endobj
",
);
for k in 0..depth {
let id = 10 + k;
let parent = if k == 0 { 4 } else { id - 1 };
let title = if k == 0 { "Top".to_string() } else { format!("L{k}") };
let kids = if k + 1 < depth { format!("/First {0} 0 R /Last {0} 0 R /Count 1", id + 1) } else { String::new() };
pdf.push_str(&format!("{id} 0 obj << /Title ({title}) /Parent {parent} 0 R {kids} /Dest [3 0 R /Fit] >> endobj\n"));
}
pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF");
pdf.into_bytes()
}
/// Cutting a subtree nested deeper than a copy can hold would lose what the copy left out, so
/// the cut copies and says why it deleted nothing.
#[test]
fn cutting_a_subtree_too_deep_to_copy_keeps_it() {
let mut h = harness();
h.state_mut().open_bytes("deep.pdf", None, deep_outline_pdf(40)).unwrap();
h.run_steps(3);
let before = active_outline(&h);
assert_eq!(before.first().map(|b| b.0.as_str()), Some("Top"));
menu(&mut h, "Top", "Cut");
assert_eq!(active_outline(&h), before, "nothing was cut");
let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
assert!(toast.contains("so nothing was cut"), "{toast}");
assert!(h.state().bookmark_clipboard.is_some(), "the copy is still on the bookmark clipboard");
}
+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"