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
|
//! The browser shell: web `Services`, drag-and-drop, the unsaved-changes guard and the eframe
|
||||||
//! web runner.
|
//! web runner.
|
||||||
|
|
||||||
use std::cell::Cell;
|
use std::cell::{Cell, RefCell};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use wasm_bindgen::JsCast as _;
|
use wasm_bindgen::JsCast as _;
|
||||||
|
|
@ -27,7 +27,8 @@ pub fn start() {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let dirty = Rc::new(Cell::new(false));
|
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}");
|
log::error!("no unsaved-changes guard: {e}");
|
||||||
}
|
}
|
||||||
let mut options = eframe::WebOptions::default();
|
let mut options = eframe::WebOptions::default();
|
||||||
|
|
@ -51,9 +52,9 @@ pub fn start() {
|
||||||
log::error!("pasting pictures is unavailable: {e}");
|
log::error!("pasting pictures is unavailable: {e}");
|
||||||
}
|
}
|
||||||
let doc = if query().contains("sample") { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
|
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;
|
app.autosave = false;
|
||||||
Ok(Box::new(WebShell { app, inbox }))
|
Ok(Box::new(WebShell { app, inbox, saves }))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -75,6 +76,38 @@ fn query() -> String {
|
||||||
struct WebShell {
|
struct WebShell {
|
||||||
app: WordApp,
|
app: WordApp,
|
||||||
inbox: Inbox,
|
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 {
|
impl eframe::App for WebShell {
|
||||||
|
|
@ -94,6 +127,7 @@ impl eframe::App for WebShell {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
self.settle_picker_saves();
|
||||||
self.app.logic(ctx);
|
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`]).
|
/// `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 open_inbox = inbox.clone();
|
||||||
|
let save_ctx = ctx.clone();
|
||||||
Services {
|
Services {
|
||||||
open_async: Some(Box::new(move |purpose: &str| {
|
open_async: Some(Box::new(move |purpose: &str| {
|
||||||
let inbox = open_inbox.clone();
|
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.
|
// Exports ask for a name; the browser decides where the download goes.
|
||||||
pick_save: Some(Box::new(|name: &str| Some(name.to_string()))),
|
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)),
|
print: Some(Box::new(print_pdf)),
|
||||||
inbox: Some(inbox),
|
inbox: Some(inbox),
|
||||||
on_dirty: Some(Box::new(move |d| dirty.set(d))),
|
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
|
/// 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.
|
/// 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 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| {
|
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();
|
e.prevent_default();
|
||||||
// Older browsers ask only when `returnValue` is set.
|
// Older browsers ask only when `returnValue` is set.
|
||||||
e.set_return_value("unsaved changes");
|
e.set_return_value("unsaved changes");
|
||||||
|
|
@ -215,20 +251,145 @@ fn guard_unload(dirty: Rc<Cell<bool>>) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
/// Trigger a browser save dialog or download of `bytes` named after the last component of `path`.
|
||||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
fn download(saves: &Rc<PickerSaves>, ctx: &egui::Context, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
|
||||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "document".into());
|
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")?;
|
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 document = window.document().ok_or("no document")?;
|
||||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||||
let opts = web_sys::BlobPropertyBag::new();
|
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 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 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_href(&url);
|
||||||
a.set_download(&name);
|
a.set_download(name);
|
||||||
a.style().set_property("display", "none").map_err(js)?;
|
a.style().set_property("display", "none").map_err(js)?;
|
||||||
let body = document.body().ok_or("no body")?;
|
let body = document.body().ok_or("no body")?;
|
||||||
body.append_child(&a).map_err(js)?;
|
body.append_child(&a).map_err(js)?;
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,4 @@ display_name = "Gleyson Carvalho"
|
||||||
sync_github_name = false
|
sync_github_name = false
|
||||||
[people.alexanderalber]
|
[people.alexanderalber]
|
||||||
real_name = "Alexander Alber"
|
real_name = "Alexander Alber"
|
||||||
|
|
||||||
[people.edurbs]
|
[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
|
/// 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
|
/// document's own file, including macro-enabled documents and templates); anything else is a
|
||||||
/// copy that leaves it unsaved. AutoSave writes only these formats.
|
/// 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();
|
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")
|
matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "odt" | "rtf" | "json" | "xml")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue