diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 6ac8ea8783..43a1e5ce1d 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -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, diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 4440637a81..879025b01d 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -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 { + 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, 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)?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 95ba4283b5..c0acc17a30 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -612,6 +612,9 @@ pub struct PdfCraftApp { control: Option, /// A bookmark being renamed in the Bookmarks panel: (path, text so far). pub bookmark_rename: Option<(Vec, 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>, /// Number pages dialog settings (1-based pages). pub number_draft: NumberDraft, pub(crate) word_count_draft: word_count_ui::Draft, diff --git a/crates/ui-egui/src/panels/bookmarks.rs b/crates/ui-egui/src/panels/bookmarks.rs index ccc45d4eb0..2b68b93158 100644 --- a/crates/ui-egui/src/panels/bookmarks.rs +++ b/crates/ui-egui/src/panels/bookmarks.rs @@ -19,6 +19,15 @@ pub enum BmAction { Indent(Vec), /// Move it out to follow its parent. Outdent(Vec), + /// Copy the bookmark and everything under it to the bookmark clipboard. + Copy(Vec), + /// Copy it, then delete it (one undo step: only the delete changes the document). + Cut(Vec), + /// Paste the bookmark clipboard under `parent` at `index` (past the end appends). + Paste { + parent: Vec, + 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, pub(super) rename: &'a mut Option<(Vec, 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, @@ -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); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 79769867d3..ae9fbb9266 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -24,6 +24,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut compare_action: Option = 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, diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index 1f07102a18..668227e8e7 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -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)> { + 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)>) { + 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 { + 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"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 97dc5b0802..7011cd5644 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"