mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 03:58:45 -06:00
refactor(ui-egui): split editing.rs into editing/
New modules: save (SaveTarget, PendingOverwrite, save methods, atomic writers), close (CloseRequest, close/quit methods).
This commit is contained in:
+13
-506
@@ -6,37 +6,21 @@ use pdfcraft_platform::staging::{StagingName, staging_suffixes};
|
||||
|
||||
use crate::PdfCraftApp;
|
||||
|
||||
/// What the user was doing when we asked whether to save.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CloseRequest {
|
||||
/// Close this document's tab. By id, not tab index: tabs can close and shift while a save
|
||||
/// panel is open.
|
||||
Tab(pdfcraft_engine::DocId),
|
||||
/// Quit the application once every dirty document is resolved.
|
||||
Quit,
|
||||
/// File ▸ Close all: like Quit, but the application stays open.
|
||||
All,
|
||||
}
|
||||
mod close;
|
||||
mod save;
|
||||
|
||||
/// A save held back by [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged): the file
|
||||
/// on disk is another version than the document came from (#2166).
|
||||
pub use close::CloseRequest;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub struct PendingOverwrite {
|
||||
pub id: pdfcraft_engine::DocId,
|
||||
pub dest: String,
|
||||
target: SaveTarget,
|
||||
/// What was to run once the document is written (close its tab, quit).
|
||||
after: Box<dyn FnOnce(&mut PdfCraftApp) + Send>,
|
||||
}
|
||||
|
||||
/// Where Save writes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SaveTarget {
|
||||
/// The file's own path; asks for one if the document has none (e.g. opened from bytes).
|
||||
InPlace,
|
||||
/// Always ask for a new location.
|
||||
As,
|
||||
}
|
||||
pub(crate) use save::in_document_folder;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use save::PendingOverwrite;
|
||||
pub use save::{SaveTarget, write_atomically};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) use save::download;
|
||||
#[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 {
|
||||
@@ -644,392 +628,6 @@ impl PdfCraftApp {
|
||||
|| new_text_typed(v)
|
||||
|| v.line_editor.as_ref().map_or_else(|| false, |ed| ed.has_unsaved_text())
|
||||
}
|
||||
|
||||
/// Save the active document. Returns `true` if it was written.
|
||||
pub fn save_active(&mut self, target: SaveTarget) -> bool {
|
||||
match self.active {
|
||||
Some(i) => self.save_view(i, target),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the document shown in tab `index`. Returns `true` if it was written now. When it has
|
||||
/// to ask where (a new document, or Save As) it returns `false` and saves on a later frame,
|
||||
/// once the user has chosen.
|
||||
pub fn save_view(&mut self, index: usize, target: SaveTarget) -> bool {
|
||||
self.save_then(index, target, |_| {})
|
||||
}
|
||||
|
||||
/// [`Self::save_view`], then `after` once the document is written: now, or on a later frame
|
||||
/// when the user had to choose where. `after` never runs when the save fails or is cancelled.
|
||||
pub(crate) fn save_then(&mut self, index: usize, target: SaveTarget, after: impl FnOnce(&mut Self) + Send + 'static) -> bool {
|
||||
// What's typed in a field is part of what's saved (#166).
|
||||
if let Some(id) = self.views.get(index).map(|v| v.id)
|
||||
&& !self.commit_typing_in(id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(id) = self.views.get(index).map(|v| v.id) else { return false };
|
||||
let Some(doc) = self.session.get(id) else { return false };
|
||||
let (name, path) = (doc.name.clone(), doc.path.clone());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let doc_path = path.clone();
|
||||
let destination = match (target, path, &self.save_override) {
|
||||
(_, _, Some(p)) => p.clone(),
|
||||
// An email attachment's temporary copy is no place to keep work: ask, as for a new
|
||||
// document (`is_mail_temp_copy`).
|
||||
(SaveTarget::InPlace, Some(p), _) if !is_mail_temp_copy(&p) => p,
|
||||
_ => {
|
||||
let name = if name.to_ascii_lowercase().ends_with(".pdf") { name } else { format!("{name}.pdf") };
|
||||
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name);
|
||||
let dialog = in_document_folder(dialog, doc_path.as_deref());
|
||||
// The bytes are taken once the user has chosen, so edits made meanwhile are saved too.
|
||||
self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| {
|
||||
app.save_doc_to_then(id, &dest.to_string_lossy(), target, Box::new(after));
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
self.save_doc_to_then(id, &destination, target, Box::new(after))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
if !self.flatten_fill_sign_for_save(id) {
|
||||
return false;
|
||||
}
|
||||
let bytes = match self.session.save_bytes(id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// An embedding page that asked for saves (`?host=parent`) gets the bytes; otherwise
|
||||
// the browser downloads them.
|
||||
let to_host = self.host_save.is_some();
|
||||
let delivered = match &self.host_save {
|
||||
Some(save) => save(&name, &bytes, matches!(target, SaveTarget::As)),
|
||||
None => download(&name, &bytes),
|
||||
};
|
||||
match delivered {
|
||||
Ok(()) => {
|
||||
let _ = self.session.mark_saved(id, bytes, None);
|
||||
if let Some(doc) = self.session.get(id)
|
||||
&& let Some(view) = self.views.get_mut(index)
|
||||
{
|
||||
view.document_changed(&doc.info);
|
||||
}
|
||||
// The host reports where the file went (and any failure) itself.
|
||||
if !to_host {
|
||||
self.notify_fmt("Downloaded {name}", &[("name", &name)]);
|
||||
}
|
||||
after(self);
|
||||
true
|
||||
}
|
||||
Err(e) if to_host => {
|
||||
self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e)]);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
self.notify_fmt("Couldn't download {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::save_doc_to`], then `after`. When `dest` is the document's own file and it is
|
||||
/// another version on disk than the one the document came from (something else wrote it
|
||||
/// since the open or the last save, #2166), it asks first: the save waits in
|
||||
/// `pending_overwrite` behind [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged).
|
||||
/// Returns `true` if written now.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget, after: Box<dyn FnOnce(&mut Self) + Send>) -> bool {
|
||||
let own = self.session.get(id).and_then(|d| d.path.as_deref()).is_some_and(|p| crate::same_file(p, dest));
|
||||
if own && self.watch.disk_differs(id, dest, self.session.get(id).map(|d| d.saved_bytes())) {
|
||||
self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), target, after });
|
||||
self.dialog = Some(crate::Dialog::OverwriteChanged);
|
||||
return false;
|
||||
}
|
||||
let saved = self.save_doc_to(id, dest, target);
|
||||
if saved {
|
||||
after(self);
|
||||
}
|
||||
saved
|
||||
}
|
||||
|
||||
/// The dialog's Save anyway: write over the changed file.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn overwrite_changed_file(&mut self) {
|
||||
let Some(p) = self.pending_overwrite.take() else { return };
|
||||
if self.save_doc_to(p.id, &p.dest, p.target) {
|
||||
(p.after)(self);
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog's Save as…: leave the file on disk as it is and ask where to put this version.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn save_changed_file_elsewhere(&mut self) {
|
||||
let Some(p) = self.pending_overwrite.take() else { return };
|
||||
if let Some(index) = self.views.iter().position(|v| v.id == p.id) {
|
||||
self.save_then(index, SaveTarget::As, p.after);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write document `id` to `dest` and make it the document's file. Returns `true` if written.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget) -> bool {
|
||||
// Save As answers on a later frame: commit what was typed meanwhile, too (#166), even
|
||||
// if another tab is active by then.
|
||||
if !self.commit_typing_in(id) || !self.flatten_fill_sign_for_save(id) {
|
||||
return false;
|
||||
}
|
||||
let Some((name, full)) = self.session.get(id).map(|d| {
|
||||
// Save As to another file is a clean copy, without the earlier revisions that still
|
||||
// hold deleted pages or replaced text (#2165). A signed document stays incremental so
|
||||
// its signatures keep covering the file.
|
||||
let full = target == SaveTarget::As
|
||||
&& !d.path.as_deref().is_some_and(|p| crate::same_file(p, dest))
|
||||
&& d.read_only_reason.is_none()
|
||||
&& !d.is_signed();
|
||||
(d.name.clone(), full)
|
||||
}) else {
|
||||
self.notify_tr("The document was closed before it could be saved.");
|
||||
return false;
|
||||
};
|
||||
let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) };
|
||||
let bytes = match bytes.and_then(|b| self.session.check_save(id, &b).map(|()| b)) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if let Err(e) = write_atomically(dest, &bytes) {
|
||||
self.notify_fmt("Couldn't save {name}: {e}", &[("name", dest), ("e", &e.to_string())]);
|
||||
return false;
|
||||
}
|
||||
// Our own write is not a change on disk to reload (#431).
|
||||
self.watch.known_now(id, dest);
|
||||
match self.session.mark_saved(id, bytes, Some(dest.to_string())) {
|
||||
Ok(()) => {
|
||||
self.forget_recovery(id);
|
||||
if let Some(doc) = self.session.get(id)
|
||||
&& let Some(view) = self.views.iter_mut().find(|v| v.id == id)
|
||||
{
|
||||
view.document_changed(&doc.info);
|
||||
// Saving over a version that changed on disk keeps this one: nothing to ask.
|
||||
view.disk_changed = false;
|
||||
}
|
||||
self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]);
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::macos_association::repair(std::path::Path::new(dest));
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
self.notify_fmt("Saved, but reopening failed: {e}", &[("e", &e.to_string())]);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close a tab, asking first if it has unsaved changes.
|
||||
pub fn request_close_tab(&mut self, index: usize) {
|
||||
// Text typed into a field counts as an unsaved change.
|
||||
if self.has_unsaved_work(index)
|
||||
&& let Some(id) = self.views.get(index).map(|v| v.id)
|
||||
{
|
||||
self.close_request = Some(CloseRequest::Tab(id));
|
||||
} else {
|
||||
self.close_tab(index);
|
||||
}
|
||||
}
|
||||
|
||||
/// File ▸ Close all: clean documents close at once; each one with unsaved changes asks.
|
||||
pub fn close_all(&mut self) {
|
||||
for i in (0..self.views.len()).rev() {
|
||||
if !self.has_unsaved_work(i) {
|
||||
self.close_tab(i);
|
||||
}
|
||||
}
|
||||
if self.first_dirty().is_some() {
|
||||
self.close_request = Some(CloseRequest::All);
|
||||
}
|
||||
}
|
||||
|
||||
/// File ▸ Revert (after the confirmation).
|
||||
pub fn revert_active(&mut self) {
|
||||
let Some((_, id)) = self.active_ids() else { return };
|
||||
match self.session.revert(id) {
|
||||
Ok(()) => {
|
||||
if let Some(i) = self.active
|
||||
&& let Some(d) = self.session.get(id)
|
||||
{
|
||||
let view = &mut self.views[i];
|
||||
view.document_changed(&d.info);
|
||||
// Revert discards this source's uncommitted typing as well as model edits.
|
||||
// Otherwise a later Save could write a rejected draft back into the file.
|
||||
view.forms.focus = None;
|
||||
view.forms.committed = None;
|
||||
view.forms.unbaked.clear();
|
||||
view.pending_edit = None;
|
||||
}
|
||||
self.notify_tr("Reverted to the last saved version");
|
||||
}
|
||||
Err(e) => self.notify_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The first tab with unsaved changes.
|
||||
pub fn first_dirty(&self) -> Option<usize> {
|
||||
(0..self.views.len()).find(|&i| self.has_unsaved_work(i))
|
||||
}
|
||||
|
||||
/// Answer the save prompt: `Some(true)` save, `Some(false)` discard, `None` cancel.
|
||||
pub fn resolve_close(&mut self, ctx: &egui::Context, choice: Option<bool>) {
|
||||
let Some(req) = self.close_request.take() else { return };
|
||||
// The next question (another dirty document) starts on Save again.
|
||||
self.save_prompt_button = Default::default();
|
||||
let index = match req {
|
||||
CloseRequest::Tab(id) => match self.views.iter().position(|v| v.id == id) {
|
||||
Some(i) => i,
|
||||
None => return, // already closed
|
||||
},
|
||||
CloseRequest::Quit | CloseRequest::All => match self.first_dirty() {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
if req == CloseRequest::Quit {
|
||||
self.quit(ctx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
// Quitting closes the unsaved tabs one by one (and brings each forward to save it):
|
||||
// remember what was open, and which tab was active, before the first one goes (#442).
|
||||
if req == CloseRequest::Quit {
|
||||
match choice {
|
||||
Some(_) => self.note_quit_session(),
|
||||
None => self.forget_quit_session(),
|
||||
}
|
||||
}
|
||||
match choice {
|
||||
None => {} // cancelled: nothing closes
|
||||
Some(false) => self.close_and_continue(ctx, index, req),
|
||||
Some(true) => {
|
||||
let Some(id) = self.views.get(index).map(|v| v.id) else { return };
|
||||
// Bring the document forward: what's typed in its form is committed and saved
|
||||
// with it, and a field that rejects the value is shown where the user can fix it.
|
||||
self.active = Some(index);
|
||||
// Close only once the save has actually been written, which may be on a later
|
||||
// frame when the user has to choose where. A failed or cancelled save keeps the
|
||||
// document open.
|
||||
let ctx = ctx.clone();
|
||||
self.save_then(index, SaveTarget::InPlace, move |app| {
|
||||
if let Some(i) = app.views.iter().position(|v| v.id == id) {
|
||||
app.close_and_continue(&ctx, i, req);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close tab `index` for a close request, then ask about the next dirty document, or quit.
|
||||
fn close_and_continue(&mut self, ctx: &egui::Context, index: usize, req: CloseRequest) {
|
||||
self.close_tab(index);
|
||||
// A newer prompt wins: the user may have started another close while a save was pending,
|
||||
// and seen its question, which keeps counting as seen.
|
||||
if (req == CloseRequest::Quit || req == CloseRequest::All) && self.close_request.is_none() {
|
||||
match self.first_dirty() {
|
||||
Some(_) => {
|
||||
// A question about the next document is a new one.
|
||||
self.save_prompt_seen = false;
|
||||
self.close_request = Some(req);
|
||||
}
|
||||
None if req == CloseRequest::Quit => self.quit(ctx),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request a normal application exit, preserving every unsaved document until answered.
|
||||
/// Returns false while the existing save/discard/cancel prompt needs a decision.
|
||||
pub(crate) fn request_quit(&mut self, ctx: &egui::Context) -> bool {
|
||||
if self.first_dirty().is_some() {
|
||||
self.close_request = Some(CloseRequest::Quit);
|
||||
ctx.request_repaint();
|
||||
false
|
||||
} else {
|
||||
self.shutdown_recovery();
|
||||
self.quit(ctx);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn quit(&mut self, ctx: &egui::Context) {
|
||||
self.allow_quit = true;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
|
||||
/// Publish whether any document has unsaved work, for the browser's reload warning (#812).
|
||||
pub(crate) fn sync_unsaved_flag(&self) {
|
||||
self.unsaved_flag.store(self.first_dirty().is_some(), std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Intercept window close while documents have unsaved changes.
|
||||
pub(crate) fn guard_quit(&mut self, ctx: &egui::Context) {
|
||||
// egui's default quit shortcut ignores extra Alt/Shift, including Ctrl+Alt+Q for '@'.
|
||||
ctx.options_mut(|o| o.quit_shortcuts.clear());
|
||||
let shortcut = ctx.input(|i| {
|
||||
i.events.iter().any(|e| {
|
||||
matches!(e, egui::Event::Key { key: egui::Key::Q, pressed: true, modifiers, .. }
|
||||
if modifiers.matches_exact(egui::Modifiers::COMMAND))
|
||||
})
|
||||
});
|
||||
if shortcut {
|
||||
self.request_quit(ctx);
|
||||
}
|
||||
self.sync_unsaved_flag();
|
||||
if !ctx.input(|i| i.viewport().close_requested()) {
|
||||
return;
|
||||
}
|
||||
// `first_dirty` counts text typed into a field as an unsaved change.
|
||||
if !self.allow_quit && self.first_dirty().is_some() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
self.close_request = Some(CloseRequest::Quit);
|
||||
} else {
|
||||
// A clean quit: nothing is left to recover.
|
||||
self.shutdown_recovery();
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for the user's attention when the "save changes?" question opens while PdfCraft is in
|
||||
/// the background (Quit in the Dock menu, logging out, or an agent quitting), and withdraw the
|
||||
/// request once the user comes back or the question is answered.
|
||||
///
|
||||
/// The question holds up the quit the user asked for, so the request is critical, as AppKit
|
||||
/// makes it for a modal panel that an inactive app presents: the Dock icon bounces until
|
||||
/// PdfCraft is active, Windows flashes the taskbar button until the window is in front, and
|
||||
/// X11 sets the urgency hint, which stays until it is reset. A question that opens in front
|
||||
/// asks nothing, and neither does leaving it open: the user has seen it. Each document asked
|
||||
/// about when quitting is a new question, so one that opens in the background asks even after
|
||||
/// a question the user saw; a request that still stands carries over to it.
|
||||
pub(crate) fn attend_save_prompt(&mut self, ctx: &egui::Context) {
|
||||
let open = self.close_request.is_some();
|
||||
let focused = ctx.input(|i| i.focused);
|
||||
if self.save_prompt_attention {
|
||||
if focused || !open {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Reset));
|
||||
self.save_prompt_attention = false;
|
||||
}
|
||||
} else if open && !self.save_prompt_seen && !focused {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Critical));
|
||||
self.save_prompt_attention = true;
|
||||
}
|
||||
self.save_prompt_seen = open;
|
||||
}
|
||||
}
|
||||
|
||||
/// The single page a content edit changes (`None` for other edits). Like comment edits,
|
||||
@@ -1084,97 +682,6 @@ fn is_form_edit(edit: &Edit) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether `path` is the temporary copy an email client opened an attachment from, so that Save
|
||||
/// asks where to keep the document instead of writing to it.
|
||||
///
|
||||
/// Outlook opens an attachment from its own cache: `INetCache\Content.Outlook\<random>\` for
|
||||
/// classic Outlook (`Temporary Internet Files\Content.Outlook\` on older Windows), and the
|
||||
/// `Packages\Microsoft.OutlookForWindows_<id>` package folder for the new Outlook. The patterns
|
||||
/// follow those layouts, so an ordinary folder that merely shares a name doesn't match. Saving there either fails,
|
||||
/// because Outlook can still hold the file, or appears to work while the edits stay out of the
|
||||
/// email and are deleted with the cache. Apple Mail opens one from `Mail Downloads/<UUID>/` in
|
||||
/// its sandbox container, where other apps can't write, so saving there fails (#2751). Matched on
|
||||
/// either separator and in any case, so it can be tested on every platform.
|
||||
pub(crate) fn is_mail_temp_copy(path: &str) -> bool {
|
||||
let p = path.replace('\\', "/").to_ascii_lowercase();
|
||||
// Outlook's cache holds `<marker><random folder>/<file>`: exactly one folder, then the file.
|
||||
let in_outlook_cache = |marker: &str| {
|
||||
p.split_once(marker)
|
||||
.is_some_and(|(_, rest)| matches!(rest.split('/').collect::<Vec<_>>()[..], [dir, file] if !dir.is_empty() && !file.is_empty()))
|
||||
};
|
||||
in_outlook_cache("/inetcache/content.outlook/")
|
||||
|| in_outlook_cache("/temporary internet files/content.outlook/")
|
||||
|| p.contains("/packages/microsoft.outlookforwindows_")
|
||||
|| p.contains("/library/containers/com.apple.mail/data/library/mail downloads/")
|
||||
}
|
||||
|
||||
/// The folder a Save As style dialog should open in for a document stored at `path`: the
|
||||
/// document's own folder, as Acrobat and Foxit do, instead of whichever folder the system picks.
|
||||
/// `None` (keep the system's choice) for a new document, a path with no parent, or an email
|
||||
/// attachment's temporary copy (`is_mail_temp_copy`), which is no place to keep work. Also right
|
||||
/// for UNC paths on Windows (`\\server\share\dir\f.pdf` gives `\\server\share\dir`).
|
||||
pub(crate) fn save_dialog_folder(path: Option<&str>) -> Option<std::path::PathBuf> {
|
||||
let path = path.filter(|p| !is_mail_temp_copy(p))?;
|
||||
let parent = std::path::Path::new(path).parent()?;
|
||||
(!parent.as_os_str().is_empty()).then(|| parent.to_path_buf())
|
||||
}
|
||||
|
||||
/// `dialog` opened in the folder of the document stored at `path` ([`save_dialog_folder`]).
|
||||
pub(crate) fn in_document_folder(dialog: rfd::AsyncFileDialog, path: Option<&str>) -> rfd::AsyncFileDialog {
|
||||
match save_dialog_folder(path) {
|
||||
Some(dir) => dialog.set_directory(dir),
|
||||
None => dialog,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write via a temporary file in the same directory and rename over the target, so a crash or
|
||||
/// full disk never leaves a half-written PDF where the original was.
|
||||
pub fn write_atomically(path: &str, bytes: &[u8]) -> std::io::Result<()> {
|
||||
write_atomically_with(path, bytes, staging_suffixes())
|
||||
}
|
||||
|
||||
/// [`write_atomically`], trying the staging names that `suffixes` give.
|
||||
fn write_atomically_with(path: &str, bytes: &[u8], suffixes: impl IntoIterator<Item = u64>) -> std::io::Result<()> {
|
||||
write_atomically_syncing(path, bytes, suffixes, std::fs::File::sync_all)
|
||||
}
|
||||
|
||||
/// [`write_atomically_with`], flushing the staging file with `sync_all` (the standard library's
|
||||
/// in the app, a failing one in tests). The writer itself is the platform crate's, shared with
|
||||
/// the tools and the CLI (#2166); the desktop app keeps its staging-name form.
|
||||
fn write_atomically_syncing(
|
||||
path: &str,
|
||||
bytes: &[u8],
|
||||
suffixes: impl IntoIterator<Item = u64>,
|
||||
sync_all: impl FnOnce(&std::fs::File) -> std::io::Result<()>,
|
||||
) -> std::io::Result<()> {
|
||||
pdfcraft_platform::staging::write_atomically_with(std::path::Path::new(path), bytes, StagingName::TagThenSuffix, suffixes, sync_all)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn short_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
/// Offer bytes as a browser download.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
use wasm_bindgen::JsCast;
|
||||
let err = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let array = js_sys::Uint8Array::from(bytes);
|
||||
let parts = js_sys::Array::of1(&array);
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type("application/pdf");
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(err)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(err)?;
|
||||
let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(err)?.dyn_into().map_err(|_| "anchor")?;
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
a.click();
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Carry out a Bookmarks-panel action as an undoable edit.
|
||||
pub fn bookmark_action(&mut self, action: crate::panels::BmAction) {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
use super::save::SaveTarget;
|
||||
use super::*;
|
||||
|
||||
/// What the user was doing when we asked whether to save.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CloseRequest {
|
||||
/// Close this document's tab. By id, not tab index: tabs can close and shift while a save
|
||||
/// panel is open.
|
||||
Tab(pdfcraft_engine::DocId),
|
||||
/// Quit the application once every dirty document is resolved.
|
||||
Quit,
|
||||
/// File ▸ Close all: like Quit, but the application stays open.
|
||||
All,
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Close a tab, asking first if it has unsaved changes.
|
||||
pub fn request_close_tab(&mut self, index: usize) {
|
||||
// Text typed into a field counts as an unsaved change.
|
||||
if self.has_unsaved_work(index)
|
||||
&& let Some(id) = self.views.get(index).map(|v| v.id)
|
||||
{
|
||||
self.close_request = Some(CloseRequest::Tab(id));
|
||||
} else {
|
||||
self.close_tab(index);
|
||||
}
|
||||
}
|
||||
|
||||
/// File ▸ Close all: clean documents close at once; each one with unsaved changes asks.
|
||||
pub fn close_all(&mut self) {
|
||||
for i in (0..self.views.len()).rev() {
|
||||
if !self.has_unsaved_work(i) {
|
||||
self.close_tab(i);
|
||||
}
|
||||
}
|
||||
if self.first_dirty().is_some() {
|
||||
self.close_request = Some(CloseRequest::All);
|
||||
}
|
||||
}
|
||||
|
||||
/// File ▸ Revert (after the confirmation).
|
||||
pub fn revert_active(&mut self) {
|
||||
let Some((_, id)) = self.active_ids() else { return };
|
||||
match self.session.revert(id) {
|
||||
Ok(()) => {
|
||||
if let Some(i) = self.active
|
||||
&& let Some(d) = self.session.get(id)
|
||||
{
|
||||
let view = &mut self.views[i];
|
||||
view.document_changed(&d.info);
|
||||
// Revert discards this source's uncommitted typing as well as model edits.
|
||||
// Otherwise a later Save could write a rejected draft back into the file.
|
||||
view.forms.focus = None;
|
||||
view.forms.committed = None;
|
||||
view.forms.unbaked.clear();
|
||||
view.pending_edit = None;
|
||||
}
|
||||
self.notify_tr("Reverted to the last saved version");
|
||||
}
|
||||
Err(e) => self.notify_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The first tab with unsaved changes.
|
||||
pub fn first_dirty(&self) -> Option<usize> {
|
||||
(0..self.views.len()).find(|&i| self.has_unsaved_work(i))
|
||||
}
|
||||
|
||||
/// Answer the save prompt: `Some(true)` save, `Some(false)` discard, `None` cancel.
|
||||
pub fn resolve_close(&mut self, ctx: &egui::Context, choice: Option<bool>) {
|
||||
let Some(req) = self.close_request.take() else { return };
|
||||
// The next question (another dirty document) starts on Save again.
|
||||
self.save_prompt_button = Default::default();
|
||||
let index = match req {
|
||||
CloseRequest::Tab(id) => match self.views.iter().position(|v| v.id == id) {
|
||||
Some(i) => i,
|
||||
None => return, // already closed
|
||||
},
|
||||
CloseRequest::Quit | CloseRequest::All => match self.first_dirty() {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
if req == CloseRequest::Quit {
|
||||
self.quit(ctx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
// Quitting closes the unsaved tabs one by one (and brings each forward to save it):
|
||||
// remember what was open, and which tab was active, before the first one goes (#442).
|
||||
if req == CloseRequest::Quit {
|
||||
match choice {
|
||||
Some(_) => self.note_quit_session(),
|
||||
None => self.forget_quit_session(),
|
||||
}
|
||||
}
|
||||
match choice {
|
||||
None => {} // cancelled: nothing closes
|
||||
Some(false) => self.close_and_continue(ctx, index, req),
|
||||
Some(true) => {
|
||||
let Some(id) = self.views.get(index).map(|v| v.id) else { return };
|
||||
// Bring the document forward: what's typed in its form is committed and saved
|
||||
// with it, and a field that rejects the value is shown where the user can fix it.
|
||||
self.active = Some(index);
|
||||
// Close only once the save has actually been written, which may be on a later
|
||||
// frame when the user has to choose where. A failed or cancelled save keeps the
|
||||
// document open.
|
||||
let ctx = ctx.clone();
|
||||
self.save_then(index, SaveTarget::InPlace, move |app| {
|
||||
if let Some(i) = app.views.iter().position(|v| v.id == id) {
|
||||
app.close_and_continue(&ctx, i, req);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close tab `index` for a close request, then ask about the next dirty document, or quit.
|
||||
pub(super) fn close_and_continue(&mut self, ctx: &egui::Context, index: usize, req: CloseRequest) {
|
||||
self.close_tab(index);
|
||||
// A newer prompt wins: the user may have started another close while a save was pending,
|
||||
// and seen its question, which keeps counting as seen.
|
||||
if (req == CloseRequest::Quit || req == CloseRequest::All) && self.close_request.is_none() {
|
||||
match self.first_dirty() {
|
||||
Some(_) => {
|
||||
// A question about the next document is a new one.
|
||||
self.save_prompt_seen = false;
|
||||
self.close_request = Some(req);
|
||||
}
|
||||
None if req == CloseRequest::Quit => self.quit(ctx),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request a normal application exit, preserving every unsaved document until answered.
|
||||
/// Returns false while the existing save/discard/cancel prompt needs a decision.
|
||||
pub(crate) fn request_quit(&mut self, ctx: &egui::Context) -> bool {
|
||||
if self.first_dirty().is_some() {
|
||||
self.close_request = Some(CloseRequest::Quit);
|
||||
ctx.request_repaint();
|
||||
false
|
||||
} else {
|
||||
self.shutdown_recovery();
|
||||
self.quit(ctx);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn quit(&mut self, ctx: &egui::Context) {
|
||||
self.allow_quit = true;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
|
||||
/// Publish whether any document has unsaved work, for the browser's reload warning (#812).
|
||||
pub(crate) fn sync_unsaved_flag(&self) {
|
||||
self.unsaved_flag.store(self.first_dirty().is_some(), std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Intercept window close while documents have unsaved changes.
|
||||
pub(crate) fn guard_quit(&mut self, ctx: &egui::Context) {
|
||||
// egui's default quit shortcut ignores extra Alt/Shift, including Ctrl+Alt+Q for '@'.
|
||||
ctx.options_mut(|o| o.quit_shortcuts.clear());
|
||||
let shortcut = ctx.input(|i| {
|
||||
i.events.iter().any(|e| {
|
||||
matches!(e, egui::Event::Key { key: egui::Key::Q, pressed: true, modifiers, .. }
|
||||
if modifiers.matches_exact(egui::Modifiers::COMMAND))
|
||||
})
|
||||
});
|
||||
if shortcut {
|
||||
self.request_quit(ctx);
|
||||
}
|
||||
self.sync_unsaved_flag();
|
||||
if !ctx.input(|i| i.viewport().close_requested()) {
|
||||
return;
|
||||
}
|
||||
// `first_dirty` counts text typed into a field as an unsaved change.
|
||||
if !self.allow_quit && self.first_dirty().is_some() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
self.close_request = Some(CloseRequest::Quit);
|
||||
} else {
|
||||
// A clean quit: nothing is left to recover.
|
||||
self.shutdown_recovery();
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for the user's attention when the "save changes?" question opens while PdfCraft is in
|
||||
/// the background (Quit in the Dock menu, logging out, or an agent quitting), and withdraw the
|
||||
/// request once the user comes back or the question is answered.
|
||||
///
|
||||
/// The question holds up the quit the user asked for, so the request is critical, as AppKit
|
||||
/// makes it for a modal panel that an inactive app presents: the Dock icon bounces until
|
||||
/// PdfCraft is active, Windows flashes the taskbar button until the window is in front, and
|
||||
/// X11 sets the urgency hint, which stays until it is reset. A question that opens in front
|
||||
/// asks nothing, and neither does leaving it open: the user has seen it. Each document asked
|
||||
/// about when quitting is a new question, so one that opens in the background asks even after
|
||||
/// a question the user saw; a request that still stands carries over to it.
|
||||
pub(crate) fn attend_save_prompt(&mut self, ctx: &egui::Context) {
|
||||
let open = self.close_request.is_some();
|
||||
let focused = ctx.input(|i| i.focused);
|
||||
if self.save_prompt_attention {
|
||||
if focused || !open {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Reset));
|
||||
self.save_prompt_attention = false;
|
||||
}
|
||||
} else if open && !self.save_prompt_seen && !focused {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Critical));
|
||||
self.save_prompt_attention = true;
|
||||
}
|
||||
self.save_prompt_seen = open;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
use super::*;
|
||||
|
||||
/// A save held back by [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged): the file
|
||||
/// on disk is another version than the document came from (#2166).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub struct PendingOverwrite {
|
||||
pub id: pdfcraft_engine::DocId,
|
||||
pub dest: String,
|
||||
target: SaveTarget,
|
||||
/// What was to run once the document is written (close its tab, quit).
|
||||
after: Box<dyn FnOnce(&mut PdfCraftApp) + Send>,
|
||||
}
|
||||
|
||||
/// Where Save writes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SaveTarget {
|
||||
/// The file's own path; asks for one if the document has none (e.g. opened from bytes).
|
||||
InPlace,
|
||||
/// Always ask for a new location.
|
||||
As,
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Save the active document. Returns `true` if it was written.
|
||||
pub fn save_active(&mut self, target: SaveTarget) -> bool {
|
||||
match self.active {
|
||||
Some(i) => self.save_view(i, target),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the document shown in tab `index`. Returns `true` if it was written now. When it has
|
||||
/// to ask where (a new document, or Save As) it returns `false` and saves on a later frame,
|
||||
/// once the user has chosen.
|
||||
pub fn save_view(&mut self, index: usize, target: SaveTarget) -> bool {
|
||||
self.save_then(index, target, |_| {})
|
||||
}
|
||||
|
||||
/// [`Self::save_view`], then `after` once the document is written: now, or on a later frame
|
||||
/// when the user had to choose where. `after` never runs when the save fails or is cancelled.
|
||||
pub(crate) fn save_then(&mut self, index: usize, target: SaveTarget, after: impl FnOnce(&mut Self) + Send + 'static) -> bool {
|
||||
// What's typed in a field is part of what's saved (#166).
|
||||
if let Some(id) = self.views.get(index).map(|v| v.id)
|
||||
&& !self.commit_typing_in(id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(id) = self.views.get(index).map(|v| v.id) else { return false };
|
||||
let Some(doc) = self.session.get(id) else { return false };
|
||||
let (name, path) = (doc.name.clone(), doc.path.clone());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let doc_path = path.clone();
|
||||
let destination = match (target, path, &self.save_override) {
|
||||
(_, _, Some(p)) => p.clone(),
|
||||
// An email attachment's temporary copy is no place to keep work: ask, as for a new
|
||||
// document (`is_mail_temp_copy`).
|
||||
(SaveTarget::InPlace, Some(p), _) if !is_mail_temp_copy(&p) => p,
|
||||
_ => {
|
||||
let name = if name.to_ascii_lowercase().ends_with(".pdf") { name } else { format!("{name}.pdf") };
|
||||
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name);
|
||||
let dialog = in_document_folder(dialog, doc_path.as_deref());
|
||||
// The bytes are taken once the user has chosen, so edits made meanwhile are saved too.
|
||||
self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| {
|
||||
app.save_doc_to_then(id, &dest.to_string_lossy(), target, Box::new(after));
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
self.save_doc_to_then(id, &destination, target, Box::new(after))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
if !self.flatten_fill_sign_for_save(id) {
|
||||
return false;
|
||||
}
|
||||
let bytes = match self.session.save_bytes(id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// An embedding page that asked for saves (`?host=parent`) gets the bytes; otherwise
|
||||
// the browser downloads them.
|
||||
let to_host = self.host_save.is_some();
|
||||
let delivered = match &self.host_save {
|
||||
Some(save) => save(&name, &bytes, matches!(target, SaveTarget::As)),
|
||||
None => download(&name, &bytes),
|
||||
};
|
||||
match delivered {
|
||||
Ok(()) => {
|
||||
let _ = self.session.mark_saved(id, bytes, None);
|
||||
if let Some(doc) = self.session.get(id)
|
||||
&& let Some(view) = self.views.get_mut(index)
|
||||
{
|
||||
view.document_changed(&doc.info);
|
||||
}
|
||||
// The host reports where the file went (and any failure) itself.
|
||||
if !to_host {
|
||||
self.notify_fmt("Downloaded {name}", &[("name", &name)]);
|
||||
}
|
||||
after(self);
|
||||
true
|
||||
}
|
||||
Err(e) if to_host => {
|
||||
self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e)]);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
self.notify_fmt("Couldn't download {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::save_doc_to`], then `after`. When `dest` is the document's own file and it is
|
||||
/// another version on disk than the one the document came from (something else wrote it
|
||||
/// since the open or the last save, #2166), it asks first: the save waits in
|
||||
/// `pending_overwrite` behind [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged).
|
||||
/// Returns `true` if written now.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget, after: Box<dyn FnOnce(&mut Self) + Send>) -> bool {
|
||||
let own = self.session.get(id).and_then(|d| d.path.as_deref()).is_some_and(|p| crate::same_file(p, dest));
|
||||
if own && self.watch.disk_differs(id, dest, self.session.get(id).map(|d| d.saved_bytes())) {
|
||||
self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), target, after });
|
||||
self.dialog = Some(crate::Dialog::OverwriteChanged);
|
||||
return false;
|
||||
}
|
||||
let saved = self.save_doc_to(id, dest, target);
|
||||
if saved {
|
||||
after(self);
|
||||
}
|
||||
saved
|
||||
}
|
||||
|
||||
/// The dialog's Save anyway: write over the changed file.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn overwrite_changed_file(&mut self) {
|
||||
let Some(p) = self.pending_overwrite.take() else { return };
|
||||
if self.save_doc_to(p.id, &p.dest, p.target) {
|
||||
(p.after)(self);
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog's Save as…: leave the file on disk as it is and ask where to put this version.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn save_changed_file_elsewhere(&mut self) {
|
||||
let Some(p) = self.pending_overwrite.take() else { return };
|
||||
if let Some(index) = self.views.iter().position(|v| v.id == p.id) {
|
||||
self.save_then(index, SaveTarget::As, p.after);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write document `id` to `dest` and make it the document's file. Returns `true` if written.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget) -> bool {
|
||||
// Save As answers on a later frame: commit what was typed meanwhile, too (#166), even
|
||||
// if another tab is active by then.
|
||||
if !self.commit_typing_in(id) || !self.flatten_fill_sign_for_save(id) {
|
||||
return false;
|
||||
}
|
||||
let Some((name, full)) = self.session.get(id).map(|d| {
|
||||
// Save As to another file is a clean copy, without the earlier revisions that still
|
||||
// hold deleted pages or replaced text (#2165). A signed document stays incremental so
|
||||
// its signatures keep covering the file.
|
||||
let full = target == SaveTarget::As
|
||||
&& !d.path.as_deref().is_some_and(|p| crate::same_file(p, dest))
|
||||
&& d.read_only_reason.is_none()
|
||||
&& !d.is_signed();
|
||||
(d.name.clone(), full)
|
||||
}) else {
|
||||
self.notify_tr("The document was closed before it could be saved.");
|
||||
return false;
|
||||
};
|
||||
let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) };
|
||||
let bytes = match bytes.and_then(|b| self.session.check_save(id, &b).map(|()| b)) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if let Err(e) = write_atomically(dest, &bytes) {
|
||||
self.notify_fmt("Couldn't save {name}: {e}", &[("name", dest), ("e", &e.to_string())]);
|
||||
return false;
|
||||
}
|
||||
// Our own write is not a change on disk to reload (#431).
|
||||
self.watch.known_now(id, dest);
|
||||
match self.session.mark_saved(id, bytes, Some(dest.to_string())) {
|
||||
Ok(()) => {
|
||||
self.forget_recovery(id);
|
||||
if let Some(doc) = self.session.get(id)
|
||||
&& let Some(view) = self.views.iter_mut().find(|v| v.id == id)
|
||||
{
|
||||
view.document_changed(&doc.info);
|
||||
// Saving over a version that changed on disk keeps this one: nothing to ask.
|
||||
view.disk_changed = false;
|
||||
}
|
||||
self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]);
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::macos_association::repair(std::path::Path::new(dest));
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
self.notify_fmt("Saved, but reopening failed: {e}", &[("e", &e.to_string())]);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` is the temporary copy an email client opened an attachment from, so that Save
|
||||
/// asks where to keep the document instead of writing to it.
|
||||
///
|
||||
/// Outlook opens an attachment from its own cache: `INetCache\Content.Outlook\<random>\` for
|
||||
/// classic Outlook (`Temporary Internet Files\Content.Outlook\` on older Windows), and the
|
||||
/// `Packages\Microsoft.OutlookForWindows_<id>` package folder for the new Outlook. The patterns
|
||||
/// follow those layouts, so an ordinary folder that merely shares a name doesn't match. Saving there either fails,
|
||||
/// because Outlook can still hold the file, or appears to work while the edits stay out of the
|
||||
/// email and are deleted with the cache. Apple Mail opens one from `Mail Downloads/<UUID>/` in
|
||||
/// its sandbox container, where other apps can't write, so saving there fails (#2751). Matched on
|
||||
/// either separator and in any case, so it can be tested on every platform.
|
||||
pub(crate) fn is_mail_temp_copy(path: &str) -> bool {
|
||||
let p = path.replace('\\', "/").to_ascii_lowercase();
|
||||
// Outlook's cache holds `<marker><random folder>/<file>`: exactly one folder, then the file.
|
||||
let in_outlook_cache = |marker: &str| {
|
||||
p.split_once(marker)
|
||||
.is_some_and(|(_, rest)| matches!(rest.split('/').collect::<Vec<_>>()[..], [dir, file] if !dir.is_empty() && !file.is_empty()))
|
||||
};
|
||||
in_outlook_cache("/inetcache/content.outlook/")
|
||||
|| in_outlook_cache("/temporary internet files/content.outlook/")
|
||||
|| p.contains("/packages/microsoft.outlookforwindows_")
|
||||
|| p.contains("/library/containers/com.apple.mail/data/library/mail downloads/")
|
||||
}
|
||||
|
||||
/// The folder a Save As style dialog should open in for a document stored at `path`: the
|
||||
/// document's own folder, as Acrobat and Foxit do, instead of whichever folder the system picks.
|
||||
/// `None` (keep the system's choice) for a new document, a path with no parent, or an email
|
||||
/// attachment's temporary copy (`is_mail_temp_copy`), which is no place to keep work. Also right
|
||||
/// for UNC paths on Windows (`\\server\share\dir\f.pdf` gives `\\server\share\dir`).
|
||||
pub(crate) fn save_dialog_folder(path: Option<&str>) -> Option<std::path::PathBuf> {
|
||||
let path = path.filter(|p| !is_mail_temp_copy(p))?;
|
||||
let parent = std::path::Path::new(path).parent()?;
|
||||
(!parent.as_os_str().is_empty()).then(|| parent.to_path_buf())
|
||||
}
|
||||
|
||||
/// `dialog` opened in the folder of the document stored at `path` ([`save_dialog_folder`]).
|
||||
pub(crate) fn in_document_folder(dialog: rfd::AsyncFileDialog, path: Option<&str>) -> rfd::AsyncFileDialog {
|
||||
match save_dialog_folder(path) {
|
||||
Some(dir) => dialog.set_directory(dir),
|
||||
None => dialog,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write via a temporary file in the same directory and rename over the target, so a crash or
|
||||
/// full disk never leaves a half-written PDF where the original was.
|
||||
pub fn write_atomically(path: &str, bytes: &[u8]) -> std::io::Result<()> {
|
||||
write_atomically_with(path, bytes, staging_suffixes())
|
||||
}
|
||||
|
||||
/// [`write_atomically`], trying the staging names that `suffixes` give.
|
||||
pub(super) fn write_atomically_with(path: &str, bytes: &[u8], suffixes: impl IntoIterator<Item = u64>) -> std::io::Result<()> {
|
||||
write_atomically_syncing(path, bytes, suffixes, std::fs::File::sync_all)
|
||||
}
|
||||
|
||||
/// [`write_atomically_with`], flushing the staging file with `sync_all` (the standard library's
|
||||
/// in the app, a failing one in tests). The writer itself is the platform crate's, shared with
|
||||
/// the tools and the CLI (#2166); the desktop app keeps its staging-name form.
|
||||
pub(super) fn write_atomically_syncing(
|
||||
path: &str,
|
||||
bytes: &[u8],
|
||||
suffixes: impl IntoIterator<Item = u64>,
|
||||
sync_all: impl FnOnce(&std::fs::File) -> std::io::Result<()>,
|
||||
) -> std::io::Result<()> {
|
||||
pdfcraft_platform::staging::write_atomically_with(std::path::Path::new(path), bytes, StagingName::TagThenSuffix, suffixes, sync_all)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn short_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
/// Offer bytes as a browser download.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
use wasm_bindgen::JsCast;
|
||||
let err = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let array = js_sys::Uint8Array::from(bytes);
|
||||
let parts = js_sys::Array::of1(&array);
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type("application/pdf");
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(err)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(err)?;
|
||||
let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(err)?.dyn_into().map_err(|_| "anchor")?;
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
a.click();
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user