mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-01 22:19:10 -06:00
1018 lines
48 KiB
Rust
1018 lines
48 KiB
Rust
//! Editing glue: apply engine edits from the UI, undo/redo, save/save-as, and the
|
|
//! "save changes?" prompt when closing a tab or quitting with unsaved edits.
|
|
|
|
use pdfcraft_engine::Edit;
|
|
use pdfcraft_platform::staging::{StagingName, staging_suffixes};
|
|
|
|
use crate::PdfCraftApp;
|
|
|
|
mod close;
|
|
mod save;
|
|
|
|
pub use close::CloseRequest;
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub use save::PendingOverwrite;
|
|
#[cfg(target_arch = "wasm32")]
|
|
pub(crate) use save::download;
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub(crate) use save::in_document_folder;
|
|
pub use save::{SaveTarget, write_atomically};
|
|
#[cfg(test)]
|
|
pub(crate) use save::{is_mail_temp_copy, save_dialog_folder};
|
|
#[cfg(test)]
|
|
use save::{write_atomically_syncing, write_atomically_with};
|
|
|
|
/// Text typed into a new text box that isn't added to the page yet.
|
|
fn new_text_typed(v: &crate::DocView) -> bool {
|
|
v.content.draft.as_ref().is_some_and(|d| d.index.is_none() && !d.text.trim().is_empty())
|
|
}
|
|
|
|
/// Keeps the pages (or comment) the user acted on selected, where they now are.
|
|
fn select_after_edit(view: &mut crate::DocView, edit: Edit, info: &pdfcraft_render::DocInfo, quick_tool: crate::QuickTool) {
|
|
match edit {
|
|
Edit::MovePages { pages, to } => {
|
|
let n = pages.iter().collect::<std::collections::BTreeSet<_>>().len();
|
|
let to = to.min(info.pages.len() - n);
|
|
view.select_pages(&(to..to + n).collect::<Vec<_>>());
|
|
}
|
|
Edit::DuplicatePages { pages, at: Some(at) } => {
|
|
let n = pages.iter().collect::<std::collections::BTreeSet<_>>().len();
|
|
let at = at.min(info.pages.len().saturating_sub(n));
|
|
view.select_pages(&(at..at + n).collect::<Vec<_>>());
|
|
}
|
|
Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]),
|
|
Edit::DeletePages { .. } => view.select_pages(&[]),
|
|
// A stroke drawn with the pen stays unselected, so the selection box and
|
|
// author popup don't sit over the next stroke (#429).
|
|
Edit::AddAnnotation(a)
|
|
if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. })
|
|
&& quick_tool == crate::QuickTool::Comment(crate::comments::CommentTool::Ink) =>
|
|
{
|
|
view.comments.selected = None;
|
|
}
|
|
Edit::AddAnnotation(a) => {
|
|
// Select the new comment (appended last among the page's comments).
|
|
let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max();
|
|
view.comments.selected = newest.map(|n| (a.page, n));
|
|
view.comments.reveal = true;
|
|
// A new highlight opens its note for typing straight away, as in Acrobat,
|
|
// unless a note typed into another card is still unsaved.
|
|
if let (pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, .. }, Some(n)) = (&a.shape, newest)
|
|
&& a.contents.is_empty()
|
|
&& view.comments.editing.is_none()
|
|
{
|
|
view.comments.open_editor(a.page, n, String::new());
|
|
}
|
|
}
|
|
Edit::DeleteAnnotation { .. } => view.comments.selected = None,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// The pages whose widgets differ between two form states, and the (field, page) pairs whose
|
|
/// value changed and so must not be drawn from the baked page.
|
|
fn form_edit_pages(
|
|
before: &[pdfcraft_engine::FormField],
|
|
after: &[pdfcraft_engine::FormField],
|
|
) -> (std::collections::BTreeSet<usize>, Vec<(String, usize)>) {
|
|
let mut changed_pages = std::collections::BTreeSet::new();
|
|
let mut unbaked = Vec::new();
|
|
for f in after {
|
|
let pages = |field: &pdfcraft_engine::FormField| field.widgets.iter().filter_map(|w| w.page).collect::<Vec<_>>();
|
|
match before.iter().find(|old| old.name == f.name) {
|
|
Some(old) if old == f => {}
|
|
Some(old) => {
|
|
changed_pages.extend(pages(f));
|
|
changed_pages.extend(pages(old));
|
|
if old.value != f.value {
|
|
unbaked.extend(pages(f).into_iter().map(|p| (f.name.clone(), p)));
|
|
}
|
|
}
|
|
None => {
|
|
changed_pages.extend(pages(f));
|
|
unbaked.extend(pages(f).into_iter().map(|p| (f.name.clone(), p)));
|
|
}
|
|
}
|
|
}
|
|
for old in before.iter().filter(|old| !after.iter().any(|f| f.name == old.name)) {
|
|
changed_pages.extend(old.widgets.iter().filter_map(|w| w.page));
|
|
}
|
|
(changed_pages, unbaked)
|
|
}
|
|
|
|
impl PdfCraftApp {
|
|
/// Apply an edit to the active document. Returns `true` on success; failures are shown.
|
|
pub fn apply_edit(&mut self, edit: Edit) -> bool {
|
|
let Some((i, id)) = self.active_ids() else { return false };
|
|
let label = edit.label();
|
|
let (form_before, page_count_before, had_xfa) = if let Some(d) = self.session.get(id) {
|
|
(Some(std::sync::Arc::clone(&d.form)), d.info.pages.len(), d.xfa.is_some())
|
|
} else {
|
|
(None, 0, false)
|
|
};
|
|
match self.session.apply(id, edit.clone()) {
|
|
Ok(()) => {
|
|
let Some(doc) = self.session.get(id) else { return true };
|
|
let info = &doc.info;
|
|
let view = &mut self.views[i];
|
|
view.applied_editor = None;
|
|
// A picture picked with the Select tool may be the one this edit removed or moved:
|
|
// drop its highlight now, not on the next frame (which only comes with the next input).
|
|
view.picked_image = None;
|
|
if let Some(ctx) = &self.ctx {
|
|
ctx.request_repaint();
|
|
}
|
|
if matches!(edit, Edit::AddAnnotation(_)) {
|
|
// A released drawing's raster can come now: it will have the annotation.
|
|
view.comments.drawings_committed();
|
|
} else {
|
|
// Only a new drawing waits on its raster; any other edit may remove or move it.
|
|
view.comments.settling.clear();
|
|
}
|
|
view.signature_drag.committed(&edit, doc.edit_generation());
|
|
view.move_background.committed(&edit, doc.edit_generation());
|
|
view.text_background.committed(&edit);
|
|
if let Some(page) = comment_page(&edit).or_else(|| content_page(&edit)) {
|
|
// Comment and content edits change one page: keep every other raster.
|
|
view.page_changed(page);
|
|
} else if let Some(form_before) = form_before
|
|
&& doc.info.pages.len() == page_count_before
|
|
&& doc.xfa.is_some() == had_xfa
|
|
&& doc.xfa.is_none()
|
|
&& is_form_edit(&edit)
|
|
{
|
|
let (changed_pages, unbaked) = form_edit_pages(&form_before, &doc.form);
|
|
view.forms.unbaked.extend(unbaked);
|
|
for page in changed_pages {
|
|
view.page_changed(page);
|
|
}
|
|
} else {
|
|
view.document_changed(info);
|
|
}
|
|
if matches!(edit, Edit::AddAnnotation(_)) {
|
|
view.comments.comment_added();
|
|
}
|
|
// Keep the pages the user acted on selected, where they now are.
|
|
select_after_edit(view, edit, info, self.quick_tool);
|
|
if std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned {
|
|
self.quick_tool = crate::QuickTool::Select;
|
|
}
|
|
// Text saved as typed but drawn with `?` (its font can't draw it): said, not silent.
|
|
// Before the scripts' output, so an alert they raise still shows.
|
|
if let Some(n) = self.session.get(id).and_then(|d| d.edit_notices.first()).cloned() {
|
|
self.notify(crate::content_ui::undrawn_message(&n));
|
|
}
|
|
let out = self.session.take_js_output(id);
|
|
self.handle_js(id, out);
|
|
true
|
|
}
|
|
Err(e) => {
|
|
if let Some(view) = self.views.get_mut(i) {
|
|
// A drawing whose annotation wasn't added must not stay painted.
|
|
view.comments.drawings_refused();
|
|
// A paragraph that couldn't be applied opens again with its typing, instead of
|
|
// the box the click moved to.
|
|
if let Edit::EditTextBlock { page, block, .. } = &edit
|
|
&& let Some(mut ed) = view.applied_editor.take().filter(|ed| (ed.page, ed.block) == (*page, *block))
|
|
{
|
|
ed.focus = true;
|
|
view.line_editor = Some(ed);
|
|
view.pending_text_open = None;
|
|
}
|
|
}
|
|
let message = if matches!(edit, Edit::MoveObjects { .. }) { crate::object_ui::move_error(&e) } else { e.to_string() };
|
|
self.notify_fmt("{label} failed: {e}", &[("label", &crate::i18n::edit_label(&label)), ("e", &message)]);
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Undo the last change: to the Combine files list while its tab shows, else the document.
|
|
pub fn undo(&mut self) {
|
|
// While a polygon, cloud or run of connected lines is being drawn, Undo takes back its
|
|
// last point instead of the last finished edit.
|
|
if let Some((i, _)) = self.active_ids()
|
|
&& self.views[i].comments.undo_point()
|
|
{
|
|
return;
|
|
}
|
|
if self.combine_showing() {
|
|
self.combine_history_step(true);
|
|
} else {
|
|
self.history_step(true);
|
|
}
|
|
}
|
|
|
|
pub fn redo(&mut self) {
|
|
if self.combine_showing() {
|
|
self.combine_history_step(false);
|
|
} else {
|
|
self.history_step(false);
|
|
}
|
|
}
|
|
|
|
fn history_step(&mut self, undo: bool) {
|
|
let Some((i, id)) = self.active_ids() else { return };
|
|
let result = if undo { self.session.undo(id) } else { self.session.redo(id) };
|
|
match result {
|
|
Ok(label) => {
|
|
if let Some(doc) = self.session.get(id) {
|
|
self.views[i].document_changed(&doc.info);
|
|
}
|
|
self.views[i].comments.selected = None;
|
|
let label = crate::i18n::edit_label(&label);
|
|
if undo {
|
|
self.notify_fmt("Undid {label}", &[("label", &label)]);
|
|
} else {
|
|
self.notify_fmt("Redid {label}", &[("label", &label)]);
|
|
}
|
|
}
|
|
Err(e) => self.notify_error(e),
|
|
}
|
|
}
|
|
|
|
/// Apply any edit a view queued this frame (organize toolbar, keys).
|
|
/// Returns false when a field refused the typing queued for it (its editor is open again).
|
|
pub(crate) fn process_pending_edits(&mut self) -> bool {
|
|
let Some(i) = self.active else { return true };
|
|
let applied = self.apply_queued_edit(i);
|
|
if !applied {
|
|
// Nor may a shortcut pressed with it (⌘S right after Enter) run behind the refusal.
|
|
self.deferred_commands.clear();
|
|
}
|
|
match self.views.get_mut(i).and_then(|v| v.pending_action.take()) {
|
|
Some(crate::canvas::ViewAction::InsertFromFile) => self.insert_from_file_dialog(),
|
|
Some(crate::canvas::ViewAction::InsertFromFileAt(at)) => self.insert_from_file_at(Some(at)),
|
|
Some(crate::canvas::ViewAction::ReplacePages) => self.replace_pages_dialog(),
|
|
Some(crate::canvas::ViewAction::Save) => {
|
|
self.save_active(SaveTarget::InPlace);
|
|
}
|
|
Some(crate::canvas::ViewAction::Extract) => self.open_extract_dialog(),
|
|
Some(crate::canvas::ViewAction::Split) => self.dialog = Some(crate::Dialog::Split),
|
|
Some(crate::canvas::ViewAction::SplitBeforeSelected) => {
|
|
self.split_draft.mode = crate::SplitMode::Selection;
|
|
self.dialog = Some(crate::Dialog::Split);
|
|
}
|
|
Some(crate::canvas::ViewAction::CopyPages { cut }) => self.copy_pages(cut),
|
|
Some(crate::canvas::ViewAction::PastePages) => self.paste_pages(),
|
|
Some(crate::canvas::ViewAction::EnhanceCamera) => self.enhance_camera(i),
|
|
Some(crate::canvas::ViewAction::CancelCamera) => self.leave_camera(i),
|
|
Some(crate::canvas::ViewAction::CameraRotate { clockwise }) => {
|
|
if let Some(done) = self.views.get(i).and_then(|v| v.camera_done) {
|
|
let degrees = if clockwise { 90 } else { -90 };
|
|
if self.apply_edit(Edit::RotatePages { pages: vec![done.page], degrees })
|
|
&& let Some(d) = self.views.get_mut(i).and_then(|v| v.camera_done.as_mut())
|
|
{
|
|
d.turns += degrees;
|
|
d.steps += 1;
|
|
}
|
|
}
|
|
}
|
|
Some(crate::canvas::ViewAction::CameraLevel) => self.camera_level(i),
|
|
Some(crate::canvas::ViewAction::CameraNext(command)) => {
|
|
self.leave_camera(i);
|
|
self.execute(command);
|
|
}
|
|
None => {}
|
|
}
|
|
applied
|
|
}
|
|
|
|
/// Enhance ▸ Camera image ▸ clean-up slider: undo the enhancement (and the turns made since)
|
|
/// and enhance again from the photo at the new level, turning it as before. Only when the
|
|
/// top of the undo history is still those steps; otherwise the slider goes back.
|
|
fn camera_level(&mut self, i: usize) {
|
|
let Some(view) = self.views.get(i) else { return };
|
|
let (id, Some(done)) = (view.id, view.camera_done) else { return };
|
|
let ours = |s: &pdfcraft_engine::Session, k: usize| {
|
|
let label = s.get(id).and_then(|d| d.can_undo().map(str::to_owned));
|
|
let want = if k + 1 == done.steps { "Enhance camera image" } else { "Rotate page" };
|
|
label.as_deref() == Some(want)
|
|
};
|
|
// Undo the run this tool applied, checking each step's label first. If the run was
|
|
// broken (another edit came in between), redo what was undone and leave the page alone:
|
|
// enhancing a page that is still straightened would warp it a second time.
|
|
let restore = |app: &mut Self| {
|
|
if let Some(d) = app.views.get_mut(i).and_then(|v| v.camera_done.as_mut()) {
|
|
(d.look, d.level) = d.applied;
|
|
}
|
|
};
|
|
let mut undone = 0;
|
|
while undone < done.steps && ours(&self.session, undone) && self.session.undo(id).is_ok() {
|
|
undone += 1;
|
|
}
|
|
if undone < done.steps {
|
|
for _ in 0..undone {
|
|
if self.session.redo(id).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
restore(self);
|
|
return;
|
|
}
|
|
let result = self.session.enhance_camera_page_at(id, done.page, done.corners, done.look, done.level).and_then(|()| {
|
|
let turn = done.turns.rem_euclid(360);
|
|
if turn == 0 { Ok(1) } else { self.session.apply(id, Edit::RotatePages { pages: vec![done.page], degrees: turn }).map(|()| 2) }
|
|
});
|
|
if let (Some(doc), Some(view)) = (self.session.get(id), self.views.get_mut(i)) {
|
|
view.document_changed(&doc.info);
|
|
if let Some(d) = view.camera_done.as_mut() {
|
|
match result {
|
|
Ok(steps) => {
|
|
d.applied = (d.look, d.level);
|
|
d.steps = steps;
|
|
}
|
|
Err(_) => (d.look, d.level) = d.applied,
|
|
}
|
|
}
|
|
}
|
|
if let Err(e) = result {
|
|
let label = crate::i18n::action_label("Enhance camera image");
|
|
self.notify_fmt("{label} failed: {e}", &[("label", &label), ("e", &e.to_string())]);
|
|
}
|
|
}
|
|
|
|
/// Leave Enhance ▸ Camera image on tab `i`.
|
|
fn leave_camera(&mut self, i: usize) {
|
|
if let Some(view) = self.views.get_mut(i) {
|
|
view.camera = None;
|
|
view.camera_done = None;
|
|
}
|
|
if self.quick_tool == crate::QuickTool::EnhanceCamera {
|
|
self.quick_tool = crate::QuickTool::Select;
|
|
}
|
|
}
|
|
|
|
/// Enhance ▸ Camera image: straighten what the corners on tab `i` enclose. On success the
|
|
/// bar offers rotate, crop and organize for the page; on failure the corners stay so they
|
|
/// can be adjusted.
|
|
fn enhance_camera(&mut self, i: usize) {
|
|
let Some(view) = self.views.get(i) else { return };
|
|
let (id, Some(q)) = (view.id, view.camera) else { return };
|
|
let Some(page) = self.session.get(id).and_then(|d| d.info.pages.get(q.page).cloned()) else { return };
|
|
let corners = q.corners.map(|[x, y]| {
|
|
let [u, v] = page.view_to_user(x, y);
|
|
[u as f64, v as f64]
|
|
});
|
|
let (look, level) = (crate::camera_ui::Look::Colour, crate::camera_ui::DEFAULT_LEVEL);
|
|
match self.session.enhance_camera_page_at(id, q.page, corners, look, level) {
|
|
Ok(()) => {
|
|
// The tool stays open with the clean-up slider, rotate, crop and organize.
|
|
if let (Some(doc), Some(view)) = (self.session.get(id), self.views.get_mut(i)) {
|
|
view.document_changed(&doc.info);
|
|
view.camera = None;
|
|
view.camera_done =
|
|
Some(crate::camera_ui::CameraDone { page: q.page, corners, look, level, applied: (look, level), turns: 0, steps: 1 });
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let label = crate::i18n::action_label("Enhance camera image");
|
|
self.notify_fmt("{label} failed: {e}", &[("label", &label), ("e", &e.to_string())]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Organize ▸ Copy / Cut: remember the selected pages (the document as it is now); Cut also
|
|
/// deletes them (one page always stays).
|
|
pub fn copy_pages(&mut self, cut: bool) {
|
|
// What's typed in a form field is part of the document (#166).
|
|
if !self.commit_form_typing() {
|
|
return;
|
|
}
|
|
let Some((i, id)) = self.active_ids() else { return };
|
|
let Some(doc) = self.session.get(id) else { return };
|
|
let pages = self.views[i].target_pages();
|
|
let n = doc.info.pages.len();
|
|
if cut && pages.len() >= n {
|
|
self.notify_tr("A document needs at least one page: copy instead");
|
|
return;
|
|
}
|
|
self.page_clipboard = Some(crate::PageClip { from: id, name: doc.name.clone(), bytes: doc.bytes.clone(), pages: pages.clone() });
|
|
if cut {
|
|
self.apply_edit(Edit::DeletePages { pages: pages.clone() });
|
|
}
|
|
let what = if pages.len() == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &pages.len().to_string())]) };
|
|
if cut {
|
|
self.notify_fmt("Cut {what}", &[("what", &what)]);
|
|
} else {
|
|
self.notify_fmt("Copied {what}", &[("what", &what)]);
|
|
}
|
|
}
|
|
|
|
/// Organize ▸ Paste: insert the copied pages after the selection (or the current page).
|
|
pub fn paste_pages(&mut self) {
|
|
let Some(clip) = self.page_clipboard.clone() else {
|
|
self.notify_tr("Copy or cut pages first");
|
|
return;
|
|
};
|
|
let Some(i) = self.active else { return };
|
|
let at = self.views[i].target_pages().into_iter().max().map_or(0, |p| p + 1);
|
|
let count = clip.pages.len();
|
|
// Pasting into the document the pages came from is a duplicate (or, after Cut, a move):
|
|
// its fields keep their names. Pasting into another document renames colliding ones.
|
|
let field_conflicts =
|
|
if self.views[i].id == clip.from { pdfcraft_engine::FieldConflicts::Merge } else { pdfcraft_engine::FieldConflicts::Rename };
|
|
if self.apply_edit(Edit::InsertPagesFrom {
|
|
name: clip.name.clone(),
|
|
bytes: clip.bytes.clone(),
|
|
pages: Some(clip.pages.clone()),
|
|
at,
|
|
field_conflicts,
|
|
}) {
|
|
self.views[i].select_pages(&(at..at + count).collect::<Vec<_>>());
|
|
}
|
|
}
|
|
|
|
/// Make the active document say what its form shows (#166): apply an edit still queued on
|
|
/// its tab, then commit the text in a field's open editor, which otherwise commits only on
|
|
/// Enter, Tab or clicking away (the editor closes, as clicking away would). Run before
|
|
/// anything reads the document: Save, Print, Export, Optimize, Sign, Extract, Split, Copy
|
|
/// pages and button scripts.
|
|
///
|
|
/// Returns false when the field rejects the value (a validation script or format; the user
|
|
/// is told why). The editor stays open with the text, and the caller must stop: nothing is
|
|
/// saved, printed or exported without it.
|
|
pub(crate) fn commit_form_typing(&mut self) -> bool {
|
|
let Some((i, id)) = self.active_ids() else { return true };
|
|
if !self.apply_queued_edit(i) {
|
|
return false;
|
|
}
|
|
let Some(draft) = self.views.get(i).and_then(|v| v.forms.focus.clone()) else { return true };
|
|
let Some(edit) = self.session.get(id).and_then(|d| crate::forms_ui::draft_edit(&draft, &d.form)) else {
|
|
return true; // nothing typed that the document doesn't already have
|
|
};
|
|
if let Some(view) = self.views.get_mut(i) {
|
|
view.forms.focus = None;
|
|
}
|
|
if self.apply_edit(edit) {
|
|
return true;
|
|
}
|
|
if let Some(view) = self.views.get_mut(i) {
|
|
let mut draft = draft;
|
|
draft.request_focus = true;
|
|
view.forms.focus = Some(draft);
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Apply the edit queued on tab `i` (the active one). Returns false only when it was a
|
|
/// field's typing and the field refused it (#166): the editor reopens with the text, and the
|
|
/// user has been told why, so the typing isn't lost and the caller can stop.
|
|
fn apply_queued_edit(&mut self, i: usize) -> bool {
|
|
let Some(edit) = self.views.get_mut(i).and_then(|v| v.pending_edit.take()) else { return true };
|
|
let signature_page = self.views.get_mut(i).and_then(|v| v.fill_signature_page.take());
|
|
let committed = self.views.get_mut(i).and_then(|v| v.forms.committed.take());
|
|
let typed = match (&edit, &committed) {
|
|
(Edit::SetFieldValue { name, .. }, Some(draft)) => *name == draft.name,
|
|
_ => false,
|
|
};
|
|
if self.apply_edit(edit) {
|
|
// Tab can wrap to the same field (including another widget of that field).
|
|
// Its new editor was opened before this edit ran, so it still holds the old
|
|
// value. Refresh from the accepted form before Save can commit that stale draft.
|
|
if typed
|
|
&& let Some(view) = self.views.get_mut(i)
|
|
&& let Some(focus) = view.forms.focus.as_mut()
|
|
&& focus.request_focus
|
|
&& committed.as_ref().is_some_and(|draft| draft.name == focus.name)
|
|
&& let Some(field) = self.session.get(view.id).and_then(|doc| doc.form.iter().find(|field| field.name == focus.name))
|
|
{
|
|
focus.text = crate::forms_ui::draft_text(field);
|
|
focus.picked = field.value.clone();
|
|
}
|
|
if let Some(page) = signature_page
|
|
&& let Some(view) = self.views.get_mut(i)
|
|
{
|
|
// Signature imports are a labeled batch; select their appended stamp just
|
|
// as AddAnnotation selects a typed or drawn signature.
|
|
let newest = self
|
|
.session
|
|
.get(view.id)
|
|
.and_then(|d| d.info.annotations.iter().filter(|a| a.page == page && !a.is_reply()).map(|a| a.index).max());
|
|
view.comments.selected = newest.map(|index| (page, index));
|
|
view.comments.reveal = true;
|
|
self.quick_tool = crate::QuickTool::Select;
|
|
}
|
|
return true;
|
|
}
|
|
if !typed {
|
|
return true;
|
|
}
|
|
if let (Some(mut draft), Some(view)) = (committed, self.views.get_mut(i)) {
|
|
draft.request_focus = true;
|
|
view.forms.focus = Some(draft);
|
|
}
|
|
false
|
|
}
|
|
|
|
/// [`Self::commit_form_typing`] for document `id`, which may not be the active tab (a save
|
|
/// panel answering later, Close All). If its tab has typing or a queued edit, the tab is
|
|
/// brought forward first, so the commit and any field scripts act on that document.
|
|
pub(crate) fn commit_typing_in(&mut self, id: pdfcraft_engine::DocId) -> bool {
|
|
let Some(i) = self.views.iter().position(|v| v.id == id) else { return true };
|
|
// Added text the standard fonts can't draw can't be saved: show it rather than drop it.
|
|
// Its page is brought into view, so the text, the warning and Discard are on screen.
|
|
let blocked = self.views.get_mut(i).and_then(|v| {
|
|
let c = v.content.blocked()?;
|
|
v.content.hold_blocked();
|
|
if let Some(page) = v.content.draft.as_ref().map(|d| d.page) {
|
|
v.go_to_page(page);
|
|
}
|
|
Some(c)
|
|
});
|
|
if let Some(c) = blocked {
|
|
self.active = Some(i);
|
|
self.notify(crate::content_ui::undrawable_message(c));
|
|
return false;
|
|
}
|
|
let typing = self.views.get(i).is_some_and(|v| {
|
|
v.pending_edit.is_some()
|
|
|| v.forms.focus.is_some()
|
|
|| v.content.draft.is_some()
|
|
|| v.line_editor.as_ref().is_some_and(|ed| ed.has_changes())
|
|
|| v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty())
|
|
});
|
|
if self.active != Some(i) {
|
|
if !typing {
|
|
return true;
|
|
}
|
|
self.active = Some(i);
|
|
}
|
|
self.commit_form_typing() && self.commit_open_fill_text(id) && self.commit_open_text_edits(id)
|
|
}
|
|
|
|
/// The Edit a PDF ▸ Text boxes (new text, and a paragraph open for editing) commit on
|
|
/// click-away. Save does that first, so the typed text is in the file instead of being left
|
|
/// open on the screen (#3496).
|
|
fn commit_open_text_edits(&mut self, id: pdfcraft_engine::DocId) -> bool {
|
|
let Some(doc) = self.session.get(id) else { return true };
|
|
let Some(view) = self.views.iter_mut().find(|v| v.id == id) else { return true };
|
|
// Both are taken out of the view before either edit runs, so keep what is needed to put
|
|
// back whichever one the document refuses: typed text must not be lost.
|
|
let (draft, select_added, editor) = (view.content.draft.clone(), view.content.select_added, view.line_editor.clone());
|
|
let added = view.content.finish_draft(doc.added());
|
|
let paragraph = view.line_editor.take_if(|ed| ed.has_changes()).and_then(crate::edit_text_ui::finished_edit);
|
|
let added_ok = added.is_none_or(|edit| self.apply_edit(edit));
|
|
let paragraph_ok = paragraph.is_none_or(|edit| self.apply_edit(edit));
|
|
if let Some(view) = self.views.iter_mut().find(|v| v.id == id) {
|
|
if !added_ok {
|
|
(view.content.draft, view.content.select_added) = (draft, select_added);
|
|
}
|
|
if !paragraph_ok {
|
|
view.line_editor = editor;
|
|
}
|
|
}
|
|
added_ok && paragraph_ok
|
|
}
|
|
|
|
/// The Fill & Sign type box commits on click-away. Save does that first, so the text is in
|
|
/// the file (and can be flattened) instead of being left on the screen.
|
|
fn commit_open_fill_text(&mut self, id: pdfcraft_engine::DocId) -> bool {
|
|
let Some(i) = self.views.iter().position(|v| v.id == id) else { return true };
|
|
let Some(tb) = self.views[i].fill_text.clone() else { return true };
|
|
let text = tb.text.trim().to_string();
|
|
if text.is_empty() {
|
|
if let Some(view) = self.views.get_mut(i) {
|
|
view.fill_text = None;
|
|
}
|
|
return true;
|
|
}
|
|
let author = self.comment_prefs.author.clone();
|
|
let rotation = self.session.get(id).map_or(0, |doc| crate::comments::page_rotation(&doc.info, tb.page));
|
|
let edit = crate::fill_sign::typed(tb.page, tb.at, &text, tb.size, rotation, &author);
|
|
if self.apply_edit(edit) {
|
|
if let Some(view) = self.views.get_mut(i) {
|
|
view.fill_text = None;
|
|
}
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// When the preference is on, bake Fill & Sign marks before the bytes are written.
|
|
/// Returns false when the document is not allowed to change (nothing is written).
|
|
fn flatten_fill_sign_for_save(&mut self, id: pdfcraft_engine::DocId) -> bool {
|
|
if !self.flatten_fill_sign_on_save {
|
|
return true;
|
|
}
|
|
let before = self.session.get(id).map(|d| d.edit_generation());
|
|
if let Err(e) = self.session.apply(id, Edit::FlattenFillSign) {
|
|
self.notify_error(e);
|
|
return false;
|
|
}
|
|
let after = self.session.get(id).map(|d| d.edit_generation());
|
|
if before != after
|
|
&& let Some(doc) = self.session.get(id)
|
|
&& let Some(view) = self.views.iter_mut().find(|v| v.id == id)
|
|
{
|
|
view.document_changed(&doc.info);
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Whether tab `index` has work that isn't saved: edits, or form typing not committed yet.
|
|
pub(crate) fn has_unsaved_work(&self, index: usize) -> bool {
|
|
let Some(v) = self.views.get(index) else { return false };
|
|
let Some(doc) = self.session.get(v.id) else { return false };
|
|
doc.dirty
|
|
|| v.pending_edit.is_some()
|
|
|| v.content.blocked().is_some()
|
|
|| v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some())
|
|
|| v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty())
|
|
|| new_text_typed(v)
|
|
|| v.line_editor.as_ref().map_or_else(|| false, |ed| ed.has_unsaved_text())
|
|
}
|
|
}
|
|
|
|
/// The single page a content edit changes (`None` for other edits). Like comment edits,
|
|
/// these leave every other page's bytes, geometry and text alone, so their rasters stay valid.
|
|
fn content_page(edit: &Edit) -> Option<usize> {
|
|
match edit {
|
|
Edit::MoveObjects { page, .. }
|
|
| Edit::EditTextLine { page, .. }
|
|
| Edit::EditTextBlock { page, .. }
|
|
| Edit::EditPageImage { page, .. }
|
|
| Edit::AddText { page, .. }
|
|
| Edit::AddImage { page, .. }
|
|
| Edit::UpdateContent { page, .. }
|
|
| Edit::DeleteContent { page, .. }
|
|
| Edit::ReplaceImage { page, .. } => Some(*page),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn comment_page(edit: &Edit) -> Option<usize> {
|
|
match edit {
|
|
Edit::AddAnnotation(a) => Some(a.page),
|
|
Edit::DeleteAnnotation { page, .. }
|
|
| Edit::SetAnnotationContents { page, .. }
|
|
| Edit::ReplyToAnnotation { page, .. }
|
|
| Edit::SetAnnotationStatus { page, .. }
|
|
| Edit::MoveAnnotation { page, .. }
|
|
| Edit::ReshapeAnnotation { page, .. }
|
|
| Edit::ResizeAnnotation { page, .. }
|
|
| Edit::StyleAnnotation { page, .. }
|
|
| Edit::FillAnnotation { page, .. }
|
|
| Edit::FontSizeAnnotation { page, .. }
|
|
| Edit::SetAnnotationInfo { page, .. } => Some(*page),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Whether `edit` is a form-related modification.
|
|
fn is_form_edit(edit: &Edit) -> bool {
|
|
matches!(
|
|
edit,
|
|
Edit::SetFieldValue { .. }
|
|
| Edit::ResetForm { .. }
|
|
| Edit::AddField { .. }
|
|
| Edit::DeleteField { .. }
|
|
| Edit::DuplicateField { .. }
|
|
| Edit::SetFieldProps { .. }
|
|
| Edit::SetFieldImage { .. }
|
|
| Edit::SetFieldScript { .. }
|
|
| Edit::SetCalculationOrder { .. }
|
|
| Edit::ApplyScriptChanges { .. }
|
|
)
|
|
}
|
|
|
|
impl PdfCraftApp {
|
|
/// Carry out a Bookmarks-panel action as an undoable edit.
|
|
pub fn bookmark_action(&mut self, action: crate::panels::BmAction) {
|
|
use crate::panels::BmAction as A;
|
|
let Some((i, id)) = self.active_ids() else { return };
|
|
let current = self.views[i].current;
|
|
let parent_of = |p: &[usize]| p[..p.len() - 1].to_vec();
|
|
let edit = match action {
|
|
A::New => {
|
|
let n = self.session.get(id).map_or(0, |d| d.info.outline.len());
|
|
// Where the reader is: the selection's corner, else the top of the window.
|
|
let (page, [left, top]) =
|
|
self.session.get(id).and_then(|d| self.views.get(i)?.bookmark_position(&d.info)).unwrap_or((current, [None, None]));
|
|
let (left, top) = (left.map(f64::from), top.map(f64::from));
|
|
self.apply_edit(Edit::AddBookmark { parent: vec![], index: n, title: "Untitled".into(), page, left, top });
|
|
// Like Acrobat: the new bookmark starts in rename mode.
|
|
self.bookmark_rename = Some((vec![n], "Untitled".into()));
|
|
self.right = Some(crate::RightPanel::Bookmarks);
|
|
return;
|
|
}
|
|
A::StartRename(path) => {
|
|
let title = self.session.get(id).and_then(|d| bookmark_at(&d.info.outline, &path)).map(|b| b.title.clone()).unwrap_or_default();
|
|
self.bookmark_rename = Some((path, title));
|
|
return;
|
|
}
|
|
A::Rename(path, title) => Edit::RenameBookmark { path, title },
|
|
A::SetToCurrentPage(path) => Edit::SetBookmarkPage { path, page: current },
|
|
A::SetStyle(path, bold, italic) => Edit::SetBookmarkStyle { path, bold, italic },
|
|
A::SetColor(path, color) => Edit::SetBookmarkColor { path, color: Some(color) },
|
|
A::Delete(path) => Edit::DeleteBookmark { path },
|
|
A::MoveUp(path) => {
|
|
let at = path[path.len() - 1].saturating_sub(1);
|
|
Edit::MoveBookmark { to_parent: parent_of(&path), from: path, index: at }
|
|
}
|
|
A::MoveDown(path) => {
|
|
let at = path[path.len() - 1] + 1;
|
|
Edit::MoveBookmark { to_parent: parent_of(&path), from: path, index: at }
|
|
}
|
|
A::Indent(path) => {
|
|
let mut to_parent = parent_of(&path);
|
|
to_parent.push(path[path.len() - 1].saturating_sub(1));
|
|
Edit::MoveBookmark { from: path, to_parent, index: usize::MAX }
|
|
}
|
|
A::Outdent(path) => {
|
|
let parent = parent_of(&path);
|
|
let grand = parent_of(&parent);
|
|
let at = parent[parent.len() - 1] + 1;
|
|
Edit::MoveBookmark { from: path, to_parent: grand, index: at }
|
|
}
|
|
A::FromStructure => {
|
|
self.right = Some(crate::RightPanel::Bookmarks);
|
|
Edit::BookmarksFromStructure
|
|
}
|
|
};
|
|
self.apply_edit(edit);
|
|
}
|
|
}
|
|
|
|
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)?;
|
|
if rest.is_empty() { Some(item) } else { bookmark_at(&item.children, rest) }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
#[cfg(test)]
|
|
mod revert_draft_tests {
|
|
use super::*;
|
|
use crate::forms_ui::Focus;
|
|
use pdfcraft_engine::FieldValue;
|
|
|
|
fn focus(text: &str) -> Focus {
|
|
Focus {
|
|
name: "name".into(),
|
|
widget: 0,
|
|
text: text.into(),
|
|
picked: Vec::new(),
|
|
request_focus: false,
|
|
select_all: false,
|
|
press: None,
|
|
calendar: None,
|
|
calendar_rect: None,
|
|
calendar_text: None,
|
|
had_focus: false,
|
|
}
|
|
}
|
|
|
|
fn app() -> PdfCraftApp {
|
|
let mut app = PdfCraftApp::new();
|
|
app.open_bytes("source.pdf", None, include_bytes!("../tests/data/form.pdf").to_vec()).unwrap();
|
|
app
|
|
}
|
|
|
|
fn queue_draft(app: &mut PdfCraftApp, index: usize, text: &str) {
|
|
app.views[index].forms.focus = Some(focus(text));
|
|
let form = app.session.get(app.views[index].id).unwrap().form.clone();
|
|
crate::forms_ui::commit(&mut app.views[index], &form);
|
|
assert!(app.views[index].forms.committed.is_some() && app.views[index].pending_edit.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn control_quit_prompts_for_uncommitted_typing_and_cancel_retains_it() {
|
|
for queued in [false, true] {
|
|
let mut app = app();
|
|
let id = app.views[0].id;
|
|
app.open_bytes("other.pdf", None, include_bytes!("../tests/data/form.pdf").to_vec()).unwrap();
|
|
if queued {
|
|
queue_draft(&mut app, 0, "Unsaved typing");
|
|
} else {
|
|
app.views[0].forms.focus = Some(focus("Unsaved typing"));
|
|
}
|
|
assert!(!app.session.get(id).unwrap().dirty);
|
|
assert_eq!(app.active, Some(1));
|
|
let ctx = egui::Context::default();
|
|
assert!(!app.request_quit(&ctx));
|
|
assert_eq!(app.close_request, Some(CloseRequest::Quit));
|
|
assert!(!app.allow_quit);
|
|
app.resolve_close(&ctx, None);
|
|
assert!(app.close_request.is_none());
|
|
assert_eq!(app.views.len(), 2);
|
|
assert!(app.has_unsaved_work(0));
|
|
assert_eq!(app.views[0].id, id);
|
|
assert!(!app.session.get(id).unwrap().dirty);
|
|
if queued {
|
|
assert!(
|
|
matches!(&app.views[0].pending_edit, Some(Edit::SetFieldValue { value: FieldValue::Text(text), .. }) if text == "Unsaved typing")
|
|
);
|
|
} else {
|
|
assert_eq!(app.views[0].forms.focus.as_ref().unwrap().text, "Unsaved typing");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A value the field's font can't draw is kept, and the user is told why it shows `?`, in the
|
|
/// interface language; a drawable value says nothing.
|
|
#[test]
|
|
fn a_value_its_font_cannot_draw_is_kept_and_explained() {
|
|
let mut app = app();
|
|
let fill =
|
|
|app: &mut PdfCraftApp, text: &str| app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text(text.into()) });
|
|
assert!(fill(&mut app, "张三"));
|
|
let toast = app.toast.as_ref().map(|t| t.0.clone()).expect("a notice");
|
|
assert!(toast.contains("“张” (U+5F20)") && toast.contains("other PDF viewers redraw the field"), "{toast}");
|
|
assert_eq!(app.session.get(app.views[0].id).unwrap().form.iter().find(|f| f.name == "name").unwrap().value, ["张三"]);
|
|
// The shell sets the drawing language each frame; here, directly (per thread).
|
|
let previous = crate::i18n::current();
|
|
crate::i18n::set_current(crate::i18n::Lang::from_code("zh-hans").expect("registered"));
|
|
assert!(fill(&mut app, "李四"));
|
|
crate::i18n::set_current(previous);
|
|
let toast = app.toast.as_ref().map(|t| t.0.clone()).expect("a notice");
|
|
assert!(toast.contains("此字段的字体无法显示“李”(U+674E)"), "{toast}");
|
|
app.toast = None;
|
|
assert!(fill(&mut app, "Ada"));
|
|
assert!(app.toast.is_none(), "{:?}", app.toast);
|
|
}
|
|
|
|
#[test]
|
|
fn unsaved_flag_follows_dirty_state() {
|
|
use std::sync::atomic::Ordering;
|
|
// `guard_quit` runs every frame, also on the web, where no close request ever arrives.
|
|
let ctx = egui::Context::default();
|
|
let mut app = app();
|
|
app.guard_quit(&ctx);
|
|
assert!(!app.unsaved_flag.load(Ordering::Relaxed));
|
|
assert!(app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Changed".into()) }));
|
|
app.guard_quit(&ctx);
|
|
assert!(app.unsaved_flag.load(Ordering::Relaxed));
|
|
}
|
|
|
|
/// Text typed into a new text box is lost on close unless the save prompt appears; an empty
|
|
/// box has nothing to lose.
|
|
#[test]
|
|
fn text_typed_into_a_new_text_box_counts_as_unsaved_work() {
|
|
use crate::content_ui::{TextDraft, default_style};
|
|
let mut app = app();
|
|
let draft = |text: &str| TextDraft { page: 0, index: None, rect: [0.0; 4], text: text.into(), style: default_style(), focus: true };
|
|
assert!(!app.has_unsaved_work(0));
|
|
app.views[0].content.draft = Some(draft(" \n"));
|
|
assert!(!app.has_unsaved_work(0));
|
|
app.views[0].content.draft = Some(draft("Typed"));
|
|
assert!(!app.session.get(app.views[0].id).unwrap().dirty);
|
|
assert!(app.has_unsaved_work(0));
|
|
}
|
|
|
|
/// Close ▸ Save, and Quit ▸ Save, write the text typed into a new text box before the tab
|
|
/// closes.
|
|
#[test]
|
|
fn saving_on_close_keeps_text_typed_into_a_new_text_box() {
|
|
use crate::content_ui::{TextDraft, default_style};
|
|
use pdfcraft_engine::AddedContent;
|
|
for quit in [false, true] {
|
|
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-new-text-{}-{quit}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("source.pdf").to_string_lossy().into_owned();
|
|
let bytes = include_bytes!("../tests/data/form.pdf").to_vec();
|
|
std::fs::write(&path, &bytes).unwrap();
|
|
let mut app = PdfCraftApp::new();
|
|
app.open_bytes("source.pdf", Some(path.clone()), bytes).unwrap();
|
|
let rect = [72.0, 72.0, 272.0, 92.0];
|
|
app.views[0].content.draft = Some(TextDraft { page: 0, index: None, rect, text: "Typed".into(), style: default_style(), focus: true });
|
|
let ctx = egui::Context::default();
|
|
if quit {
|
|
assert!(!app.request_quit(&ctx));
|
|
} else {
|
|
app.request_close_tab(0);
|
|
}
|
|
assert!(app.close_request.is_some());
|
|
app.resolve_close(&ctx, Some(true));
|
|
assert!(app.views.is_empty(), "the tab closes once saved");
|
|
|
|
let mut reopened = PdfCraftApp::new();
|
|
reopened.open_bytes("source.pdf", Some(path.clone()), std::fs::read(&path).unwrap()).unwrap();
|
|
let doc = reopened.session.get(reopened.views[0].id).unwrap();
|
|
assert!(doc.added().iter().any(|a| matches!(&a.content, AddedContent::Text(t) if t.text == "Typed")), "quit: {quit}");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn successful_revert_clears_focused_or_queued_drafts_only_on_its_document() {
|
|
for queued in [false, true] {
|
|
let mut app = app();
|
|
let source = app.views[0].id;
|
|
assert!(app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Committed change".into()) }));
|
|
if queued {
|
|
queue_draft(&mut app, 0, "Queued source draft");
|
|
} else {
|
|
app.views[0].forms.focus = Some(focus("Focused source draft"));
|
|
}
|
|
app.open_bytes("other.pdf", None, include_bytes!("../tests/data/form.pdf").to_vec()).unwrap();
|
|
let other = app.views[1].id;
|
|
assert!(app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Other committed value".into()) }));
|
|
queue_draft(&mut app, 1, "Other queued draft");
|
|
app.views[1].forms.focus = Some(focus("Other focused draft"));
|
|
let other_bytes = app.session.get(other).unwrap().bytes.clone();
|
|
let other_generation = app.session.get(other).unwrap().edit_generation();
|
|
let source_generation = app.session.get(source).unwrap().edit_generation();
|
|
app.active = Some(0);
|
|
app.revert_active();
|
|
let reverted = app.session.get(source).unwrap();
|
|
assert!(!reverted.dirty);
|
|
assert_eq!(reverted.edit_generation(), source_generation + 1);
|
|
assert!(reverted.form.iter().find(|field| field.name == "name").unwrap().value.is_empty());
|
|
assert!(app.views[0].forms.focus.is_none() && app.views[0].forms.committed.is_none() && app.views[0].pending_edit.is_none());
|
|
assert!(!app.has_unsaved_work(0));
|
|
assert_eq!(app.views[1].forms.focus.as_ref().unwrap().text, "Other focused draft");
|
|
assert_eq!(app.views[1].forms.committed.as_ref().unwrap().text, "Other queued draft");
|
|
assert!(
|
|
matches!(&app.views[1].pending_edit, Some(Edit::SetFieldValue { name, value: FieldValue::Text(text) }) if name == "name" && text == "Other queued draft")
|
|
);
|
|
let untouched = app.session.get(other).unwrap();
|
|
assert_eq!(untouched.bytes, other_bytes);
|
|
assert_eq!(untouched.edit_generation(), other_generation);
|
|
assert!(untouched.dirty && app.has_unsaved_work(1));
|
|
assert_eq!(app.active_ids(), Some((0, source)));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn failed_revert_preserves_focused_and_queued_drafts() {
|
|
let mut app = app();
|
|
let source = app.views[0].id;
|
|
queue_draft(&mut app, 0, "Queued draft");
|
|
app.views[0].forms.focus = Some(focus("Focused draft"));
|
|
// Exercise the ordinary NoDocument error boundary without a corrupt fixture.
|
|
// This verifies UI failure preservation, not other engine refresh failures.
|
|
app.session.close(source);
|
|
app.revert_active();
|
|
assert_eq!(app.views[0].forms.focus.as_ref().unwrap().text, "Focused draft");
|
|
assert_eq!(app.views[0].forms.committed.as_ref().unwrap().text, "Queued draft");
|
|
assert!(
|
|
matches!(&app.views[0].pending_edit, Some(Edit::SetFieldValue { name, value: FieldValue::Text(text) }) if name == "name" && text == "Queued draft")
|
|
);
|
|
assert_eq!(app.active_ids(), Some((0, source)));
|
|
assert_eq!(app.toast.as_ref().unwrap().0, pdfcraft_engine::EditError::NoDocument.to_string());
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod content_page_tests {
|
|
use super::content_page;
|
|
use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget};
|
|
|
|
/// Single-page content edits keep every other page's rasters; anything else still
|
|
/// invalidates the whole document.
|
|
#[test]
|
|
fn single_page_content_edits_map_to_their_page() {
|
|
let moved = Edit::MoveObjects { page: 3, objects: vec![ObjectTarget { kind: ObjectKind::Text, index: 0 }], offset: [1.0, 0.0] };
|
|
assert_eq!(content_page(&moved), Some(3));
|
|
assert_eq!(content_page(&Edit::RotatePages { pages: vec![0], degrees: 90 }), None);
|
|
assert_eq!(content_page(&Edit::SetInfo { key: "Title".into(), value: "x".into() }), None);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod attention_tests {
|
|
use super::CloseRequest;
|
|
use crate::PdfCraftApp;
|
|
|
|
/// The attention requests one frame of `attend_save_prompt` sends, with the window `focused`
|
|
/// or not.
|
|
fn attend(app: &mut PdfCraftApp, ctx: &egui::Context, focused: bool) -> Vec<egui::UserAttentionType> {
|
|
let mut out = ctx.run_ui(egui::RawInput { focused, ..Default::default() }, |ui| app.attend_save_prompt(ui.ctx()));
|
|
// The frame's font texture isn't uploaded anywhere in a unit test.
|
|
out.textures_delta.clear();
|
|
out.viewport_output
|
|
.values()
|
|
.flat_map(|v| v.commands.iter())
|
|
.filter_map(|c| if let egui::ViewportCommand::RequestUserAttention(a) = c { Some(*a) } else { None })
|
|
.collect()
|
|
}
|
|
|
|
/// A save that finishes after a newer "save changes?" question opened in front, and after the
|
|
/// user went to another app, asks for no attention: the user saw that question.
|
|
#[test]
|
|
fn a_save_finishing_behind_a_newer_question_the_user_saw_asks_for_nothing() {
|
|
let ctx = egui::Context::default();
|
|
let mut app = PdfCraftApp::new();
|
|
for name in ["a.pdf", "b.pdf", "c.pdf"] {
|
|
let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap();
|
|
app.open_bytes(name, None, bytes.as_ref().clone()).unwrap();
|
|
app.active = Some(app.views.len() - 1);
|
|
assert!(app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }));
|
|
}
|
|
// a.pdf was answered with Save while quitting. While that save is pending, the user closes
|
|
// c.pdf's tab, and its question opens in front.
|
|
let c = app.views[2].id;
|
|
app.close_request = Some(CloseRequest::Tab(c));
|
|
assert!(attend(&mut app, &ctx, true).is_empty());
|
|
// a.pdf's save finishes once the user has gone to another app.
|
|
app.close_and_continue(&ctx, 0, CloseRequest::Quit);
|
|
assert_eq!(app.close_request, Some(CloseRequest::Tab(c)), "the newer question stands");
|
|
assert!(attend(&mut app, &ctx, false).is_empty(), "the user has seen it");
|
|
}
|
|
}
|