diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml index 08d0f4ec0..a64d729dc 100644 --- a/apps/pdfcraft-web/Cargo.toml +++ b/apps/pdfcraft-web/Cargo.toml @@ -16,5 +16,5 @@ egui = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget"] } js-sys = "0.3" diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index 1c99eb1dc..a46a4674b 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -54,12 +54,34 @@ fn main() { // eframe never raises a close request on a browser reload or tab close, so `guard_quit` never // prompts; ask the browser to confirm instead while a document has unsaved work (#812). warn_before_unload(app.unsaved_flag.clone()); + // `?host=parent`: a same-origin page embedding PdfCraft in an iframe (such + // as the Nextcloud app) passes files in and takes saves back (`host` below). + if query_param("host").as_deref() == Some("parent") + && let Some(bridge) = host::Bridge::new() + { + // `?author=`: the host's name for the user signs their comments, + // unless they chose a name in PdfCraft (the web default is "Guest"). + if let Some(author) = query_param("author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()) + && app.comment_prefs.author == "Guest" + { + app.comment_prefs.author = author.chars().take(200).collect(); + } + bridge.listen(app.startup_inbox.clone(), cc.egui_ctx.clone()); + app.host_save = Some(bridge.save_fn()); + app.host_dirty = Some(bridge.dirty_fn()); + bridge.post_ready(); + } Ok(Box::new(app)) }), ) .await; if let Err(e) = started { eframe::web_sys::console::error_1(&e); + if query_param("host").as_deref() == Some("parent") + && let Some(bridge) = host::Bridge::new() + { + bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}"))); + } } }); } @@ -97,6 +119,132 @@ async fn fetch_bytes(url: &str) -> Result, String> { Ok(js_sys::Uint8Array::new(&buf).to_vec()) } +/// The `postMessage` bridge to a same-origin parent page. Messages are plain objects with a +/// `type`: +/// +/// - `pdfcraft:ready` (to the parent): the app is listening; send files now. +/// - `pdfcraft:open` (from the parent): `{ name, bytes }`, where `bytes` is an `ArrayBuffer` or +/// `Uint8Array` holding a PDF. It opens in a tab, like a `?file=` URL. +/// - `pdfcraft:save` (to the parent): `{ name, saveAs, bytes }` when the user saves; `bytes` is +/// a transferred `ArrayBuffer`. The parent stores the file (and reports its own errors). +/// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away. +/// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start. +/// +/// Only the parent window on the app's own origin is heard or answered. +#[cfg(target_arch = "wasm32")] +mod host { + use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure}; + + #[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). + pub fn new() -> Option { + 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()?; + Some(Self { window, parent, origin }) + } + + fn post(&self, message: &js_sys::Object, transfer: Option<&JsValue>) -> Result<(), String> { + let sent = match transfer { + Some(t) => self.parent.post_message_with_transfer(message, &self.origin, &js_sys::Array::of1(t)), + None => self.parent.post_message(message, &self.origin), + }; + sent.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}"))) + } + + pub fn post_ready(&self) { + if let Some(m) = message(&[("type", "pdfcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]) { + let _ = self.post(&m, None); + } + } + + /// The app couldn't start (no WebGL, for example): let the parent say so. + pub fn post_failed(&self, error: &str) { + if let Some(m) = message(&[("type", "pdfcraft:failed".into()), ("error", error.into())]) { + let _ = self.post(&m, None); + } + } + + /// Hear `pdfcraft:open` from the parent and queue the file like a `?file=` download. + pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) { + let parent = self.parent.clone(); + let origin = self.origin.clone(); + let on_message = Closure::::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("pdfcraft:open") { + return; + } + let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "document.pdf".to_string()); + let Ok(bytes) = js_sys::Reflect::get(&data, &JsValue::from_str("bytes")) else { return }; + let bytes = if let Some(array) = bytes.dyn_ref::() { + array.to_vec() + } else if bytes.is_instance_of::() { + js_sys::Uint8Array::new(&bytes).to_vec() + } else { + return; + }; + if let Ok(mut q) = inbox.lock() { + q.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(); + } + } + + pub fn save_fn(&self) -> pdfcraft_ui_egui::HostSaveFn { + let bridge = self.clone(); + Box::new(move |name: &str, bytes: &[u8], save_as: bool| { + let buffer = js_sys::Uint8Array::from(bytes).buffer(); + let m = + message(&[("type", "pdfcraft:save".into()), ("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())]) + .ok_or_else(|| "couldn't build the message".to_string())?; + bridge.post(&m, Some(&buffer)) + }) + } + + pub fn dirty_fn(&self) -> pdfcraft_ui_egui::HostDirtyFn { + let bridge = self.clone(); + let mut last = false; + Box::new(move |dirty: bool| { + if dirty != last { + last = dirty; + if let Some(m) = message(&[("type", "pdfcraft:dirty".into()), ("dirty", dirty.into())]) { + let _ = bridge.post(&m, None); + } + } + }) + } + } + + fn message(fields: &[(&str, JsValue)]) -> Option { + 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 { + js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string() + } +} + #[cfg(not(target_arch = "wasm32"))] fn main() { eprintln!("pdfcraft-web targets wasm32: run `trunk serve` or `trunk build --release` in apps/pdfcraft-web"); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index a660f181a..a250167ed 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -404,7 +404,7 @@ impl PdfCraftApp { } #[cfg(target_arch = "wasm32")] { - let _ = (target, path); + let _ = path; if !self.flatten_fill_sign_for_save(id) { return false; } @@ -415,16 +415,32 @@ impl PdfCraftApp { return false; } }; - match download(&name, &bytes) { + // An embedding page that asked for saves (`?host=parent`) gets the bytes; otherwise + // the browser downloads them. + let to_host = self.host_save.is_some(); + let delivered = match &self.host_save { + Some(save) => save(&name, &bytes, matches!(target, SaveTarget::As)), + None => download(&name, &bytes), + }; + match delivered { Ok(()) => { let _ = self.session.mark_saved(id, bytes, None); - if let Some(doc) = self.session.get(id) { - self.views[index].document_changed(&doc.info); + if let Some(doc) = self.session.get(id) + && let Some(view) = self.views.get_mut(index) + { + view.document_changed(&doc.info); + } + // The host reports where the file went (and any failure) itself. + if !to_host { + self.notify_fmt("Downloaded {name}", &[("name", &name)]); } - self.notify_fmt("Downloaded {name}", &[("name", &name)]); after(self); true } + Err(e) if to_host => { + self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e)]); + false + } Err(e) => { self.notify_fmt("Couldn't download {name}: {e}", &[("name", &name), ("e", &e.to_string())]); false diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index db2520e7b..64661d8f7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -341,6 +341,14 @@ pub enum OsEvent { /// Returns the [`OsEvent`]s that arrived since it was last called (set by the desktop app). pub type OsEventsFn = Box Vec>; +/// Hands a saved PDF to the page embedding PdfCraft (web): `(name, bytes, save_as)`. `save_as` +/// is `true` for Save As. Set by the web app when a host page (such as a Nextcloud app) asked +/// for saves; without it the browser downloads the file. +pub type HostSaveFn = Box Result<(), String>>; + +/// Told every frame whether any tab has unsaved work, so a host page can warn before leaving. +pub type HostDirtyFn = Box; + pub struct PasswordPrompt { pub name: String, pub path: Option, @@ -435,6 +443,10 @@ pub struct PdfCraftApp { pub unsaved_flag: std::sync::Arc, /// Requests from the operating system, polled every frame (macOS Apple events). pub os_events: Option, + /// Save delivers to the embedding page instead of downloading (web, see [`HostSaveFn`]). + pub host_save: Option, + /// Reports unsaved work to the embedding page (web, see [`HostDirtyFn`]). + pub host_dirty: Option, /// A pending "save changes?" question (closing a dirty tab or quitting). pub close_request: Option, /// Save to this path instead of asking (tests and automation). @@ -707,6 +719,8 @@ impl PdfCraftApp { failed_inbox: Default::default(), unsaved_flag: Default::default(), os_events: None, + host_save: None, + host_dirty: None, close_request: None, save_override: None, props_draft: None, @@ -1895,6 +1909,12 @@ impl eframe::App for PdfCraftApp { self.guard_quit(ctx); let now = ctx.input(|i| i.time); self.autosave_tick(now); + if self.host_dirty.is_some() { + let dirty = self.first_dirty().is_some(); + if let Some(report) = self.host_dirty.as_mut() { + report(dirty); + } + } self.poll_updates(); // Shortcuts deferred last frame: the text field has taken that frame's typing since. let deferred = std::mem::take(&mut self.deferred_commands); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index f1a267ee2..99a5d9c00 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2186,3 +2186,24 @@ fn the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page() assert_eq!(page_texts(h.state()), ["Page 1"]); assert_eq!(h.state().views[0].current, 0); } + +#[test] +fn host_dirty_hears_when_unsaved_work_appears_and_goes() { + let reports = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = reports.clone(); + let mut h = harness(2, move |app| { + app.set_option("organize", "on").unwrap(); + app.host_dirty = Some(Box::new(move |dirty| sink.lock().unwrap().push(dirty))); + }); + assert_eq!(reports.lock().unwrap().last(), Some(&false), "a freshly opened document has nothing unsaved"); + h.get_by_label("Rotate clockwise").click(); + h.run_steps(3); + assert_eq!(reports.lock().unwrap().last(), Some(&true), "an edit is unsaved work"); + + let out = temp_path("host-dirty.pdf"); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.key_press_modifiers(Modifiers::COMMAND, Key::S); + h.run_steps(3); + assert_eq!(reports.lock().unwrap().last(), Some(&false), "saving clears it"); + let _ = std::fs::remove_file(out); +}