mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 04:58:34 -06:00
M4/M1: Drag page thumbnails to reorder (selection or the grabbed page, insertion bar, one undo step); damaged files say they were repaired, and Document Properties ▸ Advanced lists the repairs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9fcece8afb
commit
09c1404d95
@@ -152,6 +152,8 @@ pub struct DocView {
|
||||
pub crop_drag: crate::crop::CropDrag,
|
||||
/// Use a certificate: a signature rectangle being drawn, or an empty signature field clicked.
|
||||
pub sign: crate::sign_ui::SignView,
|
||||
/// Organize: the pages being dragged to a new place.
|
||||
pub org_drag: Option<Vec<usize>>,
|
||||
/// Fill & Sign text being typed.
|
||||
pub fill_text: Option<crate::fill_sign::TypeBox>,
|
||||
/// A non-edit action requested by the organize toolbar, handled by the app.
|
||||
@@ -233,6 +235,7 @@ impl DocView {
|
||||
pending_redaction: None,
|
||||
crop_drag: None,
|
||||
sign: Default::default(),
|
||||
org_drag: None,
|
||||
fill_text: None,
|
||||
}
|
||||
}
|
||||
@@ -829,7 +832,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let view = &mut app.views[index];
|
||||
// Opened without the owner password and something is restricted.
|
||||
let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble())));
|
||||
match notices(view, info, secured, crate::sign_ui::banner(&doc.signatures), ui, &t) {
|
||||
let repaired = !doc.repair_log().is_empty();
|
||||
match notices(view, info, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t) {
|
||||
Some(Notice::Repairs) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Advanced)),
|
||||
Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)),
|
||||
Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures),
|
||||
None => {}
|
||||
@@ -1564,6 +1569,7 @@ fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t:
|
||||
enum Notice {
|
||||
Security,
|
||||
Signatures,
|
||||
Repairs,
|
||||
}
|
||||
|
||||
/// The notice bar above the pages: the signature status first (Acrobat's signature bar), then
|
||||
@@ -1572,6 +1578,7 @@ fn notices(
|
||||
view: &mut DocView,
|
||||
info: &DocInfo,
|
||||
secured: bool,
|
||||
repaired: bool,
|
||||
signed: Option<(&str, Color32, String)>,
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
@@ -1595,10 +1602,13 @@ fn notices(
|
||||
return None;
|
||||
}
|
||||
let mut open_security = false;
|
||||
let mut open_repairs = false;
|
||||
let msg = if secured {
|
||||
Some(("lock", "This document is secured. Some changes are restricted by its security settings.".to_string(), false))
|
||||
} else if !info.fields.is_empty() {
|
||||
Some(("text-cursor-input", format!("This document contains {} interactive form fields.", info.fields.len()), true))
|
||||
} else if repaired {
|
||||
Some(("bandage", "This file was damaged and has been repaired. Saving keeps the repaired version.".to_string(), false))
|
||||
} else if !info.warnings.is_empty() {
|
||||
Some(("triangle-alert", info.warnings[0].clone(), false))
|
||||
} else {
|
||||
@@ -1622,9 +1632,15 @@ fn notices(
|
||||
if secured && crate::widgets::pill_button(ui, "Security settings", false).clicked() {
|
||||
open_security = true;
|
||||
}
|
||||
if repaired && !secured && info.fields.is_empty() && crate::widgets::pill_button(ui, "Details", false).clicked() {
|
||||
open_repairs = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if open_repairs {
|
||||
return Some(Notice::Repairs);
|
||||
}
|
||||
open_security.then_some(Notice::Security)
|
||||
}
|
||||
|
||||
@@ -1870,11 +1886,19 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, ui: &mut
|
||||
|
||||
/// Organize pages: a thumbnail grid (Acrobat's Organize Pages view). Click selects, ⌘-click
|
||||
/// toggles, ⇧-click extends; double-click opens the page.
|
||||
/// The gap (0 = before the first page, n = after the last) the pointer points at in the grid.
|
||||
fn drop_gap(cells: &[(usize, Rect)], p: Pos2) -> Option<usize> {
|
||||
let (i, r) = cells.iter().min_by(|(_, a), (_, b)| a.distance_sq_to_pos(p).total_cmp(&b.distance_sq_to_pos(p)))?;
|
||||
Some(if p.x < r.center().x { *i } else { i + 1 })
|
||||
}
|
||||
|
||||
fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable: bool, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let cell = vec2(190.0, 250.0);
|
||||
let mut open_page = None;
|
||||
organize_toolbar(view, info, editable, ui, t);
|
||||
let mut cells: Vec<(usize, Rect)> = Vec::with_capacity(info.pages.len());
|
||||
let mut drop = false;
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.add_space(20.0);
|
||||
let cols = ((ui.available_width() - 40.0) / cell.x).floor().max(1.0) as usize;
|
||||
@@ -1886,7 +1910,21 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable
|
||||
let i = row * cols + col;
|
||||
let Some(p) = info.pages.get(i) else { break };
|
||||
let c = Rect::from_min_size(pos2(row_rect.left() + left + col as f32 * cell.x, row_rect.top()), cell);
|
||||
let resp = ui.interact(c, ui.id().with(("org", i)), Sense::click());
|
||||
let resp = ui.interact(c, ui.id().with(("org", i)), if editable { Sense::click_and_drag() } else { Sense::click() });
|
||||
cells.push((i, c));
|
||||
// Drag pages to move them (the selection, or the page grabbed).
|
||||
if resp.drag_started() {
|
||||
if !view.selected.contains(&i) {
|
||||
view.selected = [i].into();
|
||||
view.select_anchor = Some(i);
|
||||
}
|
||||
let mut pages: Vec<usize> = view.selected.iter().copied().collect();
|
||||
pages.sort_unstable();
|
||||
view.org_drag = Some(pages);
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
drop = true;
|
||||
}
|
||||
resp.widget_info(|| {
|
||||
egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, view.selected.contains(&i), format!("Page {}", p.label))
|
||||
});
|
||||
@@ -1928,6 +1966,39 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable
|
||||
}
|
||||
}
|
||||
}
|
||||
// While dragging: the gap the pages would go to, drawn as a bar.
|
||||
if let (Some(pages), Some(p)) = (view.org_drag.clone(), ui.input(|i| i.pointer.hover_pos())) {
|
||||
if let Some(gap) = drop_gap(&cells, p) {
|
||||
let x = match cells.iter().find(|(i, _)| *i == gap) {
|
||||
Some((_, r)) => r.left() + 3.0,
|
||||
None => cells.last().map_or(0.0, |(_, r)| r.right() - 3.0),
|
||||
};
|
||||
let row = cells.iter().find(|(i, _)| *i == gap).or(cells.last()).map(|(_, r)| r.y_range()).unwrap_or(egui::Rangef::new(0.0, 0.0));
|
||||
ui.painter().line_segment([pos2(x, row.min + 10.0), pos2(x, row.max - 10.0)], Stroke::new(3.0, t.accent));
|
||||
ui.painter().text(
|
||||
p + vec2(14.0, 14.0),
|
||||
Align2::LEFT_TOP,
|
||||
format!("{} page{}", pages.len(), if pages.len() == 1 { "" } else { "s" }),
|
||||
theme::medium(12.0),
|
||||
t.accent_text,
|
||||
);
|
||||
}
|
||||
if drop {
|
||||
view.org_drag = None;
|
||||
if let Some(gap) = drop_gap(&cells, p) {
|
||||
// `to` counts positions without the moving pages.
|
||||
let to = gap - pages.iter().filter(|x| **x < gap).count();
|
||||
let first = pages[0];
|
||||
let contiguous = pages.windows(2).all(|w| w[1] == w[0] + 1);
|
||||
if !(contiguous && to == first) {
|
||||
view.pending_edit = Some(Edit::MovePages { pages: pages.clone(), to });
|
||||
view.selected = (to..to + pages.len()).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if drop {
|
||||
view.org_drag = None;
|
||||
}
|
||||
});
|
||||
let s = THUMB_W * ppp / info.pages.iter().map(|p| p.width).fold(1.0, f32::max);
|
||||
let queue: Vec<RenderRequest> = (0..info.pages.len())
|
||||
|
||||
@@ -202,6 +202,18 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
||||
row(ui, "Layers", i.layers.len().to_string());
|
||||
row(ui, "Attachments", i.attachments.len().to_string());
|
||||
row(ui, "JavaScript", yes(i.has_javascript));
|
||||
// What was repaired while reading a damaged file (fidelity: never silent).
|
||||
let repairs = doc.repair_log();
|
||||
row(ui, "Repairs", if repairs.is_empty() { "None".to_string() } else { repairs.len().to_string() });
|
||||
if !repairs.is_empty() {
|
||||
ui.label("");
|
||||
egui::CollapsingHeader::new("Repair log").show(ui, |ui| {
|
||||
for r in &repairs {
|
||||
ui.add(egui::Label::new(egui::RichText::new(r).small()).wrap());
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -517,3 +517,27 @@ fn extract_options_and_rotate_pages_dialog() {
|
||||
let d = s.session.get(s.views[0].id).unwrap();
|
||||
assert_eq!(d.info.pages.iter().map(|p| p.rotation).collect::<Vec<_>>(), [90, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_thumbnails_reorders_pages() {
|
||||
let mut h = organize(4);
|
||||
let before = page_texts(h.state());
|
||||
let grab = |h: &Harness<'static, PrintCraftApp>, label: &str| h.get_by_label(label).rect();
|
||||
let (from, to) = (grab(&h, "Page 1").center(), grab(&h, "Page 3").right_center() - egui::vec2(10.0, 0.0));
|
||||
h.hover_at(from);
|
||||
h.run_steps(1);
|
||||
h.drag_at(from);
|
||||
h.run_steps(1);
|
||||
for k in 1..=5 {
|
||||
h.hover_at(from + (to - from) * (k as f32 / 5.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(to);
|
||||
h.run_steps(4);
|
||||
// Page 1 now sits after page 3.
|
||||
let after = page_texts(h.state());
|
||||
assert_eq!(after, vec![before[1].clone(), before[2].clone(), before[0].clone(), before[3].clone()], "{after:?}");
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move page"));
|
||||
let sel: Vec<usize> = h.state().views[0].selected.iter().copied().collect();
|
||||
assert_eq!(sel, vec![2], "the moved page stays selected");
|
||||
}
|
||||
|
||||
@@ -171,3 +171,26 @@ fn layouts_fit_height_labels_and_system_theme() {
|
||||
h.state_mut().set_option("theme", "system").unwrap();
|
||||
assert!(h.state().follow_system_theme);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_files_say_they_were_repaired() {
|
||||
// No cross-reference table: the file is reconstructed.
|
||||
let damaged = 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 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF"
|
||||
.to_vec();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| {
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.open_bytes("damaged.pdf", None, damaged.clone()).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h.get_by_label_contains("This file was damaged and has been repaired.");
|
||||
h.get_by_label("Details").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, Some(Dialog::Properties(printcraft_ui_egui::PropsTab::Advanced)));
|
||||
h.get_by_label("Repair log");
|
||||
}
|
||||
|
||||
@@ -55,11 +55,11 @@ title = "Record repairs and show them to the user"
|
||||
area = "A"
|
||||
tier = "P0"
|
||||
milestone = "M1"
|
||||
status = "partial"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = ["doc_info"]
|
||||
evidence = []
|
||||
notes = "doc_info reports repair notes; no repair log in the UI yet"
|
||||
evidence = ["crates/ui-egui/tests/viewer.rs::damaged_files_say_they_were_repaired"]
|
||||
notes = "a notice says the file was repaired; Document Properties ▸ Advanced lists the repairs; doc_info reports them"
|
||||
|
||||
[[feature]]
|
||||
id = "core.xref-streams"
|
||||
@@ -2587,11 +2587,11 @@ title = "Reorder pages by drag and drop"
|
||||
area = "D"
|
||||
tier = "P0"
|
||||
milestone = "M4"
|
||||
status = "partial"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = "move-earlier/later buttons exist; drag in the grid is M4.7"
|
||||
evidence = ["crates/ui-egui/tests/editing.rs::dragging_thumbnails_reorders_pages"]
|
||||
notes = "drag thumbnails (or the selection) in the page grid; an insertion bar shows where they go; move earlier/later buttons too"
|
||||
|
||||
[[feature]]
|
||||
id = "organize.insert-blank"
|
||||
@@ -3446,8 +3446,8 @@ milestone = "M5"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/engine/src/tests.rs::highlight_multiplies_over_text"]
|
||||
notes = "appearances for every type PrintCraft creates (highlight multiplies); clouds, callouts, stamps and rich text are not drawn yet"
|
||||
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/engine/src/tests.rs::highlight_multiplies_over_text", "crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn"]
|
||||
notes = "appearances for every type PrintCraft creates (highlight multiplies; clouds, callouts, stamps, polygons, carets); rich text not yet"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.sticky-note"
|
||||
|
||||
Reference in New Issue
Block a user