mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
* #1445: Save in the close prompt that can't write the file says so instead of looking like Cancel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1445: a failed save from the close prompt brings the prompt back with the reason, Save As included Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1315: web Save before New/Open waits for the save picker; cancel or failure keeps the document Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1315: overlapping saves hold the replacement; cancel and waiting messages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Dmitrii Khlapov
Brandon Thomas
parent
52e56f8d76
commit
03ba9e483f
+5
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; 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 trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: the web's Save before New/Open waits for the save picker, #1315; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; 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 trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -153,6 +153,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back |
|
||||
| 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) |
|
||||
| 2026-10-14 | Text copied out of an exported PDF reads as shown (#1473): All Caps and Small Caps text comes out in capitals, as from Word's PDFs, and plain text after it keeps its own case (before, a capital first drawn for a lowercase letter made later capitals extract lowercase) |
|
||||
| 2026-10-14 | Web: Save in the "save changes?" prompt before New or Open now waits for the browser's save picker: the new or opened document replaces yours only once the file is written and closed. Cancelling the picker, or a write that fails, keeps your document open with its edits and undo, still unsaved, and the prompt comes back with the reason; a save that ends after another document has replaced it no longer runs the New/Open on that one or marks it unsaved, and New/Open while a picker save is still in flight ask first (#1315) |
|
||||
| 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) |
|
||||
| 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX |
|
||||
| 2026-10-14 | Hebrew and Arabic in exported PDFs copy and search as whole words (#1383): right-to-left glyphs are written left to right as they appear, the order Chrome and Edge (PDFium) and poppler read text back from (checked by hand), instead of in reading order, which they reversed (`שלום` came back as `םולש`, Arabic in pieces). Lam-alef stays whole and vowel marks follow their letters, though Chrome and poppler can still break a fully vowelled word with spaces. The page looks the same. Readers that take glyphs strictly in file order get only plain Hebrew runs right, through `/ReversedChars`; screen readers are unverified |
|
||||
| 2026-10-14 | HTML import (#1381): header cells (`<th>`) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it |
|
||||
@@ -166,7 +168,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-13 | A paragraph that ends with a page break keeps its space after on the page before, as in Word: the next page no longer starts up to a line too low, and a page break before right after it no longer leaves a blank page |
|
||||
| 2026-10-13 | Tables break across pages like Word's: a cell without its own margins lines up with its neighbours, exact row heights include the bottom margin, and a row whose margin doesn't fit at a page bottom is split with widow control instead of moving to the next page whole |
|
||||
| 2026-10-13 | Equation parsers are hardened (#1456): deeply chained input (`x^1^1…`, `a/a/…`, `\sum\sum…`) no longer builds a tree thousands of levels deep that overflows small stacks, `\text{…}` is escaped in LaTeX output so it reads back unchanged, and runs merge inside every structure. |
|
||||
| 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) |
|
||||
| 2026-10-13 | A WMF with a million object records opens in milliseconds instead of a second, and a large polygon or a small crop of a large bitmap no longer builds a second full-size copy while it is read (#1463) |
|
||||
| 2026-10-13 | PDF export (#1474): a horizontal or vertical line inside a group (a divider in a grouped box) is drawn, as on screen; it was left out because it has no height |
|
||||
| 2026-10-14 | A paragraph that runs on into the next column or onto the next page wraps around the shapes there (#1374): its continued lines are broken again for that column's wrap areas instead of running through a shape there (or around one left on the page before); the lines before, and the paragraph's own floating objects, stay where they were. Paragraphs with nothing new to wrap around are not laid out again |
|
||||
@@ -329,6 +330,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) |
|
||||
| 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) |
|
||||
| 2026-10-14 | trivial | Progress log: All Caps and Small Caps text extracts from PDFs as shown (#1473) |
|
||||
| 2026-10-14 | trivial | Progress log: the web's Save before New/Open waits for the save picker (#1315) |
|
||||
| 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) |
|
||||
| 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) |
|
||||
| 2026-10-14 | trivial | Progress log: right-to-left text in exported PDFs extracts as whole words (#1383) |
|
||||
| 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) |
|
||||
@@ -341,7 +344,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | trivial | Progress log: list label follows paragraph alignment (#1159) |
|
||||
| 2026-10-13 | trivial | Progress log: space after a page break |
|
||||
| 2026-10-13 | trivial | Progress log: table row margins and splitting |
|
||||
| 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) |
|
||||
| 2026-10-13 | trivial | Progress log: lines inside groups in PDF export (#1474) |
|
||||
| 2026-10-14 | trivial | Progress log: continued paragraphs wrap around the next column's shapes (#1374) |
|
||||
| 2026-10-13 | trivial | Progress log: PAGE fields on the pages a paragraph runs onto (#1352) |
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! The browser shell: web `Services`, drag-and-drop, the unsaved-changes guard and the eframe
|
||||
//! web runner.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
@@ -27,7 +28,7 @@ pub fn start() {
|
||||
return;
|
||||
};
|
||||
let dirty = Rc::new(Cell::new(false));
|
||||
let saves = Rc::new(PickerSaves::default());
|
||||
let saves = AsyncSaves::default();
|
||||
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
|
||||
log::error!("no unsaved-changes guard: {e}");
|
||||
}
|
||||
@@ -67,7 +68,7 @@ pub fn start() {
|
||||
if !sample {
|
||||
app.start_screen();
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox, saves }))
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -89,38 +90,6 @@ 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 {
|
||||
@@ -140,7 +109,6 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
});
|
||||
}
|
||||
self.settle_picker_saves();
|
||||
self.app.logic(ctx);
|
||||
}
|
||||
|
||||
@@ -201,7 +169,7 @@ 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>>, saves: Rc<PickerSaves>) -> Services {
|
||||
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: AsyncSaves) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let save_ctx = ctx.clone();
|
||||
Services {
|
||||
@@ -228,6 +196,7 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Rc<P
|
||||
})),
|
||||
// Exports ask for a name; the browser decides where the download goes.
|
||||
pick_save: Some(Box::new(|name: &str| Some(name.to_string()))),
|
||||
async_saves: Some(saves.clone()),
|
||||
download: Some(Box::new(move |path: &str, bytes: &[u8]| download(&saves, &save_ctx, path, bytes))),
|
||||
print: Some(Box::new(print_pdf)),
|
||||
inbox: Some(inbox),
|
||||
@@ -249,11 +218,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>>, saves: Rc<PickerSaves>) -> Result<(), String> {
|
||||
fn guard_unload(dirty: Rc<Cell<bool>>, saves: AsyncSaves) -> 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| {
|
||||
// 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<Cell<bool>>, saves: Rc<PickerSaves>) -> Result<(), Str
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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<PickerSaves>, ctx: &egui::Context, path: &str, bytes: &[u
|
||||
}
|
||||
|
||||
fn trigger_save_file_picker(
|
||||
saves: &Rc<PickerSaves>,
|
||||
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(())
|
||||
|
||||
@@ -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<Cell<…>>` 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<u64>,
|
||||
/// Tickets started and not finished.
|
||||
pending: Vec<u64>,
|
||||
/// 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<Cell<State>>);
|
||||
|
||||
impl AsyncSaves {
|
||||
fn with<R>(&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<u64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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 Потребителски размер
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 459 and column 13.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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 ユーザー定義サイズ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 564 and column 13.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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 Другой размер
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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 Прилагођена величина
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 440 and column 13.
|
@@ -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 Власний розмір
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -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…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -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 自定义大小
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -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 自訂大小
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 565 and column 13.
|
+313
-5
@@ -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<Box<dyn Fn(&str, &[u8]) -> 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<async_saves::AsyncSaves>,
|
||||
/// 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<Box<dyn Fn(bool)>>,
|
||||
@@ -158,6 +163,38 @@ pub struct Services {
|
||||
pub make_default_app: Option<Box<dyn Fn() -> Result<DefaultApp, String>>>,
|
||||
}
|
||||
|
||||
/// 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<std::path::PathBuf>,
|
||||
/// Web: saves the browser's picker hasn't finished yet ([`async_saves`], #1315).
|
||||
saves_in_flight: Vec<SaveInFlight>,
|
||||
/// Web: the command a Save Changes prompt held back until its picker save ends (#1315).
|
||||
awaiting_save: Option<AwaitingSave>,
|
||||
/// The file dialog the host is showing, and what its answer is for ([`file_dialogs`]).
|
||||
file_dialog: Option<file_dialogs::PendingDialog>,
|
||||
/// 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<bool, String> {
|
||||
/// In the browser the save picker may write the file later ([`PromptSave::Later`], #1315).
|
||||
fn save_for_prompt(&mut self) -> Result<PromptSave, String> {
|
||||
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<std::cell::Cell<u64>>) {
|
||||
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() {
|
||||
|
||||
+2
-1
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user