mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-27 11:57:36 -06:00
Web: a ?host=parent bridge for pages that keep WordCraft's files
A same-origin page that frames wordcraft-web (a Nextcloud app, a personal
file server) can now hand it a document and store what it saves, with the
same postMessage protocol as GridCraft's and PdfCraft's bridges:
wordcraft:ready / open / save {name, saveAs, bytes} / dirty / failed.
In host mode Save, Save As and Export go to the parent instead of the save
picker or a download (Services::host_save, with whether it was Save As).
Save writes the opened file under its own name and format, as the desktop
app does (an import-only .doc still becomes a .docx), and a Save or Save
As that keeps everything makes that name the document's file, so the next
Save goes to it. The parent guards leaving the page; ?author= names the
user for comments and tracked changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,4 +29,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
|
||||
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
|
||||
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
|
||||
//!
|
||||
//! - `wordcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now.
|
||||
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
|
||||
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
|
||||
//! every file WordCraft would otherwise download: Save, Save As, File › Export and Export as
|
||||
//! PDF. Save writes the opened file under its own name and format, as the desktop app does;
|
||||
//! `saveAs` is true for Save As and File › Export. The parent stores it and reports failures
|
||||
//! itself.
|
||||
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
|
||||
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
//!
|
||||
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
|
||||
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
/// The longest user name taken from `?author=`.
|
||||
const MAX_AUTHOR_CHARS: usize = 200;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
|
||||
/// on another origin: messages only go to this origin, so the browser would drop every save
|
||||
/// without an error while the document showed as saved.
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
// Reading another origin's location throws, which is `None` too.
|
||||
if parent.location().origin().ok()? != origin {
|
||||
return None;
|
||||
}
|
||||
Some(Self { window, parent, origin })
|
||||
}
|
||||
|
||||
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
|
||||
/// changes to it, and hear the files it sends.
|
||||
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
|
||||
self.listen(inbox, ctx);
|
||||
let bridge = self.clone();
|
||||
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
|
||||
// Anything else that would download goes to the parent too.
|
||||
let bridge = self.clone();
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
|
||||
// The browser's save picker is never used, so no save finishes later.
|
||||
services.async_saves = None;
|
||||
let bridge = self.clone();
|
||||
let reported: Rc<Cell<Option<bool>>> = Rc::default();
|
||||
services.on_dirty = Some(Box::new(move |dirty: bool| {
|
||||
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
reported.set(Some(dirty));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs comments and tracked changes.
|
||||
pub fn set_author(app: &mut WordApp, author: &str) {
|
||||
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
|
||||
if !author.is_empty() {
|
||||
app.session.author = author;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
|
||||
}
|
||||
|
||||
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let message = message(&[
|
||||
("type", "wordcraft:save".into()),
|
||||
("name", name.as_str().into()),
|
||||
("saveAs", save_as.into()),
|
||||
("bytes", buffer.clone().into()),
|
||||
])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
self.parent
|
||||
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
|
||||
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
}
|
||||
|
||||
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
|
||||
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("wordcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
|
||||
let Some(bytes) = bytes(&data) else { return };
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
|
||||
/// Post a message to the parent; false if it couldn't be sent.
|
||||
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
|
||||
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
}
|
||||
|
||||
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
|
||||
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
|
||||
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
|
||||
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
Some(array.to_vec())
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
Some(js_sys::Uint8Array::new(&bytes).to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@
|
||||
//! browser file picker (bytes arrive through `Services::inbox`); Save and Export download.
|
||||
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
|
||||
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
|
||||
//! comments and tracked changes.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
@@ -15,6 +18,8 @@ mod prefs;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod save_fallback;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -27,9 +27,15 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
|
||||
// what it saves (`host`). That page guards leaving it, so this one doesn't.
|
||||
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
|
||||
let app_bridge = bridge.clone();
|
||||
let dirty = Rc::new(Cell::new(false));
|
||||
let saves = AsyncSaves::default();
|
||||
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
|
||||
if bridge.is_none()
|
||||
&& let Err(e) = guard_unload(dirty.clone(), saves.clone())
|
||||
{
|
||||
log::error!("no unsaved-changes guard: {e}");
|
||||
}
|
||||
let mut options = eframe::WebOptions::default();
|
||||
@@ -56,12 +62,17 @@ pub fn start() {
|
||||
// The browser's locale picks the new document's paper, the units and the
|
||||
// decimal separator (#838).
|
||||
let region = wordcraft_ui_egui::i18n::system_region();
|
||||
let sample = query().contains("sample");
|
||||
// In host mode the parent sends the document to edit; no sample meanwhile.
|
||||
let sample = app_bridge.is_none() && query().contains("sample");
|
||||
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(®ion) };
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let messages = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone()));
|
||||
let mut services = services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone());
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = WordApp::new(session, services);
|
||||
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
|
||||
app.services.pasted = Some(pasted);
|
||||
app.autosave = false;
|
||||
@@ -79,10 +90,19 @@ pub fn start() {
|
||||
if !sample {
|
||||
app.start_screen();
|
||||
}
|
||||
if let Some(bridge) = &app_bridge {
|
||||
if let Some(author) = query_param("author") {
|
||||
crate::host::Bridge::set_author(&mut app, &author);
|
||||
}
|
||||
bridge.post_ready();
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox, messages, prefs }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let (Err(e), Some(bridge)) = (&result, &bridge) {
|
||||
bridge.post_failed(&format!("{e:?}"));
|
||||
}
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
@@ -96,6 +116,10 @@ fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn query_param(key: &str) -> Option<String> {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inbox.
|
||||
struct WebShell {
|
||||
|
||||
+109
-4
@@ -127,6 +127,12 @@ pub struct Services {
|
||||
/// #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, framed by a page that keeps the files (`?host=parent`): set alongside `download`,
|
||||
/// Save, Save As and Export hand the bytes to that page instead, with whether it was Save As
|
||||
/// ([`HostSaveFn`]). Save then writes the opened file under its own name and format, as on
|
||||
/// the desktop, and a Save or Save As that keeps everything makes that name the document's
|
||||
/// file, so the next Save goes to it.
|
||||
pub host_save: Option<HostSaveFn>,
|
||||
/// 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)>>,
|
||||
@@ -223,6 +229,10 @@ pub enum DefaultApp {
|
||||
/// Files delivered asynchronously.
|
||||
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// [`Services::host_save`]: `(file name, bytes, save_as)`. An error means the page didn't get
|
||||
/// the file, and the save failed.
|
||||
pub type HostSaveFn = Box<dyn Fn(&str, &[u8], bool) -> Result<(), String>>;
|
||||
|
||||
/// Whether a pasted picture belongs in the document: the page has keyboard focus, and no
|
||||
/// Backstage or dialog is open (#1349). A focused text field (comment, Find box) means the page
|
||||
/// does not have focus, so `canvas_focused` already covers it; egui's own "wants keyboard input"
|
||||
@@ -1004,7 +1014,7 @@ impl WordApp {
|
||||
}
|
||||
// Web: saving and exporting become downloads.
|
||||
if self.services.download.is_some() && matches!(id, "file.save" | "file.saveAs" | "file.exportPdf" | "file.exportPng") {
|
||||
let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.default_save_name());
|
||||
let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.web_save_name(id));
|
||||
let name = if id == "file.exportPdf" && !name.ends_with(".pdf") { format!("{name}.pdf") } else { name };
|
||||
// A save with a password encrypts this and later saves (`file.save`'s `password`).
|
||||
if matches!(id, "file.save" | "file.saveAs")
|
||||
@@ -1029,9 +1039,12 @@ impl WordApp {
|
||||
if let Some(saves) = &saves {
|
||||
let _ = saves.take_started();
|
||||
}
|
||||
if let Some(d) = &self.services.download
|
||||
&& let Err(e) = d(&name, &bytes)
|
||||
{
|
||||
let delivered = match (&self.services.host_save, &self.services.download) {
|
||||
(Some(host), _) => host(&name, &bytes, id == "file.saveAs"),
|
||||
(None, Some(download)) => download(&name, &bytes),
|
||||
(None, None) => Ok(()),
|
||||
};
|
||||
if let Err(e) = delivered {
|
||||
let msg = i18n::fmt(tl!("Couldn't download {name}: {error}"), &[("name", &name), ("error", &e)]);
|
||||
log::error!("download of {name} failed: {e}");
|
||||
self.status(msg.clone());
|
||||
@@ -1042,6 +1055,10 @@ impl WordApp {
|
||||
let marked_saved = matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name);
|
||||
if marked_saved {
|
||||
self.session.dirty = false;
|
||||
// The host page keeps the file under this name now, as a desktop save would.
|
||||
if self.services.host_save.is_some() {
|
||||
self.session.path = Some(std::path::PathBuf::from(&name));
|
||||
}
|
||||
}
|
||||
// The browser's picker writes the file later (#1315): until it says how that went,
|
||||
// the document's save isn't confirmed.
|
||||
@@ -1931,6 +1948,20 @@ impl WordApp {
|
||||
|
||||
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros),
|
||||
/// otherwise .docx.
|
||||
/// The name a browser Save or Export without a name uses. A host page that keeps the files
|
||||
/// ([`Services::host_save`]) gets Save back under the opened file's own name and format, as
|
||||
/// the desktop app writes it, except an import-only Word 97–2003 file (#2445).
|
||||
fn web_save_name(&self, id: &str) -> String {
|
||||
if id == "file.save"
|
||||
&& self.services.host_save.is_some()
|
||||
&& !self.legacy_word_path()
|
||||
&& let Some(name) = self.session.path.as_ref().and_then(|p| p.file_name())
|
||||
{
|
||||
return name.to_string_lossy().to_string();
|
||||
}
|
||||
self.default_save_name()
|
||||
}
|
||||
|
||||
fn default_save_name(&self) -> String {
|
||||
let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_string());
|
||||
match ext {
|
||||
@@ -6761,6 +6792,80 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Web inside a page that keeps the files: the app with `host_save` recording each file it
|
||||
/// is handed (name, Save As, size).
|
||||
fn host_app(fail: bool) -> (WordApp, std::rc::Rc<std::cell::RefCell<Vec<(String, bool, usize)>>>) {
|
||||
let mut a = typed();
|
||||
let got = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
let seen = got.clone();
|
||||
a.services.download = Some(Box::new(|_, _| Err("downloads go to the host page".into())));
|
||||
a.services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
|
||||
if fail {
|
||||
return Err("the page is gone".into());
|
||||
}
|
||||
seen.borrow_mut().push((name.to_string(), save_as, bytes.len()));
|
||||
Ok(())
|
||||
}));
|
||||
(a, got)
|
||||
}
|
||||
|
||||
/// Open `name` as the host page sends it (bytes of the typed document in that format).
|
||||
fn host_open(a: &mut WordApp, name: &str) {
|
||||
let bytes = wordcraft_engine::io::save_bytes_with(name, &typed().session.doc, None, &Default::default()).unwrap();
|
||||
a.execute("file.open", json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})).unwrap();
|
||||
a.run("text.insert", json!({"text": "Edited "})).unwrap();
|
||||
assert!(a.session.dirty);
|
||||
}
|
||||
|
||||
/// Save hands the opened file back under its own name and format; Save As says so and
|
||||
/// becomes the file the next Save writes.
|
||||
#[test]
|
||||
fn host_saves_go_back_to_the_opened_file() {
|
||||
let (mut a, got) = host_app(false);
|
||||
host_open(&mut a, "report.odt");
|
||||
a.execute("file.save", json!({})).unwrap();
|
||||
assert!(!a.session.dirty);
|
||||
a.run("text.insert", json!({"text": "More "})).unwrap();
|
||||
a.execute("file.saveAs", json!({"path": "report copy.docx"})).unwrap();
|
||||
assert!(!a.session.dirty);
|
||||
a.run("text.insert", json!({"text": "Again "})).unwrap();
|
||||
a.execute("file.save", json!({})).unwrap();
|
||||
let got = got.borrow();
|
||||
let calls: Vec<(&str, bool)> = got.iter().map(|(n, s, _)| (n.as_str(), *s)).collect();
|
||||
assert_eq!(calls, [("report.odt", false), ("report copy.docx", true), ("report copy.docx", false)]);
|
||||
assert!(got.iter().all(|(_, _, len)| *len > 0));
|
||||
}
|
||||
|
||||
/// A Markdown file goes back as Markdown, which doesn't keep everything: the document stays
|
||||
/// unsaved, as on the desktop.
|
||||
#[test]
|
||||
fn host_save_of_markdown_keeps_its_format_and_stays_unsaved() {
|
||||
let (mut a, got) = host_app(false);
|
||||
host_open(&mut a, "notes.md");
|
||||
a.execute("file.save", json!({})).unwrap();
|
||||
assert_eq!(got.borrow().first().map(|(n, s, _)| (n.as_str(), *s)), Some(("notes.md", false)));
|
||||
assert!(a.session.dirty);
|
||||
assert_eq!(a.session.path.as_deref(), Some(std::path::Path::new("notes.md")));
|
||||
}
|
||||
|
||||
/// An import-only Word 97–2003 file is saved as a .docx, never back over itself (#2445).
|
||||
#[test]
|
||||
fn host_save_of_a_legacy_word_file_writes_a_docx() {
|
||||
let (mut a, got) = host_app(false);
|
||||
a.session.path = Some("old.doc".into());
|
||||
a.execute("file.save", json!({})).unwrap();
|
||||
assert_eq!(got.borrow().first().map(|(n, _, _)| n.as_str()), Some("old.docx"));
|
||||
}
|
||||
|
||||
/// The page not getting the file is a failed save: the document keeps its changes.
|
||||
#[test]
|
||||
fn a_host_save_that_fails_leaves_the_document_unsaved() {
|
||||
let (mut a, _) = host_app(true);
|
||||
host_open(&mut a, "report.docx");
|
||||
assert!(a.execute("file.save", json!({})).is_err());
|
||||
assert!(a.session.dirty);
|
||||
}
|
||||
|
||||
/// Macro-enabled documents and templates keep everything (#172), so once saved in this
|
||||
/// session AutoSave covers them like a .docx.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user