| >, saves: Rc Box Option> {
/// 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>, saves: Rc) -> Result<(), String> {
+fn guard_unload(dirty: Rc>, saves: AsyncSaves) -> Result<(), String> {
let window = web_sys::window().ok_or("no window")?;
let on_unload = wasm_bindgen::closure::Closure::::new(move |e: web_sys::BeforeUnloadEvent| {
// A save still in the browser's picker hasn't written anything yet.
- if dirty.get() || saves.pending.get() > 0 {
+ if dirty.get() || saves.pending() > 0 {
e.prevent_default();
// Older browsers ask only when `returnValue` is set.
e.set_return_value("unsaved changes");
@@ -266,7 +235,7 @@ fn guard_unload(dirty: Rc>, saves: Rc) -> Result<(), Str
}
/// Trigger a browser save dialog or download of `bytes` named after the last component of `path`.
-fn download(saves: &Rc, ctx: &egui::Context, path: &str, bytes: &[u8]) -> Result<(), String> {
+fn download(saves: &AsyncSaves, 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")?;
@@ -288,7 +257,7 @@ fn download(saves: &Rc, ctx: &egui::Context, path: &str, bytes: &[u
}
fn trigger_save_file_picker(
- saves: &Rc,
+ saves: &AsyncSaves,
ctx: &egui::Context,
window: &web_sys::Window,
picker_fn: &js_sys::Function,
@@ -332,18 +301,19 @@ fn trigger_save_file_picker(
let saves = saves.clone();
let ctx = ctx.clone();
let name = name.to_string();
- saves.pending.set(saves.pending.get().saturating_add(1));
+ // The app holds back a Save Changes prompt's New/Open until this ticket ends (#1315).
+ let ticket = saves.start();
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 {
+ let outcome = 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
+ SaveOutcome::Saved
}
Err(e) => {
log::error!("couldn't write {name}: {e}");
- Some(Some(e))
+ SaveOutcome::Failed(e)
}
},
Err(e) => {
@@ -351,17 +321,14 @@ fn trigger_save_file_picker(
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)
+ SaveOutcome::Cancelled
} else {
log::error!("showSaveFilePicker error: {e:?}");
- Some(Some(format!("{e:?}")))
+ SaveOutcome::Failed(format!("{e:?}"))
}
}
};
- if let Some(error) = unsaved {
- saves.unsaved.borrow_mut().push((name, error));
- }
- saves.pending.set(saves.pending.get().saturating_sub(1));
+ saves.finish(ticket, outcome);
ctx.request_repaint();
});
Ok(())
diff --git a/crates/ui-egui/src/async_saves.rs b/crates/ui-egui/src/async_saves.rs
new file mode 100644
index 000000000..a3c054e79
--- /dev/null
+++ b/crates/ui-egui/src/async_saves.rs
@@ -0,0 +1,108 @@
+//! Saves that finish after the save command has returned (#1315).
+//!
+//! In the browser, Save hands the bytes to the save picker; the user picks a place (or cancels)
+//! and the file is written and closed later, long after `download` returned. The host starts a
+//! ticket for such a save ([`AsyncSaves::start`]) while `download` runs and reports how it ended
+//! ([`AsyncSaves::finish`]); the app reads the outcomes each frame. A "Save changes?" prompt
+//! whose Save went to the picker holds its New/Open back until its ticket ends: it runs on
+//! success, and on a cancel or failure the document stays open, still unsaved, and the prompt
+//! comes back with the reason.
+//!
+//! Plain `Rc>` state: no locks, no borrow that could fail.
+
+use std::cell::Cell;
+use std::rc::Rc;
+
+/// How a save that finished later ended.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum SaveOutcome {
+ /// The file is written and closed.
+ Saved,
+ /// The user cancelled the picker; nothing was written.
+ Cancelled,
+ /// The picker, the write or the close failed (the reason).
+ Failed(String),
+}
+
+#[derive(Default)]
+struct State {
+ next: u64,
+ /// The ticket the current `download` call started, not yet claimed by the app.
+ started: Option,
+ /// Tickets started and not finished.
+ pending: Vec,
+ /// Tickets finished and not yet read by the app.
+ finished: Vec<(u64, SaveOutcome)>,
+}
+
+/// Saves in flight, shared between the host's `download` hook and the app (a cheap clone).
+#[derive(Clone, Default)]
+pub struct AsyncSaves(Rc| >);
+
+impl AsyncSaves {
+ fn with(&self, f: impl FnOnce(&mut State) -> R) -> R {
+ let mut state = self.0.take();
+ let r = f(&mut state);
+ self.0.set(state);
+ r
+ }
+
+ /// The host's `download` hook: the save it is starting finishes later. Returns its ticket,
+ /// to pass to [`Self::finish`].
+ pub fn start(&self) -> u64 {
+ self.with(|s| {
+ s.next = s.next.wrapping_add(1);
+ let ticket = s.next;
+ s.started = Some(ticket);
+ s.pending.push(ticket);
+ ticket
+ })
+ }
+
+ /// The save `ticket` ended. An unknown or already finished ticket is ignored.
+ pub fn finish(&self, ticket: u64, outcome: SaveOutcome) {
+ self.with(|s| {
+ if let Some(i) = s.pending.iter().position(|&t| t == ticket) {
+ s.pending.swap_remove(i);
+ s.finished.push((ticket, outcome));
+ }
+ });
+ }
+
+ /// Saves started and not finished (the browser's leave-page guard warns while there are any).
+ pub fn pending(&self) -> usize {
+ self.with(|s| s.pending.len())
+ }
+
+ /// The ticket the last `download` call started, if it started one (claimed once).
+ pub(crate) fn take_started(&self) -> Option {
+ self.with(|s| s.started.take())
+ }
+
+ /// The saves that ended since the last call, oldest first.
+ pub(crate) fn take_finished(&self) -> Vec<(u64, SaveOutcome)> {
+ self.with(|s| std::mem::take(&mut s.finished))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn tickets_start_finish_and_are_read_once() {
+ let saves = AsyncSaves::default();
+ let a = saves.start();
+ let b = saves.start();
+ assert_ne!(a, b);
+ assert_eq!(saves.take_started(), Some(b));
+ assert_eq!(saves.take_started(), None);
+ assert_eq!(saves.pending(), 2);
+ saves.finish(a, SaveOutcome::Cancelled);
+ saves.finish(a, SaveOutcome::Saved);
+ saves.finish(999, SaveOutcome::Saved);
+ assert_eq!(saves.pending(), 1);
+ assert_eq!(saves.take_finished(), vec![(a, SaveOutcome::Cancelled)]);
+ assert!(saves.take_finished().is_empty());
+ }
+}
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index 1cf83e0bb..e867f457e 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -2467,6 +2467,8 @@
Leave out the date and time of comments and tracked changes when saving Без дата и час на коментарите и проследените промени при запазване
The document you are editing keeps the names and dates; only the saved file leaves them out. Документът, който редактирате, запазва имената и датите; само записаният файл ги пропуска.
Privacy Settings Текущи настройки за поверителност
+ Waiting for the save to finish… Изчакване запазването да приключи…
+ Saving {name} was cancelled, so it is still open and nothing was lost. Запазването на {name} е отменено. Документът остава отворен и нищо не е загубено.
Paper Хартия
Paper size: Размер на хартията:
Custom size Потребителски размер
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index e58338a8a..06b8341ab 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -2092,6 +2092,8 @@
Leave out the date and time of comments and tracked changes when saving Při ukládání vynechat datum a čas komentářů a sledovaných změn
The document you are editing keeps the names and dates; only the saved file leaves them out. Upravovaný dokument si jména a data ponechá; vynechá je jen uložený soubor.
Privacy Settings Aktuální nastavení soukromí
+ Waiting for the save to finish… Čeká se na dokončení ukládání…
+ Saving {name} was cancelled, so it is still open and nothing was lost. Ukládání {name} bylo zrušeno, dokument zůstává otevřený a nic se neztratilo.
Paper Papír
Paper size: Velikost papíru:
Custom size Vlastní velikost
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index 1da97e88b..38d822dc8 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -2086,6 +2086,8 @@
Leave out the date and time of comments and tracked changes when saving Beim Speichern Datum und Uhrzeit von Kommentaren und nachverfolgten Änderungen weglassen
The document you are editing keeps the names and dates; only the saved file leaves them out. Das bearbeitete Dokument behält Namen und Daten; nur die gespeicherte Datei lässt sie weg.
Privacy Settings Aktuelle Datenschutzeinstellungen
+ Waiting for the save to finish… Warten, bis das Speichern abgeschlossen ist…
+ Saving {name} was cancelled, so it is still open and nothing was lost. Das Speichern von {name} wurde abgebrochen. Das Dokument ist weiterhin geöffnet und nichts ist verloren gegangen.
Paper Papier
Paper size: Papierformat:
Custom size Benutzerdefiniertes Format
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index 8e4970d93..c2faf8e26 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -2453,6 +2453,8 @@ preview Title Título
Leave out the date and time of comments and tracked changes when saving Omitir la fecha y la hora de los comentarios y los cambios controlados al guardar
The document you are editing keeps the names and dates; only the saved file leaves them out. El documento que editas conserva los nombres y las fechas; solo el archivo guardado los omite.
Privacy Settings Configuración de privacidad actual
+ Waiting for the save to finish… Esperando a que termine de guardarse…
+ Saving {name} was cancelled, so it is still open and nothing was lost. Se canceló el guardado de {name}; sigue abierto y no se perdió nada.
Paper Papel
Paper size: Tamaño del papel:
Custom size Tamaño personalizado
diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv
index f5a44f431..bde7bced7 100644
--- a/crates/ui-egui/src/i18n/et.tsv
+++ b/crates/ui-egui/src/i18n/et.tsv
@@ -2121,6 +2121,8 @@
Leave out the date and time of comments and tracked changes when saving Jäta salvestamisel välja kommentaaride ja jälitatud muudatuste kuupäev ja kellaaeg
The document you are editing keeps the names and dates; only the saved file leaves them out. Redigeeritav dokument säilitab nimed ja kuupäevad; need jäetakse välja ainult salvestatud failist.
Privacy Settings Praegused privaatsussätted
+ Waiting for the save to finish… Ootan salvestamise lõppu…
+ Saving {name} was cancelled, so it is still open and nothing was lost. Faili {name} salvestamine tühistati, see on endiselt avatud ja midagi ei läinud kaotsi.
Paper Paber
Paper size: Paberi suurus:
Custom size Kohandatud suurus
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index edb6b4f9e..dcf05de67 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -2095,6 +2095,8 @@
Leave out the date and time of comments and tracked changes when saving Omettre la date et l’heure des commentaires et des modifications suivies à l’enregistrement
The document you are editing keeps the names and dates; only the saved file leaves them out. Le document en cours de modification garde les noms et les dates ; seul le fichier enregistré les omet.
Privacy Settings Paramètres de confidentialité actuels
+ Waiting for the save to finish… En attente de la fin de l'enregistrement…
+ Saving {name} was cancelled, so it is still open and nothing was lost. L'enregistrement de {name} a été annulé : le document reste ouvert et rien n'a été perdu.
Paper Papier
Paper size: Taille du papier :
Custom size Taille personnalisée
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
index c7ebd6893..bf8a069d2 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -2475,6 +2475,8 @@
Leave out the date and time of comments and tracked changes when saving Mentéskor a megjegyzések és a követett módosítások dátumának és idejének elhagyása
The document you are editing keeps the names and dates; only the saved file leaves them out. A szerkesztett dokumentum megtartja a neveket és a dátumokat; csak a mentett fájlból maradnak ki.
Privacy Settings Jelenlegi adatvédelmi beállítások
+ Waiting for the save to finish… Várakozás a mentés befejezésére…
+ Saving {name} was cancelled, so it is still open and nothing was lost. A(z) {name} mentése megszakadt, így továbbra is nyitva van, és semmi sem veszett el.
Paper Papír
Paper size: Papírméret:
Custom size Egyéni méret
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index 1d78bb46d..cb0605942 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -2462,6 +2462,8 @@ preview Title Esempio
Leave out the date and time of comments and tracked changes when saving Ometti data e ora di commenti e revisioni al salvataggio
The document you are editing keeps the names and dates; only the saved file leaves them out. Il documento che stai modificando conserva nomi e date; solo il file salvato li omette.
Privacy Settings Impostazioni di privacy attuali
+ Waiting for the save to finish… In attesa del termine del salvataggio…
+ Saving {name} was cancelled, so it is still open and nothing was lost. Il salvataggio di {name} è stato annullato: il documento è ancora aperto e non è andato perso nulla.
Paper Carta
Paper size: Dimensioni carta:
Custom size Dimensioni personalizzate
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index 8896a814b..01a055a69 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -2087,6 +2087,8 @@
Leave out the date and time of comments and tracked changes when saving 保存時にコメントと変更履歴の日時を含めない
The document you are editing keeps the names and dates; only the saved file leaves them out. 編集中のドキュメントには名前と日時が残ります。保存したファイルにだけ含まれません。
Privacy Settings 現在のプライバシー設定
+ Waiting for the save to finish… 保存が完了するのを待っています…
+ Saving {name} was cancelled, so it is still open and nothing was lost. {name} の保存はキャンセルされました。文書は開いたままで、何も失われていません。
Paper 用紙
Paper size: 用紙サイズ:
Custom size ユーザー定義サイズ
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
index 4c6e4f088..30f043461 100644
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -2471,6 +2471,8 @@ preview Title Tytuł
Leave out the date and time of comments and tracked changes when saving Pomiń datę i godzinę komentarzy i śledzonych zmian przy zapisywaniu
The document you are editing keeps the names and dates; only the saved file leaves them out. Edytowany dokument zachowuje nazwiska i daty; pomija je tylko zapisany plik.
Privacy Settings Bieżące ustawienia prywatności
+ Waiting for the save to finish… Oczekiwanie na zakończenie zapisywania…
+ Saving {name} was cancelled, so it is still open and nothing was lost. Zapisywanie pliku {name} zostało anulowane. Dokument jest nadal otwarty i nic nie zostało utracone.
Paper Papier
Paper size: Rozmiar papieru:
Custom size Rozmiar niestandardowy
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index 1a0c9a4b7..8ff415f17 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -2099,6 +2099,8 @@
Leave out the date and time of comments and tracked changes when saving Omitir a data e a hora dos comentários e das alterações controladas ao salvar
The document you are editing keeps the names and dates; only the saved file leaves them out. O documento que você está editando mantém os nomes e as datas; só o arquivo salvo os omite.
Privacy Settings Configurações de privacidade atuais
+ Waiting for the save to finish… Aguardando o término do salvamento…
+ Saving {name} was cancelled, so it is still open and nothing was lost. O salvamento de {name} foi cancelado; ele continua aberto e nada foi perdido.
Paper Papel
Paper size: Tamanho do papel:
Custom size Tamanho personalizado
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index 993b27c14..b5456c933 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -2477,6 +2477,8 @@ preview Title Пример
Leave out the date and time of comments and tracked changes when saving Не сохранять дату и время примечаний и отслеживаемых изменений
The document you are editing keeps the names and dates; only the saved file leaves them out. Редактируемый документ сохраняет имена и даты; их не будет только в сохранённом файле.
Privacy Settings Текущие параметры конфиденциальности
+ Waiting for the save to finish… Ожидание завершения сохранения…
+ Saving {name} was cancelled, so it is still open and nothing was lost. Сохранение {name} отменено. Документ по-прежнему открыт, ничего не потеряно.
Paper Бумага
Paper size: Размер бумаги:
Custom size Другой размер
diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv
index 211af3972..3d8df0657 100644
--- a/crates/ui-egui/src/i18n/sr-latn.tsv
+++ b/crates/ui-egui/src/i18n/sr-latn.tsv
@@ -2097,6 +2097,8 @@
Leave out the date and time of comments and tracked changes when saving Izostavi datum i vreme komentara i praćenih izmena pri čuvanju
The document you are editing keeps the names and dates; only the saved file leaves them out. Dokument koji uređujete zadržava imena i datume; izostavlja ih samo sačuvana datoteka.
Privacy Settings Trenutna podešavanja privatnosti
+ Waiting for the save to finish… Čeka se da se čuvanje završi…
+ Saving {name} was cancelled, so it is still open and nothing was lost. Čuvanje {name} je otkazano. Dokument je i dalje otvoren i ništa nije izgubljeno.
Paper Papir
Paper size: Veličina papira:
Custom size Prilagođena veličina
diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv
index b89e800a0..592baa1b2 100644
--- a/crates/ui-egui/src/i18n/sr.tsv
+++ b/crates/ui-egui/src/i18n/sr.tsv
@@ -2101,6 +2101,8 @@
Leave out the date and time of comments and tracked changes when saving Изостави датум и време коментара и праћених измена при чувању
The document you are editing keeps the names and dates; only the saved file leaves them out. Документ који уређујете задржава имена и датуме; изоставља их само сачувана датотека.
Privacy Settings Тренутна подешавања приватности
+ Waiting for the save to finish… Чека се да се чување заврши…
+ Saving {name} was cancelled, so it is still open and nothing was lost. Чување {name} је отказано. Документ је и даље отворен и ништа није изгубљено.
Paper Папир
Paper size: Величина папира:
Custom size Прилагођена величина
diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv
index d63c09cee..c80fe40f2 100644
--- a/crates/ui-egui/src/i18n/tr.tsv
+++ b/crates/ui-egui/src/i18n/tr.tsv
@@ -2476,6 +2476,8 @@ preview Title Örnek
Leave out the date and time of comments and tracked changes when saving Kaydederken açıklamaların ve izlenen değişikliklerin tarih ve saatini dahil etme
The document you are editing keeps the names and dates; only the saved file leaves them out. Düzenlediğiniz belge adları ve tarihleri korur; yalnızca kaydedilen dosya bunları içermez.
Privacy Settings Geçerli gizlilik ayarları
+ Waiting for the save to finish… Kaydetme işleminin bitmesi bekleniyor…
+ Saving {name} was cancelled, so it is still open and nothing was lost. {name} kaydetme işlemi iptal edildi; belge hâlâ açık ve hiçbir şey kaybolmadı.
Paper Kağıt
Paper size: Kağıt boyutu:
Custom size Özel boyut
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index 20d4c5bed..4bfb71e4d 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -2099,6 +2099,8 @@
Leave out the date and time of comments and tracked changes when saving Не зберігати дату й час приміток і відстежуваних змін
The document you are editing keeps the names and dates; only the saved file leaves them out. Документ, який ви редагуєте, зберігає імена й дати; їх не буде лише у збереженому файлі.
Privacy Settings Поточні налаштування конфіденційності
+ Waiting for the save to finish… Очікування завершення збереження…
+ Saving {name} was cancelled, so it is still open and nothing was lost. Збереження {name} скасовано. Документ досі відкритий, нічого не втрачено.
Paper Папір
Paper size: Розмір паперу:
Custom size Власний розмір
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
index e1848ba3f..45fcb708e 100644
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -2524,3 +2524,7 @@
Paper Giấy
Paper size: Khổ giấy:
The margins don't fit on a page this size; make the margins smaller first. Lề không vừa với trang có kích thước này; hãy thu nhỏ lề trước.
+
+# Web Save before New/Open (#1510)
+ Saving {name} was cancelled, so it is still open and nothing was lost. Đã hủy lưu {name}, nên tài liệu vẫn mở và không mất gì.
+ Waiting for the save to finish… Đang chờ lưu xong…
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index 7751d040b..6566f76b1 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -2099,6 +2099,8 @@
Leave out the date and time of comments and tracked changes when saving 保存时不包含批注和修订的日期与时间
The document you are editing keeps the names and dates; only the saved file leaves them out. 正在编辑的文档保留姓名和日期;只有保存的文件中不包含它们。
Privacy Settings 当前隐私设置
+ Waiting for the save to finish… 正在等待保存完成…
+ Saving {name} was cancelled, so it is still open and nothing was lost. 已取消保存 {name},文档仍处于打开状态,没有丢失任何内容。
Paper 纸张
Paper size: 纸张大小:
Custom size 自定义大小
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index e350e75db..19091d6de 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -2088,6 +2088,8 @@
Leave out the date and time of comments and tracked changes when saving 儲存時不包含註解和追蹤修訂的日期與時間
The document you are editing keeps the names and dates; only the saved file leaves them out. 正在編輯的文件會保留名稱和日期;只有儲存的檔案不包含它們。
Privacy Settings 目前的隱私設定
+ Waiting for the save to finish… 正在等待儲存完成…
+ Saving {name} was cancelled, so it is still open and nothing was lost. 已取消儲存 {name},文件仍保持開啟,沒有遺失任何內容。
Paper 紙張
Paper size: 紙張大小:
Custom size 自訂大小
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 38a34bfba..1a9fd0a72 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -22,6 +22,7 @@ macro_rules! tl {
pub mod a11y;
pub mod a11y_pane;
+pub mod async_saves;
pub mod autoscroll;
pub mod backstage;
pub mod canvas;
@@ -114,6 +115,10 @@ pub struct Services {
/// Web: hand bytes to the browser as a download. An error means no download started (the
/// browser can't tell the page whether the user then kept the file).
pub download: Option Result<(), String>>>,
+ /// Web: saves that `download` started and that finish later (the browser's save picker,
+ /// #1315). The hook starts a ticket for such a save and reports how it ended; until then the
+ /// document's save isn't confirmed, and a New/Open held back by the Save Changes prompt waits.
+ pub async_saves: Option,
/// Web: told whether the document has unsaved changes after each pass, for the browser's
/// leave-page guard (`beforeunload` runs between frames and can't ask the app).
pub on_dirty: Option>,
@@ -158,6 +163,38 @@ pub struct Services {
pub make_default_app: Option Result>>,
}
+/// How Save in the Save Changes prompt went.
+enum PromptSave {
+ /// Written, keeping everything: the held-back command may run.
+ Saved,
+ /// Not saved (a format that doesn't keep everything); the command is cancelled.
+ NotSaved,
+ /// Web: the browser's save picker finishes later; its ticket ([`async_saves`]).
+ Later(u64),
+}
+
+/// Web: a save the browser's picker hasn't finished ([`async_saves`]).
+struct SaveInFlight {
+ ticket: u64,
+ /// The file name it saves as.
+ name: String,
+ /// The document it saved ([`Session::document_id`]).
+ document: u64,
+ /// Whether it marked the document saved (a Save in a format that keeps everything).
+ marked_saved: bool,
+}
+
+/// Web: the command a Save Changes prompt holds back until its picker save ends.
+struct AwaitingSave {
+ ticket: u64,
+ then: String,
+ params: Value,
+ /// The document the prompt asked about.
+ document: u64,
+ /// Its revision once the save started: a later edit isn't in that save.
+ rev: u64,
+}
+
/// What [`Services::make_default_app`] did.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DefaultApp {
@@ -462,6 +499,10 @@ pub struct WordApp {
/// there. A file that was merely opened isn't rewritten until the user saves it: saving drops
/// whatever WordCraft can't represent (content controls, charts, macros…).
autosave_path: Option,
+ /// Web: saves the browser's picker hasn't finished yet ([`async_saves`], #1315).
+ saves_in_flight: Vec,
+ /// Web: the command a Save Changes prompt held back until its picker save ends (#1315).
+ awaiting_save: Option,
/// The file dialog the host is showing, and what its answer is for ([`file_dialogs`]).
file_dialog: Option,
/// The built-in file browser, when it is open, and the folder it showed last (#576).
@@ -556,6 +597,8 @@ impl WordApp {
read_aloud_at: None,
read_aloud_error: None,
autosave_path: None,
+ saves_in_flight: Vec::new(),
+ awaiting_save: None,
change_picture_target: None,
recipient_list_pending: false,
font_generation: wordcraft_fonts::FontDb::global().generation(),
@@ -662,7 +705,7 @@ impl WordApp {
self.dialog = dialogs::Dialog::open(name, self);
return Ok(json!({"pending": name}));
}
- if self.session.dirty && discards_document(id, ¶ms) {
+ if (self.session.dirty || self.save_unconfirmed()) && discards_document(id, ¶ms) {
let name = self.prompt_name();
self.dialog = Some(dialogs::Dialog::SaveChanges {
name,
@@ -758,6 +801,11 @@ impl WordApp {
if matches!(id, "file.save" | "file.saveAs") { self.session.password.as_ref().map(wordcraft_engine::Password::as_str) } else { None };
let opts = wordcraft_engine::cmd::file::export_options(&self.session, ¶ms);
let bytes = wordcraft_engine::io::save_bytes_with(&name, &self.session.doc, password, &opts)?;
+ // A ticket left over from an earlier call isn't this save's.
+ let saves = self.services.async_saves.clone();
+ if let Some(saves) = &saves {
+ let _ = saves.take_started();
+ }
if let Some(d) = &self.services.download
&& let Err(e) = d(&name, &bytes)
{
@@ -768,9 +816,17 @@ impl WordApp {
}
// An export is a copy, and so is a save in a format that doesn't keep everything (a
// page image, plain text): the document itself still has unsaved changes.
- if matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name) {
+ let marked_saved = matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name);
+ if marked_saved {
self.session.dirty = false;
}
+ // The browser's picker writes the file later (#1315): until it says how that went,
+ // the document's save isn't confirmed.
+ if let Some(ticket) = saves.as_ref().and_then(async_saves::AsyncSaves::take_started) {
+ let document = self.session.document_id();
+ self.saves_in_flight.push(SaveInFlight { ticket, name: name.clone(), document, marked_saved });
+ return Ok(json!({"downloaded": name, "bytes": bytes.len(), "pendingSave": ticket}));
+ }
return Ok(json!({"downloaded": name, "bytes": bytes.len()}));
}
let document = self.session.document_id();
@@ -861,7 +917,15 @@ impl WordApp {
};
}
SaveChoice::Save => match self.save_for_prompt() {
- Ok(saved) => saved,
+ Ok(PromptSave::Saved) => true,
+ Ok(PromptSave::NotSaved) => false,
+ // The browser's picker finishes later: the command waits for it (#1315).
+ Ok(PromptSave::Later(ticket)) => {
+ let rev = self.session.rev();
+ self.awaiting_save = Some(AwaitingSave { ticket, then, params, document, rev });
+ self.status(tl!("Waiting for the save to finish…"));
+ return Ok(json!({"pending": "save"}));
+ }
Err(error) => return Err(self.save_failed(&error, then, params, document)),
},
};
@@ -893,10 +957,24 @@ impl WordApp {
/// the command is cancelled and the document stays. A save that fails (no permission, the
/// file locked by another app, a missing folder) is the save's error ([`Self::save_failed`]
/// tells the user).
- fn save_for_prompt(&mut self) -> Result {
+ /// In the browser the save picker may write the file later ([`PromptSave::Later`], #1315).
+ fn save_for_prompt(&mut self) -> Result {
let v = self.execute("file.save", json!({}))?;
let saved = v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some();
- Ok(self.safely_saved(saved))
+ if !self.safely_saved(saved) {
+ return Ok(PromptSave::NotSaved);
+ }
+ Ok(match v.get("pendingSave").and_then(Value::as_u64) {
+ Some(ticket) => PromptSave::Later(ticket),
+ None => PromptSave::Saved,
+ })
+ }
+
+ /// Web: a save of this document is still in the browser's picker (#1315). The document
+ /// looks saved, but nothing is written yet, so New/Open ask first.
+ fn save_unconfirmed(&self) -> bool {
+ let document = self.session.document_id();
+ self.saves_in_flight.iter().any(|s| s.marked_saved && s.document == document)
}
/// The document's name as the Save Changes prompt shows it: its file's name, else its title.
@@ -912,13 +990,80 @@ impl WordApp {
let name = self.prompt_name();
let message = i18n::fmt(tl!("Couldn't save {name}, so it is still open and nothing was lost: {error}"), &[("name", &name), ("error", error)]);
log::error!("save from the Save Changes prompt failed: {error}");
+ self.prompt_again(message, then, params, document)
+ }
+
+ /// The Save Changes prompt comes back with `message` (also on the status bar), the held-back
+ /// command still waiting, if the document is still the one it asked about. Returns `message`.
+ fn prompt_again(&mut self, message: String, then: String, params: Value, document: u64) -> String {
self.status(message.clone());
if document == self.session.document_id() {
+ let name = self.prompt_name();
self.dialog = Some(dialogs::Dialog::SaveChanges { name, then, params, document, message: message.clone() });
}
message
}
+ /// Read how the saves that finished later ended (#1315). Called every frame.
+ /// A save that didn't write the file leaves the document it was for unsaved again (only that
+ /// document: one that has replaced it since is left alone). The command a Save Changes prompt
+ /// held back runs once its save is written and closed, if the document is still the same and
+ /// unchanged since; a cancelled or failed save brings the prompt back with the reason.
+ pub(crate) fn settle_async_saves(&mut self) {
+ let Some(saves) = self.services.async_saves.clone() else { return };
+ for (ticket, outcome) in saves.take_finished() {
+ let current = self.session.document_id();
+ if let Some(i) = self.saves_in_flight.iter().position(|s| s.ticket == ticket) {
+ let save = self.saves_in_flight.swap_remove(i);
+ match &outcome {
+ async_saves::SaveOutcome::Saved => log::info!("saved {} through the save picker", save.name),
+ async_saves::SaveOutcome::Cancelled | async_saves::SaveOutcome::Failed(_) => {
+ if save.marked_saved && save.document == current {
+ self.session.dirty = true;
+ }
+ }
+ }
+ if let async_saves::SaveOutcome::Failed(error) = &outcome {
+ let msg = i18n::fmt(tl!("Couldn't download {name}: {error}"), &[("name", &save.name), ("error", error)]);
+ log::error!("the save picker couldn't save {}: {error}", save.name);
+ self.status(msg);
+ }
+ }
+ let Some(wait) = self.awaiting_save.take_if(|w| w.ticket == ticket) else { continue };
+ if wait.document != current {
+ log::info!("the document a held-back {} was for has been replaced; dropped", wait.then);
+ continue;
+ }
+ match outcome {
+ // Only when nothing changed since and no later save of it is still unconfirmed.
+ async_saves::SaveOutcome::Saved if !self.session.dirty && self.session.rev() == wait.rev && !self.save_unconfirmed() => {
+ if let Err(e) = self.execute_user(&wait.then, wait.params) {
+ self.status(e);
+ }
+ }
+ // Edited while the picker was open (those edits may not be written yet): ask again.
+ async_saves::SaveOutcome::Saved => {
+ let name = self.prompt_name();
+ self.dialog = Some(dialogs::Dialog::SaveChanges {
+ name,
+ then: wait.then,
+ params: wait.params,
+ document: wait.document,
+ message: String::new(),
+ });
+ }
+ async_saves::SaveOutcome::Cancelled => {
+ let name = self.prompt_name();
+ let message = i18n::fmt(tl!("Saving {name} was cancelled, so it is still open and nothing was lost."), &[("name", &name)]);
+ self.prompt_again(message, wait.then, wait.params, wait.document);
+ }
+ async_saves::SaveOutcome::Failed(error) => {
+ self.save_failed(&error, wait.then, wait.params, wait.document);
+ }
+ }
+ }
+ }
+
/// Whether a save before New/Open/Close counts: it happened, and kept everything.
fn safely_saved(&mut self, saved: bool) -> bool {
if saved && self.session.dirty {
@@ -1425,6 +1570,7 @@ impl WordApp {
self.applied_editing = Some(lang);
}
options::sync(self);
+ self.settle_async_saves();
// Chinese text wants the Chinese face before the Japanese one (one glyph style per line).
// An installed CJK font is read only once CJK text is shown (#241): a CJK interface, the
// language names in Options, or a file name the caption is drawing. A file opened later
@@ -3982,6 +4128,168 @@ mod tests {
assert!(msg.contains("blocked by the browser"), "the status bar says why: {msg:?}");
}
+ /// #1315: a web app whose `download` hands the save to the browser's picker, which finishes
+ /// later. Returns the app (with unsaved typing) and the saves its download starts, plus the
+ /// last ticket started.
+ fn web_picker_app() -> (WordApp, async_saves::AsyncSaves, std::rc::Rc>) {
+ let mut a = typed();
+ let saves = async_saves::AsyncSaves::default();
+ let last = std::rc::Rc::new(std::cell::Cell::new(0));
+ let (hook, seen) = (saves.clone(), last.clone());
+ a.services.download = Some(Box::new(move |_, _| {
+ seen.set(hook.start());
+ Ok(())
+ }));
+ a.services.async_saves = Some(saves.clone());
+ (a, saves, last)
+ }
+
+ /// #1315: Save in the prompt before New waits for the picker: New runs only once the file
+ /// is written and closed.
+ #[test]
+ fn web_save_before_new_waits_for_the_picker_to_finish() {
+ let (mut a, saves, last) = web_picker_app();
+ a.run("file.new", json!({})).unwrap();
+ assert_eq!(prompt(&a), Some("saveChanges"));
+ let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
+ assert_eq!(r["pending"], "save", "{r}");
+ assert_eq!(saves.pending(), 1);
+ a.settle_async_saves();
+ assert!(body_text(&a).contains(UNSAVED), "nothing is replaced while the picker is open");
+ saves.finish(last.get(), async_saves::SaveOutcome::Saved);
+ a.settle_async_saves();
+ assert!(!body_text(&a).contains(UNSAVED), "the New ran once the save finished");
+ assert!(!a.session.dirty);
+ assert_eq!(prompt(&a), None);
+ }
+
+ /// #1315: cancelling the picker, or a write/close that fails, keeps the document: its text,
+ /// its undo, still unsaved, and the prompt back with the reason. Nothing is replaced.
+ #[test]
+ fn web_save_before_new_that_is_cancelled_or_fails_keeps_the_document() {
+ for (outcome, reason) in
+ [(async_saves::SaveOutcome::Cancelled, "cancel"), (async_saves::SaveOutcome::Failed("the disk is full".into()), "the disk is full")]
+ {
+ let (mut a, saves, last) = web_picker_app();
+ a.run("file.new", json!({})).unwrap();
+ a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
+ a.status_msg = None;
+ saves.finish(last.get(), outcome.clone());
+ a.settle_async_saves();
+ assert!(body_text(&a).contains(UNSAVED), "{outcome:?}: nothing was replaced");
+ assert!(a.session.dirty, "{outcome:?}: still unsaved");
+ assert_eq!(prompt(&a), Some("saveChanges"), "{outcome:?}: the prompt is back");
+ let message = prompt_message(&a).unwrap_or_default();
+ assert!(message.contains("still open") && message.contains(reason), "{outcome:?}: {message}");
+ let status = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
+ assert!(status.contains(reason), "{outcome:?}: the status bar says why: {status:?}");
+ // The undo history is still there.
+ a.execute("edit.undo", json!({})).unwrap();
+ assert!(!body_text(&a).contains(UNSAVED), "{outcome:?}: the typing can be undone");
+ }
+ }
+
+ /// #1315 review: the prompt's Save is in the picker (ticket 1), the user edits and saves
+ /// again (ticket 2, which marks the document saved at once). Ticket 1 ending doesn't let the
+ /// held-back New replace the document while ticket 2 hasn't written its edits.
+ #[test]
+ fn web_overlapping_saves_hold_the_replacement() {
+ let (mut a, saves, last) = web_picker_app();
+ a.run("file.new", json!({})).unwrap();
+ a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
+ let first = last.get();
+ a.run("text.insert", json!({"text": "Later edit "})).unwrap();
+ a.execute("file.save", json!({})).unwrap();
+ assert!(!a.session.dirty, "the second save marked the document saved");
+ saves.finish(first, async_saves::SaveOutcome::Saved);
+ a.settle_async_saves();
+ assert!(body_text(&a).contains("Later edit") && body_text(&a).contains(UNSAVED), "nothing was replaced");
+ assert_eq!(prompt(&a), Some("saveChanges"), "the prompt asks again");
+ }
+
+ /// #1315 review: edited while the picker was open, then the save ends: those edits aren't
+ /// in the file, so the prompt asks again instead of running New.
+ #[test]
+ fn web_save_that_ends_after_an_edit_asks_again() {
+ let (mut a, saves, last) = web_picker_app();
+ a.run("file.new", json!({})).unwrap();
+ a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
+ a.run("text.insert", json!({"text": "Later edit "})).unwrap();
+ saves.finish(last.get(), async_saves::SaveOutcome::Saved);
+ a.settle_async_saves();
+ assert!(body_text(&a).contains("Later edit"), "nothing was replaced");
+ assert!(a.session.dirty);
+ assert_eq!(prompt(&a), Some("saveChanges"));
+ assert_eq!(prompt_message(&a).as_deref(), Some(""));
+ }
+
+ /// #1315 review: the status bar says New/Open is waiting for the save; a cancelled picker
+ /// says it was cancelled, not that saving failed.
+ #[test]
+ fn web_save_prompt_says_it_waits_and_that_a_cancel_was_a_cancel() {
+ let (mut a, saves, last) = web_picker_app();
+ a.run("file.new", json!({})).unwrap();
+ a.status_msg = None;
+ a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
+ let status = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
+ assert!(status.contains("Waiting for the save"), "{status:?}");
+ saves.finish(last.get(), async_saves::SaveOutcome::Cancelled);
+ a.settle_async_saves();
+ let message = prompt_message(&a).unwrap_or_default();
+ assert!(message.contains("cancelled") && message.contains("still open"), "{message}");
+ assert!(!message.contains("Couldn't save"), "a cancel isn't a failure: {message}");
+ }
+
+ /// #1315 review: a document dropped or picked on the web (`file.open` with its bytes) waits
+ /// for the save too, then opens.
+ #[test]
+ fn web_save_before_open_opens_the_file_once_saved() {
+ let dir = scratch("web-open-after-save");
+ let bytes = std::fs::read(docx_on_disk(&dir, "Picked document")).unwrap();
+ let (mut a, saves, last) = web_picker_app();
+ let data = wordcraft_engine::cmd::insert::base64_encode(&bytes);
+ a.run("file.open", json!({"path": "picked.docx", "data": data})).unwrap();
+ assert_eq!(prompt(&a), Some("saveChanges"));
+ a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
+ a.settle_async_saves();
+ assert!(body_text(&a).contains(UNSAVED), "nothing opened while the picker is open");
+ saves.finish(last.get(), async_saves::SaveOutcome::Saved);
+ a.settle_async_saves();
+ assert!(body_text(&a).contains("Picked document"), "the picked file opened: {:?}", body_text(&a));
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// #1315: while the picker save is in flight the document's save isn't confirmed, so New
+ /// asks again instead of replacing it.
+ #[test]
+ fn web_new_while_a_picker_save_is_in_flight_asks_first() {
+ let (mut a, _saves, _last) = web_picker_app();
+ a.execute("file.save", json!({})).unwrap();
+ a.run("file.new", json!({})).unwrap();
+ assert_eq!(prompt(&a), Some("saveChanges"));
+ assert!(body_text(&a).contains(UNSAVED), "nothing was replaced");
+ }
+
+ /// #1315: a save that ends after the document was replaced neither runs the held-back New on
+ /// the new document nor marks it unsaved.
+ #[test]
+ fn web_picker_save_ending_after_the_document_changed_leaves_the_new_one_alone() {
+ for outcome in [async_saves::SaveOutcome::Saved, async_saves::SaveOutcome::Failed("gone".into())] {
+ let (mut a, saves, last) = web_picker_app();
+ a.run("file.new", json!({})).unwrap();
+ a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
+ // Another document replaces it (an agent's or a script's command) while the picker is open.
+ a.execute("file.new", json!({})).unwrap();
+ a.execute("text.insert", json!({"text": "Second document"})).unwrap();
+ a.session.dirty = false;
+ saves.finish(last.get(), outcome.clone());
+ a.settle_async_saves();
+ assert!(body_text(&a).contains("Second document"), "{outcome:?}: the held-back New didn't run on it");
+ assert!(!a.session.dirty, "{outcome:?}: the new document isn't marked unsaved");
+ assert_eq!(prompt(&a), None, "{outcome:?}");
+ }
+ }
+
/// A new document has no path: Save goes through Save As, and cancelling that cancels the New.
#[test]
fn save_as_cancelled_keeps_the_document() {
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index b3ca7d96e..b8d4d2abc 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
# UI parity with Microsoft Word
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: web picture paste only lands when the page is the edit target, #1349; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (web Save Changes prompt waits for the save picker, #1315; previously trivial: Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: web picture paste only lands when the page is the edit target, #1349; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch.
Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our
@@ -53,6 +53,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| 2026-10-13 | trivial | Clicks on ribbon and status-bar chrome that run no command button (Styles and style-set gallery tiles, tab names, empty ribbon space, View check boxes, the status bar's view buttons and language menu) no longer take keyboard focus from the page: the caret stays and typing continues, as in Word; text fields such as the font size box still take focus. Tabs, gallery tiles and the status bar's view buttons report their name and state to screen readers (#1093) |
| 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) |
| 2026-10-13 | trivial | Copy and Cut of a selection holding an equation put HTML with the equation as MathML on the system clipboard beside the text (desktop, through arboard), as MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816). Only copies containing an equation get HTML. Web: text only |
+| 2026-10-14 | trivial | Save Changes prompt on the web: Save before New/Open holds the command back until the browser's save picker has written and closed the file; a cancelled or failed picker save keeps the document and brings the prompt back with the reason (#1315) |
| 2026-10-14 | trivial | Page Setup: a Paper group with Paper size (the named sizes, the one the section has in either orientation, or Custom size) and Width and Height in the measurement unit; a wider-than-tall size turns the orientation to landscape; a size the margins don't fit keeps the dialog open with a message and applies nothing. Layout › Size ends with More Paper Sizes…, which opens it (#1433) |
| 2026-10-13 | trivial | Dark page (View › Switch Modes): highlights and character shading are dimmed toward black, keeping their hue (yellow turns a dim olive), instead of having their lightness inverted, which left yellow bright under white text; text on them draws light (white for black and Automatic), coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule. Light page, print and exports unchanged. Unlike Word, Automatic text stays white on the dark page (#1252) |
| 2026-10-13 | trivial | Full screen (#980): View › Window › Full Screen and F11 (⌃⌘F on macOS), Escape leaves it once nothing else takes it (a selection collapses first); Next Field moves to Alt+F1; keyboard counts re-measured (152 commands, 172 combinations) |
| | | | | |