mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 18:52:36 +00:00
Merge pull request #6125 from KenM76/feat/bookmark-clipboard
feat(ui): cut, copy and paste bookmark subtrees (#3994)
This commit is contained in:
7 files changed
+203
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user