web: use File System Access API showSaveFilePicker with download fallback (#728)
* web: use File System Access API showSaveFilePicker with download fallback * Review fixes for #728: mark the document saved only after the picker writes the file A cancelled or failed save in the browser's save picker marks the document unsaved again, and the beforeunload guard keeps warning while a picker save is in flight. The picker's file types use our own neutral format names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6cf0aec0ff
commit
59b04d7644
|
|
@ -1,7 +1,7 @@
|
|||
//! The browser shell: web `Services`, drag-and-drop, the unsaved-changes guard and the eframe
|
||||
//! web runner.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
|
@ -27,7 +27,8 @@ pub fn start() {
|
|||
return;
|
||||
};
|
||||
let dirty = Rc::new(Cell::new(false));
|
||||
if let Err(e) = guard_unload(dirty.clone()) {
|
||||
let saves = Rc::new(PickerSaves::default());
|
||||
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
|
||||
log::error!("no unsaved-changes guard: {e}");
|
||||
}
|
||||
let mut options = eframe::WebOptions::default();
|
||||
|
|
@ -51,9 +52,9 @@ pub fn start() {
|
|||
log::error!("pasting pictures is unavailable: {e}");
|
||||
}
|
||||
let doc = if query().contains("sample") { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
|
||||
let mut app = WordApp::new(Session::new(doc), services(inbox.clone(), cc.egui_ctx.clone(), dirty));
|
||||
let mut app = WordApp::new(Session::new(doc), services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone()));
|
||||
app.autosave = false;
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
Ok(Box::new(WebShell { app, inbox, saves }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
|
@ -75,6 +76,38 @@ fn query() -> String {
|
|||
struct WebShell {
|
||||
app: WordApp,
|
||||
inbox: Inbox,
|
||||
saves: Rc<PickerSaves>,
|
||||
}
|
||||
|
||||
/// Saves the browser's save picker finishes after `download` has returned (#728). The app marks
|
||||
/// the document saved as soon as `download` returns, so a cancelled or failed picker save must
|
||||
/// mark it unsaved again, and the `beforeunload` guard keeps warning while one is in flight.
|
||||
#[derive(Default)]
|
||||
struct PickerSaves {
|
||||
/// Picker saves started and not yet written and closed.
|
||||
pending: Cell<u32>,
|
||||
/// Picker saves that didn't write the file: (name, error, or `None` when cancelled).
|
||||
unsaved: RefCell<Vec<(String, Option<String>)>>,
|
||||
}
|
||||
|
||||
impl WebShell {
|
||||
/// A picker save that didn't happen leaves the document with unsaved changes.
|
||||
fn settle_picker_saves(&mut self) {
|
||||
let unsaved = std::mem::take(&mut *self.saves.unsaved.borrow_mut());
|
||||
for (name, error) in unsaved {
|
||||
// Only a save in a format that keeps everything marked the document saved.
|
||||
if wordcraft_ui_egui::keeps_everything(&name) {
|
||||
self.app.session.dirty = true;
|
||||
}
|
||||
if let Some(error) = error {
|
||||
let msg = wordcraft_ui_egui::i18n::fmt(
|
||||
wordcraft_ui_egui::i18n::t("Couldn't download {name}: {error}"),
|
||||
&[("name", &name), ("error", &error)],
|
||||
);
|
||||
self.app.status(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
|
|
@ -94,6 +127,7 @@ impl eframe::App for WebShell {
|
|||
}
|
||||
});
|
||||
}
|
||||
self.settle_picker_saves();
|
||||
self.app.logic(ctx);
|
||||
}
|
||||
|
||||
|
|
@ -153,8 +187,9 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St
|
|||
}
|
||||
|
||||
/// `dirty` is the flag the `beforeunload` guard reads ([`guard_unload`]).
|
||||
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>) -> Services {
|
||||
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let save_ctx = ctx.clone();
|
||||
Services {
|
||||
open_async: Some(Box::new(move |purpose: &str| {
|
||||
let inbox = open_inbox.clone();
|
||||
|
|
@ -179,7 +214,7 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>) -> Services
|
|||
})),
|
||||
// Exports ask for a name; the browser decides where the download goes.
|
||||
pick_save: Some(Box::new(|name: &str| Some(name.to_string()))),
|
||||
download: Some(Box::new(download)),
|
||||
download: Some(Box::new(move |path: &str, bytes: &[u8]| download(&saves, &save_ctx, path, bytes))),
|
||||
print: Some(Box::new(print_pdf)),
|
||||
inbox: Some(inbox),
|
||||
on_dirty: Some(Box::new(move |d| dirty.set(d))),
|
||||
|
|
@ -200,10 +235,11 @@ fn animations_enabled() -> Box<dyn Fn() -> Option<bool>> {
|
|||
|
||||
/// Closing, reloading or leaving the tab with unsaved changes asks first. Browsers show their own
|
||||
/// confirmation (a page can only ask for it), so there is no Save button: Cancel, then File › Save.
|
||||
fn guard_unload(dirty: Rc<Cell<bool>>) -> Result<(), String> {
|
||||
fn guard_unload(dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(move |e: web_sys::BeforeUnloadEvent| {
|
||||
if dirty.get() {
|
||||
// A save still in the browser's picker hasn't written anything yet.
|
||||
if dirty.get() || saves.pending.get() > 0 {
|
||||
e.prevent_default();
|
||||
// Older browsers ask only when `returnValue` is set.
|
||||
e.set_return_value("unsaved changes");
|
||||
|
|
@ -215,20 +251,145 @@ fn guard_unload(dirty: Rc<Cell<bool>>) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
/// Trigger a browser save dialog or download of `bytes` named after the last component of `path`.
|
||||
fn download(saves: &Rc<PickerSaves>, ctx: &egui::Context, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "document".into());
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
|
||||
// On browsers supporting the File System Access API (Chromium: Chrome, Edge, etc.),
|
||||
// `showSaveFilePicker` lets the user choose the save location and filename natively.
|
||||
if let Ok(picker_val) = js_sys::Reflect::get(&window, &wasm_bindgen::JsValue::from_str("showSaveFilePicker"))
|
||||
&& let Ok(picker_fn) = picker_val.dyn_into::<js_sys::Function>()
|
||||
{
|
||||
match trigger_save_file_picker(saves, ctx, &window, &picker_fn, &name, bytes) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
log::warn!("showSaveFilePicker failed: {e}; falling back to anchor download");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for browsers without showSaveFilePicker (e.g. Firefox, Safari):
|
||||
trigger_anchor_download(&window, &name, bytes)
|
||||
}
|
||||
|
||||
fn trigger_save_file_picker(
|
||||
saves: &Rc<PickerSaves>,
|
||||
ctx: &egui::Context,
|
||||
window: &web_sys::Window,
|
||||
picker_fn: &js_sys::Function,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("docx").to_ascii_lowercase();
|
||||
let dot_ext = format!(".{ext}");
|
||||
let mime = mime_for(name);
|
||||
|
||||
let accept_obj = js_sys::Object::new();
|
||||
let exts_arr = js_sys::Array::new();
|
||||
exts_arr.push(&wasm_bindgen::JsValue::from_str(&dot_ext));
|
||||
let _ = js_sys::Reflect::set(&accept_obj, &wasm_bindgen::JsValue::from_str(mime), &exts_arr);
|
||||
|
||||
// Our own neutral description: the format's name, as its extension says it.
|
||||
let type_desc = match ext.as_str() {
|
||||
"htm" => "HTML".to_string(),
|
||||
"txt" => "Text".to_string(),
|
||||
e if !e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric()) => e.to_ascii_uppercase(),
|
||||
_ => "Document".to_string(),
|
||||
};
|
||||
|
||||
let type_obj = js_sys::Object::new();
|
||||
let _ = js_sys::Reflect::set(&type_obj, &wasm_bindgen::JsValue::from_str("description"), &wasm_bindgen::JsValue::from_str(&type_desc));
|
||||
let _ = js_sys::Reflect::set(&type_obj, &wasm_bindgen::JsValue::from_str("accept"), &accept_obj);
|
||||
|
||||
let types_arr = js_sys::Array::new();
|
||||
types_arr.push(&type_obj);
|
||||
|
||||
let picker_opts = js_sys::Object::new();
|
||||
let _ = js_sys::Reflect::set(&picker_opts, &wasm_bindgen::JsValue::from_str("suggestedName"), &wasm_bindgen::JsValue::from_str(name));
|
||||
let _ = js_sys::Reflect::set(&picker_opts, &wasm_bindgen::JsValue::from_str("types"), &types_arr);
|
||||
|
||||
// Call showSaveFilePicker synchronously while transient user activation is active:
|
||||
let prom_val = picker_fn.call1(window, &picker_opts).map_err(js)?;
|
||||
let prom: js_sys::Promise = prom_val.dyn_into().map_err(|_| "not a promise".to_string())?;
|
||||
|
||||
let bytes_vec = bytes.to_vec();
|
||||
let saves = saves.clone();
|
||||
let ctx = ctx.clone();
|
||||
let name = name.to_string();
|
||||
saves.pending.set(saves.pending.get().saturating_add(1));
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// The document counts as saved only once the file is written and closed.
|
||||
let unsaved = match wasm_bindgen_futures::JsFuture::from(prom).await {
|
||||
Ok(handle_val) => match write_file_handle(&handle_val, &bytes_vec).await {
|
||||
Ok(()) => {
|
||||
log::info!("saved {name} through the save picker");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("couldn't write {name}: {e}");
|
||||
Some(Some(e))
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let err_name =
|
||||
js_sys::Reflect::get(&e, &wasm_bindgen::JsValue::from_str("name")).ok().and_then(|v| v.as_string()).unwrap_or_default();
|
||||
if err_name == "AbortError" {
|
||||
log::debug!("save picker cancelled");
|
||||
Some(None)
|
||||
} else {
|
||||
log::error!("showSaveFilePicker error: {e:?}");
|
||||
Some(Some(format!("{e:?}")))
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(error) = unsaved {
|
||||
saves.unsaved.borrow_mut().push((name, error));
|
||||
}
|
||||
saves.pending.set(saves.pending.get().saturating_sub(1));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_file_handle(handle: &wasm_bindgen::JsValue, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let create_fn: js_sys::Function = js_sys::Reflect::get(handle, &wasm_bindgen::JsValue::from_str("createWritable"))
|
||||
.map_err(js)?
|
||||
.dyn_into()
|
||||
.map_err(|_| "createWritable is not a function".to_string())?;
|
||||
let stream_prom: js_sys::Promise = create_fn.call0(handle).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
|
||||
let stream = wasm_bindgen_futures::JsFuture::from(stream_prom).await.map_err(js)?;
|
||||
|
||||
let write_fn: js_sys::Function = js_sys::Reflect::get(&stream, &wasm_bindgen::JsValue::from_str("write"))
|
||||
.map_err(js)?
|
||||
.dyn_into()
|
||||
.map_err(|_| "write is not a function".to_string())?;
|
||||
let uint8 = js_sys::Uint8Array::from(bytes);
|
||||
let write_prom: js_sys::Promise = write_fn.call1(&stream, &uint8).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
|
||||
wasm_bindgen_futures::JsFuture::from(write_prom).await.map_err(js)?;
|
||||
|
||||
let close_fn: js_sys::Function = js_sys::Reflect::get(&stream, &wasm_bindgen::JsValue::from_str("close"))
|
||||
.map_err(js)?
|
||||
.dyn_into()
|
||||
.map_err(|_| "close is not a function".to_string())?;
|
||||
let close_prom: js_sys::Promise = close_fn.call0(&stream).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
|
||||
wasm_bindgen_futures::JsFuture::from(close_prom).await.map_err(js)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trigger_anchor_download(window: &web_sys::Window, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type(mime_for(&name));
|
||||
opts.set_type(mime_for(name));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor".to_string())?;
|
||||
a.set_href(&url);
|
||||
a.set_download(&name);
|
||||
a.set_download(name);
|
||||
a.style().set_property("display", "none").map_err(js)?;
|
||||
let body = document.body().ok_or("no body")?;
|
||||
body.append_child(&a).map_err(js)?;
|
||||
|
|
|
|||
|
|
@ -59,6 +59,4 @@ display_name = "Gleyson Carvalho"
|
|||
sync_github_name = false
|
||||
[people.alexanderalber]
|
||||
real_name = "Alexander Alber"
|
||||
|
||||
[people.edurbs]
|
||||
sync_github_name = true
|
||||
|
|
|
|||
|
|
@ -1565,7 +1565,7 @@ impl AutoSaveBlock {
|
|||
/// Whether saving to `name` keeps the whole document (the formats `file.save` treats as the
|
||||
/// document's own file, including macro-enabled documents and templates); anything else is a
|
||||
/// copy that leaves it unsaved. AutoSave writes only these formats.
|
||||
fn keeps_everything(name: &str) -> bool {
|
||||
pub fn keeps_everything(name: &str) -> bool {
|
||||
let ext = std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "odt" | "rtf" | "json" | "xml")
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue