From d5471bccf063ec1a4a3b664db7369bfcaea033f5 Mon Sep 17 00:00:00 2001 From: brad Date: Tue, 3 Nov 2026 18:24:57 -0600 Subject: [PATCH] Web: open and save through a framing page (?host=parent) A same-origin page that frames DeckCraft's web build with ?host=parent sends a file with deckcraft:open and gets every save back as deckcraft:save {name, saveAs, bytes} instead of a download, with deckcraft:ready, deckcraft:dirty and deckcraft:failed, as GridCraft, WordCraft and PdfCraft do. Without ?host=parent nothing changes. Save follows the desktop app. Every file from the page opens through file.adopt like File > Open on the desktop; a presentation sent with a presentation name keeps that name as its file, so Save (Ctrl+S, the File tab, the Close prompt or the command palette) writes it back under that name and format with saveAs: false. Anything else (no name, an outline, a damaged file opened as a repaired copy, a dropped file) is untitled, and its first Save is a new .deckcraft with saveAs: true, as is Save As. Exports and Print are copies (saveAs: false). Host mode starts with an empty presentation that gives way to the first file while untouched, &author= signs comments, and the URL flags are read as whole parameters so a free-text author can't set ?show. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 3 + apps/deckcraft-web/Cargo.toml | 2 +- apps/deckcraft-web/src/host.rs | 192 +++++++++++++++++++++++++ apps/deckcraft-web/src/main.rs | 7 +- apps/deckcraft-web/src/web.rs | 57 +++++++- crates/engine/src/cmd/file.rs | 16 ++- crates/ui-egui/src/app/commands.rs | 5 + crates/ui-egui/src/app/files.rs | 76 ++++++++++ crates/ui-egui/src/backstage.rs | 3 +- crates/ui-egui/src/backstage/info.rs | 14 +- crates/ui-egui/src/backstage/tests.rs | 29 ++++ crates/ui-egui/src/dialogs/body.rs | 5 +- crates/ui-egui/src/host_save_tests.rs | 198 ++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 5 +- crates/ui-egui/src/services.rs | 13 ++ packaging/web/README.md | 67 ++++++++- 16 files changed, 672 insertions(+), 20 deletions(-) create mode 100644 apps/deckcraft-web/src/host.rs create mode 100644 crates/ui-egui/src/host_save_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9315be8a6..c254c0d1f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (progress log: web `?host=parent` bridge opens and saves through a same-origin framing page; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-03 | Web: the `?host=parent` bridge (as in GridCraft, WordCraft and PdfCraft) lets a same-origin page that frames DeckCraft send a presentation and store its saves; Save writes an opened .pptx back as .pptx under its own name, as on the desktop | | 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order | | 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves | | 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged | @@ -332,6 +334,7 @@ app's JSON control channel (`deckcraft --control PORT`). |---|---|---| | 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged | | 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged | +| 2026-11-03 | trivial | Progress log: web `?host=parent` bridge to a framing page; scores unchanged | | 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained | | 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | diff --git a/apps/deckcraft-web/Cargo.toml b/apps/deckcraft-web/Cargo.toml index 541985e5b..ca04d5be9 100644 --- a/apps/deckcraft-web/Cargo.toml +++ b/apps/deckcraft-web/Cargo.toml @@ -27,4 +27,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", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget", "UrlSearchParams"] } diff --git a/apps/deckcraft-web/src/host.rs b/apps/deckcraft-web/src/host.rs new file mode 100644 index 000000000..85c357a12 --- /dev/null +++ b/apps/deckcraft-web/src/host.rs @@ -0,0 +1,192 @@ +//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed +//! DeckCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The +//! same messages as GridCraft's, WordCraft's and PdfCraft's bridges; each is a plain object with a +//! `type`: +//! +//! - `deckcraft:ready` (to the parent): `{ version }`, sent once the app is running and +//! listening; send a file now. +//! - `deckcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or +//! `Uint8Array`, name a bare file name. It opens like a file picked with File › Open on the +//! desktop: a presentation sent with its name (.pptx, .potx, .ppsx, .deckcraft) has that name +//! as its file, so Save goes back to it; an outline (.txt, .md, .rtf), a file without a name and +//! a damaged file open untitled. +//! - `deckcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for +//! every file DeckCraft would otherwise download. The parent stores it and reports failures +//! itself. `saveAs` says whether the file becomes the presentation's file: +//! - Save: `false`. It writes the opened file under its own name and format (a .pptx stays a +//! .pptx), as the desktop app does. A name the parent never sent (an untitled presentation, an +//! outline, a file sent without a name, a repaired copy of a damaged file, a file the user +//! picked or dropped) is a new file: `true`, as for Save As, named `<title>.deckcraft` as +//! Save suggests on the desktop. +//! - Save As (File › Save As as .pptx or .deckcraft, Ctrl+Shift+S): `true`. The presentation is +//! now that file; the next Save sends its name with `false`. +//! - Export (PDF, pictures, outline, GIF, handouts, a Save As type that isn't a presentation) +//! and Print: `false`. They are copies; the presentation's file stays the same. +//! - `deckcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away. +//! - `deckcraft: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 app +//! starts with an empty presentation instead of the sample, which gives way to the first file the +//! parent sends; the parent guards leaving the page, and `?author=` names the user for comments. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use deckcraft_ui_egui::{Services, SlideApp}; +use wasm_bindgen::JsCast as _; +use wasm_bindgen::JsValue; +use wasm_bindgen::closure::Closure; + +/// The longest user name taken from `?author=` (the preferences' own limit). +const MAX_AUTHOR_CHARS: usize = 256; + +#[derive(Clone)] +pub struct Bridge { + window: web_sys::Window, + parent: web_sys::Window, + origin: String, + /// Files the parent sent (the name, if it sent one), waiting for the next frame. + opened: Rc<RefCell<Vec<(Option<String>, Vec<u8>)>>>, + /// The unsaved-changes state last reported to the parent. + reported: Rc<Cell<Option<bool>>>, +} + +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 presentation 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()?; + // An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target. + if origin == "null" { + return None; + } + // Reading another origin's location throws, which is `None` too. + if parent.location().origin().ok()? != origin { + return None; + } + Some(Self { window, parent, origin, opened: Rc::default(), reported: Rc::default() }) + } + + /// Hand saved and exported files to the parent instead of downloading them, and hear the + /// files it sends. + pub fn connect(&self, services: &mut Services, ctx: egui::Context) { + self.listen(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))); + // Exports and Print are copies. + let bridge = self.clone(); + services.download = Some(Box::new(move |name: &str, bytes: &[u8]| { + if let Err(e) = bridge.post_save(name, bytes, false) { + log::error!("couldn't hand {name} to the page: {e}"); + } + })); + } + + /// `?author=`: the host's name for the user signs new comments. + pub fn set_author(app: &mut SlideApp, author: &str) { + let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect(); + if !author.is_empty() { + app.session.prefs.author = author; + } + } + + pub fn post_ready(&self) { + self.send(&[("type", "deckcraft: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", "deckcraft:failed".into()), ("error", error.into())]); + } + + /// Before each frame: open the files the parent sent. + pub fn open_files(&self, app: &mut SlideApp) { + let opened = std::mem::take(&mut *self.opened.borrow_mut()); + for (name, bytes) in opened { + app.open_from_host(name.as_deref(), &bytes); + } + } + + /// After each frame: tell the parent when unsaved changes appear or go away. + pub fn report_dirty(&self, app: &SlideApp) { + let dirty = app.session.documents().iter().any(|d| d.is_dirty()); + if self.reported.get() != Some(dirty) && self.send(&[("type", "deckcraft:dirty".into()), ("dirty", dirty.into())]) { + self.reported.set(Some(dirty)); + } + } + + 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", "deckcraft: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 `deckcraft:open` from the parent and queue the file for the next frame. + fn listen(&self, ctx: egui::Context) { + let parent = self.parent.clone(); + let origin = self.origin.clone(); + let opened = self.opened.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("deckcraft:open") { + return; + } + // No name is no file: the presentation opens untitled, never under an invented name. + let name = string(&data, "name").filter(|n| !n.trim().is_empty()); + let Some(bytes) = bytes(&data) else { return }; + opened.borrow_mut().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 + } +} diff --git a/apps/deckcraft-web/src/main.rs b/apps/deckcraft-web/src/main.rs index 2ce622e9a..8fe335eb8 100644 --- a/apps/deckcraft-web/src/main.rs +++ b/apps/deckcraft-web/src/main.rs @@ -9,15 +9,20 @@ //! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive //! asynchronously through `Services::inbox`, applied to the document, slide and operation //! that started the pick (a dismissed picker or a closed document drops the file); -//! - Save and Export trigger a browser download; +//! - Save and Export trigger a browser download (or go to the framing page with `?host=parent`); //! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox; //! - preferences are not persisted. //! //! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?blank` starts with an //! empty presentation instead of the sample deck; `?show` starts the slide show. +//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the +//! presentation to open and stores what DeckCraft saves (`host`), and `&author=` names the user +//! for comments. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +#[cfg(target_arch = "wasm32")] +mod host; #[cfg(target_arch = "wasm32")] mod presenter; #[cfg(target_arch = "wasm32")] diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs index a385d44c8..67f5157c5 100644 --- a/apps/deckcraft-web/src/web.rs +++ b/apps/deckcraft-web/src/web.rs @@ -24,8 +24,16 @@ pub fn start() { log::error!("missing <canvas id=\"{CANVAS_ID}\">"); return; }; + // `?host=parent`: a same-origin page framing DeckCraft opens presentations in it and + // stores what it saves (`host`). The presenter popup is never the framed page. + let host = query_param("host").as_deref() == Some("parent") && crate::presenter::token().is_none(); + let bridge = if host { crate::host::Bridge::new() } else { None }; + if host && bridge.is_none() { + log::warn!("?host=parent needs a framing page on the same origin; saves download instead"); + } + let app_bridge = bridge.clone(); let mut options = eframe::WebOptions::default(); - if query().contains("webgl") + if query_has("webgl") && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { create.instance_descriptor.backends = eframe::wgpu::Backends::GL; @@ -44,22 +52,38 @@ pub fn start() { return Ok(Box::new(presenter) as Box<dyn eframe::App>); } let inbox: Inbox = Inbox::default(); - let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone())); - let q = query(); - if !q.contains("blank") { + let mut services = services(inbox.clone(), cc.egui_ctx.clone()); + if let Some(bridge) = &app_bridge { + bridge.connect(&mut services, cc.egui_ctx.clone()); + } + let mut app = SlideApp::new(Session::new(), services); + // In host mode the parent sends the presentation to edit; no sample meanwhile. + if app_bridge.is_none() && !query_has("blank") { if let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) { log::error!("sample deck: {e}"); } } else if let Err(e) = app.run("file.new", serde_json::json!({})) { log::error!("new presentation: {e}"); } - if q.contains("show") { + if query_has("show") { app.start_show(0, false); } - Ok(Box::new(WebShell { app, inbox })) + if app_bridge.is_some() + && let Some(author) = query_param("author") + { + crate::host::Bridge::set_author(&mut app, &author); + } + Ok(Box::new(WebShell { app, inbox, bridge: app_bridge })) }), ) .await; + // Ready only once the runner is up: starting can still fail after the app is made. + if let Some(bridge) = &bridge { + match &result { + Ok(()) => bridge.post_ready(), + Err(e) => bridge.post_failed(&format!("{e:?}")), + } + } if let Some(el) = document.get_element_by_id(LOADING_ID) { match result { Ok(()) => el.remove(), @@ -73,17 +97,36 @@ 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) +} + +/// Whether the URL has the flag `key` (`?show`, `&blank=1`). A whole parameter, never a substring: +/// `?author=` is free text. +fn query_has(key: &str) -> bool { + web_sys::UrlSearchParams::new_with_str(&query()).is_ok_and(|q| q.has(key)) +} + /// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously) /// and feed them through the inbox. struct WebShell { app: SlideApp, inbox: Inbox, + /// The parent page that keeps the files (`?host=parent`). + bridge: Option<crate::host::Bridge>, } impl eframe::App for WebShell { - /// Delegate one eframe logic pass to the wrapped application. + /// Delegate one eframe logic pass to the wrapped application, opening the files the parent + /// page sent first and telling it about unsaved changes after. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + if let Some(bridge) = &self.bridge { + bridge.open_files(&mut self.app); + } self.app.logic(ctx); + if let Some(bridge) = &self.bridge { + bridge.report_dirty(&self.app); + } if self.app.show.is_none() && let Some(host) = self.app.services.presenter.as_mut() { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8943eb9a3..e750977d9 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -253,8 +253,15 @@ pub struct LoadedFile { #[cfg(not(target_arch = "wasm32"))] pub fn read_presentation_file(path: &str) -> Result<LoadedFile> { let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; - let (doc, damaged) = open_checked(path, &bytes)?; - let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes); + loaded_file(path, &bytes) +} + +/// Parse `bytes` as the presentation file `path`, as [`read_presentation_file`] does after +/// reading it. The web host bridge uses it for a file its page sent, which then saves back to +/// that name like a file opened on the desktop. +pub fn loaded_file(path: &str, bytes: &[u8]) -> Result<LoadedFile> { + let (doc, damaged) = open_checked(path, bytes)?; + let native = deckcraft_format::sniff(bytes) || deckcraft_pptx::sniff(bytes); Ok(LoadedFile { path: path.to_string(), doc, native, damaged }) } @@ -469,7 +476,10 @@ pub fn format_for_path(path: &str) -> &'static str { } } -fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> { +/// The active presentation is saved as it is now: no unsaved changes, and `path` (when given) +/// becomes its file. `file.save` calls it after writing; the web host bridge calls it once the +/// page has the bytes. +pub fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> { let st = s.doc_mut()?; st.saved_doc = st.doc.clone(); if path.is_some() { diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 0f7781a84..718b95ace 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -20,6 +20,11 @@ impl SlideApp { pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> { let r = if UI_COMMANDS.iter().any(|c| c.0 == id) { self.run_ui(id, ¶ms) + } else if id == "file.save" && self.services.host_save.is_some() && params.get("path").is_none() { + // A page that keeps the files (`?host=parent`): Save by id (the command palette) goes + // to the page like Ctrl+S. The engine would write to the presentation's path, which a + // browser can't. + self.save_to_host(None).map(|name| json!({"path": name})) } else { self.session.execute(id, ¶ms).map_err(|e| e.to_string()) }; diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 1e846e0db..c1c6198b4 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -462,6 +462,8 @@ impl SlideApp { Err(e) => self.set_status(e.to_string()), } } + } else if self.services.host_save.is_some() { + let _ = self.save_to_host(Some(ext)); } else if let Some(dl) = self.services.download.as_mut() && let Some(d) = self.session.active() { @@ -487,6 +489,10 @@ impl SlideApp { } pub fn save(&mut self) { + if self.services.host_save.is_some() { + let _ = self.save_to_host(None); + return; + } if self.session.active().and_then(|d| d.path.clone()).is_some() { match self.session.execute("file.save", &json!({})) { Ok(_) => self.set_status("Saved"), @@ -497,6 +503,76 @@ impl SlideApp { } } + /// Saves to the page that keeps the files ([`Services::host_save`](crate::Services::host_save)). + /// Save (`ext` `None`) writes the presentation's own file (the one the page sent, or the + /// last one saved there) in that file's format, as the desktop app does; a presentation with + /// no file of its own is a new `.deckcraft` file, the name Save suggests on the desktop. Save + /// As (`pptx`, `deckcraft`) is a new file named after the presentation. Once the page has the + /// bytes, the presentation is saved and that name is its file. Returns the name, or why it + /// wasn't saved (also shown in the status bar). + pub(super) fn save_to_host(&mut self, ext: Option<&str>) -> Result<String, String> { + use deckcraft_engine::cmd::file::{format_for_path, mark_saved, save_bytes}; + let Some(d) = self.session.active() else { return Err("no presentation is open".into()) }; + let own = d.path.as_deref().and_then(|p| std::path::Path::new(p).file_name()).map(|n| n.to_string_lossy().to_string()); + let (name, save_as) = match (ext, own) { + (None, Some(own)) => (own, false), + (ext, _) => (format!("{}.{}", d.title(), ext.unwrap_or("deckcraft")), true), + }; + let saved = save_bytes(&d.doc, format_for_path(&name)).map_err(|e| e.to_string()).and_then(|bytes| { + let host = self.services.host_save.as_mut().ok_or("no page to save to")?; + host(&name, &bytes, save_as).map_err(|e| format!("Couldn't save {name}: {e}"))?; + mark_saved(&mut self.session, Some(name.clone())).map_err(|e| e.to_string()) + }); + match saved { + Ok(()) => { + self.set_status(format!("Saved {name}")); + Ok(name) + } + Err(e) => { + self.set_status(e.clone()); + Err(e) + } + } + } + + /// A file the page that keeps the files sent (`?host=parent`), with the name it sent, if any. + /// It opens as File › Open opens it on the desktop: a presentation as itself, an outline + /// (.txt, .md, .rtf) as a new untitled presentation. The name is the presentation's file only + /// when the page sent one with a presentation extension and the file is an intact + /// presentation, so Save hands it back under that name and format; anything else (no name, an + /// outline, a damaged file opened as a repaired copy) is untitled, and its first Save is a new + /// file. An untouched untitled presentation that was the only one open (the blank one the page + /// starts with) gives way to the presentation that opened. + pub fn open_from_host(&mut self, name: Option<&str>, bytes: &[u8]) { + let blank = match self.session.documents() { + [d] if d.path.is_none() && !d.is_dirty() => Some(d.uid), + _ => None, + }; + let before = self.session.documents().len(); + let shown = name.unwrap_or("Presentation"); + let named = name.is_some_and(|n| is_presentation_name(&n.to_ascii_lowercase())); + let opened = deckcraft_engine::cmd::file::loaded_file(shown, bytes).and_then(|mut f| { + // `file.adopt` keeps the path only for a native file; a name the page didn't send + // for a presentation must not become one. + f.native &= named; + let token = self.session.stage_loaded(f); + self.session.execute("file.adopt", &json!({"path": shown, "token": token})) + }); + if let Err(e) = opened { + self.notify(e.to_string()); + } + self.drain_requests(); + if self.session.documents().len() > before + && let Some(opened) = self.session.active().map(|d| d.uid) + && let Some(i) = self.session.documents().iter().position(|d| Some(d.uid) == blank) + { + self.session.close_document(i); + if let Some(i) = self.session.documents().iter().position(|d| d.uid == opened) { + self.session.set_active(i); + } + } + } + /// Act on requests commands left for the UI. pub(super) fn drain_requests(&mut self) { for r in std::mem::take(&mut self.session.ui_requests) { diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 0fec08a52..814669728 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -249,7 +249,8 @@ fn act(app: &mut SlideApp, nav: Nav, current: Page) -> Option<Page> { match nav { Nav::Page(p) => Some(p), Nav::Save => { - let web = app.services.pick_save.is_none() && app.services.download.is_some(); + // A page that keeps the files (`host_save`) saves as the desktop does. + let web = app.services.pick_save.is_none() && app.services.download.is_some() && app.services.host_save.is_none(); let has_path = app.session.active().is_some_and(|d| d.path.is_some()); if !has_path && !web { // A presentation that was never saved goes to Save As first, as in PowerPoint. diff --git a/crates/ui-egui/src/backstage/info.rs b/crates/ui-egui/src/backstage/info.rs index 0dce74741..27c0cec6d 100644 --- a/crates/ui-egui/src/backstage/info.rs +++ b/crates/ui-egui/src/backstage/info.rs @@ -166,10 +166,20 @@ pub(super) fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: }) .response .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Save as type")); - let web = app.services.pick_save.is_none(); + // A page that keeps the files (`host_save`) saves; plain web downloads. + let web = app.services.pick_save.is_none() && app.services.host_save.is_none(); let label = if web { "Download" } else { "Save" }; let save = ui.add(egui::Button::new(RichText::new(label).color(t.accent_text)).fill(t.accent).min_size(vec2(84.0, 26.0))); - if save.on_hover_text(if web { "Download the file" } else { "Choose where to save" }).clicked() { + if save + .on_hover_text(if web { + "Download the file" + } else if app.services.host_save.is_some() { + "Save to the page" + } else { + "Choose where to save" + }) + .clicked() + { let fmt = bs.file_type; let done = if matches!(fmt, "pptx" | "deckcraft") { let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty())); diff --git a/crates/ui-egui/src/backstage/tests.rs b/crates/ui-egui/src/backstage/tests.rs index e6aa048ac..205a5522c 100644 --- a/crates/ui-egui/src/backstage/tests.rs +++ b/crates/ui-egui/src/backstage/tests.rs @@ -393,6 +393,35 @@ fn ctrl_w_asks_before_closing_unsaved_changes() { assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close")); } +/// Framed by a page that keeps the files (`?host=parent`), the Close prompt's Save is a real save: +/// when the page doesn't get the file the presentation stays open with its changes, and when it +/// does the presentation closes. (A plain browser download closes either way.) +#[test] +fn host_close_prompt_keeps_the_presentation_when_the_save_fails() { + for fail in [true, false] { + let services = Services { + host_save: Some(Box::new(move |_: &str, _: &[u8], _: bool| if fail { Err("the page is gone".into()) } else { Ok(()) })), + download: Some(Box::new(|_: &str, _: &[u8]| {})), + ..Default::default() + }; + let mut h = harness(SlideApp::new(Session::with_new(), services)); + h.state_mut().run("slide.new", json!({})).unwrap(); + h.run_steps(2); + h.key_press_modifiers(Modifiers::COMMAND, Key::W); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close")); + // Enter is the prompt's Save. + h.key_press(Key::Enter); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "fail={fail}"); + let open = h.state().session.documents().len(); + assert_eq!(open, usize::from(fail), "fail={fail}: open presentations"); + if fail { + assert!(h.state().session.active().unwrap().is_dirty()); + } + } +} + /// Info shows the presentation's counts and properties, and edits them through file.properties. #[test] fn info_shows_and_edits_the_properties() { diff --git a/crates/ui-egui/src/dialogs/body.rs b/crates/ui-egui/src/dialogs/body.rs index cb1a879e9..932cd469a 100644 --- a/crates/ui-egui/src/dialogs/body.rs +++ b/crates/ui-egui/src/dialogs/body.rs @@ -339,8 +339,9 @@ fn dlg_close(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))); if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { app.save(); - // In the browser a save is a download: the presentation stays "unsaved". - let web = app.services.pick_save.is_none() && app.services.download.is_some(); + // In the browser a save is a download: the presentation stays "unsaved". A page + // that keeps the files saves it for real, so a failed save keeps it open. + let web = app.services.pick_save.is_none() && app.services.download.is_some() && app.services.host_save.is_none(); if web || app.session.active().is_some_and(|d| !d.is_dirty()) { run(app, "file.close", json!({})); } diff --git a/crates/ui-egui/src/host_save_tests.rs b/crates/ui-egui/src/host_save_tests.rs new file mode 100644 index 000000000..8b9b03c08 --- /dev/null +++ b/crates/ui-egui/src/host_save_tests.rs @@ -0,0 +1,198 @@ +//! The web build framed by a page that keeps the files (`?host=parent`): what Save, Save As, +//! the exports and Print hand that page, under which name, and whether it is a new file. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::cell::RefCell; +use std::rc::Rc; + +use serde_json::json; + +use super::{Services, SlideApp}; +use crate::Session; + +/// What the page was handed: (name, Save As, bytes). +type Got = Rc<RefCell<Vec<(String, bool, Vec<u8>)>>>; + +/// The web app inside such a page, with `host_save` and `download` pointed at it as the bridge +/// does; `fail` makes every save miss the page. +fn host_app(fail: bool) -> (SlideApp, Got) { + let got: Got = Rc::default(); + let (saves, downloads) = (got.clone(), got.clone()); + let services = Services { + host_save: Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| { + if fail { + return Err("the page is gone".into()); + } + saves.borrow_mut().push((name.to_string(), save_as, bytes.to_vec())); + Ok(()) + })), + download: Some(Box::new(move |name: &str, bytes: &[u8]| downloads.borrow_mut().push((name.to_string(), false, bytes.to_vec())))), + ..Default::default() + }; + // The page starts with a blank presentation, as the web shell does in host mode. + (SlideApp::new(Session::with_new(), services), got) +} + +/// A .pptx with `slides` slides, as the page would send it. +fn pptx(slides: usize) -> Vec<u8> { + let mut s = Session::with_new(); + while s.doc().unwrap().doc.slides.len() < slides { + s.execute("slide.new", &json!({})).unwrap(); + } + deckcraft_engine::cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap() +} + +fn edit(app: &mut SlideApp) { + app.session.execute("slide.new", &json!({})).unwrap(); + assert!(app.session.active().unwrap().is_dirty()); +} + +fn calls(got: &Got) -> Vec<(String, bool)> { + got.borrow().iter().map(|(n, s, _)| (n.clone(), *s)).collect() +} + +fn path(app: &SlideApp) -> Option<String> { + app.session.active().and_then(|d| d.path.clone()) +} + +/// The bug this prevents: Save of a .pptx the page sent came back as `<name>.deckcraft`. Save +/// now hands the page the opened file under its own name and format, not as a new file; Save As +/// says it is a new file and becomes the file the next Save writes. +#[test] +fn host_saves_go_back_to_the_opened_file() { + let (mut app, got) = host_app(false); + app.open_from_host(Some("Lecture 3.pptx"), &pptx(2)); + assert_eq!(app.session.documents().len(), 1, "the untouched blank presentation gave way"); + edit(&mut app); + app.save(); + assert!(!app.session.active().unwrap().is_dirty()); + edit(&mut app); + app.save_as_dialog("deckcraft"); + assert!(!app.session.active().unwrap().is_dirty()); + edit(&mut app); + app.save(); + let names = [("Lecture 3.pptx", false), ("Lecture 3.deckcraft", true), ("Lecture 3.deckcraft", false)]; + assert_eq!(calls(&got), names.map(|(n, s)| (n.to_string(), s))); + // The first save is a .pptx that reopens with the edit in it. + let first = got.borrow()[0].2.clone(); + assert!(first.windows(20).any(|w| w == b"ppt/presentation.xml"), "Save writes PowerPoint, not .deckcraft"); + let reopened = deckcraft_engine::cmd::file::loaded_file("Lecture 3.pptx", &first).unwrap(); + assert_eq!(reopened.doc.slides.len(), 3); +} + +/// A presentation the page never sent is a new file, as Save As: the page must not overwrite a +/// file of the same name. The next Save goes back to it. +#[test] +fn host_save_of_an_untitled_presentation_is_save_as() { + let (mut app, got) = host_app(false); + let name = format!("{}.deckcraft", app.session.active().unwrap().title()); + edit(&mut app); + app.save(); + edit(&mut app); + app.save(); + assert_eq!(calls(&got), [(name.clone(), true), (name, false)]); +} + +/// A presentation the user dropped (or picked with File › Open) didn't come from the page, so +/// its name isn't one the page holds: Save makes a new file. +#[test] +fn a_file_the_page_never_sent_saves_as_a_new_file() { + let (mut app, got) = host_app(false); + app.open_or_insert_bytes("dropped.pptx", &pptx(1)); + app.save(); + assert_eq!(calls(&got), [("dropped.deckcraft".to_string(), true)]); +} + +/// Exports and Print are copies: named after the presentation, not a new file, and the +/// presentation keeps its own file and its unsaved changes. +#[test] +fn host_exports_and_print_are_copies() { + let (mut app, got) = host_app(false); + app.open_from_host(Some("deck.pptx"), &pptx(1)); + edit(&mut app); + assert!(app.export_as("pdf", json!({}))); + assert!(app.print_pdf(json!({}))); + assert_eq!(calls(&got), [("deck.pdf".to_string(), false), ("deck.pdf".to_string(), false)]); + assert!(app.session.active().unwrap().is_dirty()); + assert_eq!(path(&app).as_deref(), Some("deck.pptx")); +} + +/// The page not getting the file is a failed save: the presentation keeps its changes. +#[test] +fn a_host_save_that_fails_leaves_the_presentation_unsaved() { + let (mut app, _) = host_app(true); + app.open_from_host(Some("deck.pptx"), &pptx(1)); + edit(&mut app); + app.save(); + assert!(app.session.active().unwrap().is_dirty()); + assert!(app.status.as_ref().is_some_and(|(m, _)| m.contains("Couldn't save deck.pptx"))); +} + +/// Only an untouched start presentation gives way: one with changes stays open beside the file. +#[test] +fn an_edited_start_presentation_stays_open() { + let (mut app, _) = host_app(false); + edit(&mut app); + app.open_from_host(Some("deck.pptx"), &pptx(1)); + assert_eq!(app.session.documents().len(), 2); + assert_eq!(path(&app).as_deref(), Some("deck.pptx")); +} + +/// The command palette runs `file.save` by id. With a file of its own the engine would write to +/// that path, which a browser can't; in host mode it goes to the page like Ctrl+S. +#[test] +fn palette_save_goes_to_the_page() { + let (mut app, got) = host_app(false); + app.open_from_host(Some("deck.pptx"), &pptx(1)); + edit(&mut app); + let r = app.run("file.save", json!({})).unwrap(); + assert_eq!(r["path"], "deck.pptx"); + assert_eq!(calls(&got), [("deck.pptx".to_string(), false)]); + assert!(!app.session.active().unwrap().is_dirty()); +} + +/// A file the page sent without a name, or with a name that isn't a presentation's, has no file +/// of its own: Save is a new `.deckcraft`, never an overwrite of an invented `Presentation.pptx`. +#[test] +fn a_file_sent_without_a_presentation_name_saves_as_a_new_file() { + for (name, saved) in [(None, "Presentation.deckcraft"), (Some("Talk"), "Talk.deckcraft")] { + let (mut app, got) = host_app(false); + app.open_from_host(name, &pptx(2)); + assert_eq!(app.session.documents().len(), 1, "{name:?}"); + assert_eq!(app.session.active().unwrap().doc.slides.len(), 2, "{name:?}"); + assert_eq!(path(&app), None, "{name:?}"); + app.save(); + assert_eq!(calls(&got), [(saved.to_string(), true)], "{name:?}"); + } +} + +/// An outline from the page opens as a new untitled presentation, as File › Open does on the +/// desktop: the blank start presentation gives way, and Save is a new file. +#[test] +fn an_outline_from_the_page_opens_as_a_new_presentation() { + let (mut app, got) = host_app(false); + app.open_from_host(Some("notes.md"), b"# One\n\n# Two\n\n# Three\n"); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.session.active().unwrap().doc.slides.len(), 3); + assert_eq!(path(&app), None); + app.save(); + assert_eq!(calls(&got), [("notes.deckcraft".to_string(), true)]); +} + +/// A damaged file from the page opens as an untitled "(Repaired)" copy, so Save is a new file +/// and can't overwrite the page's original with the emptied media. +#[test] +fn a_damaged_file_from_the_page_saves_as_a_new_repaired_file() { + let mut p = deckcraft_model::Presentation::default(); + p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec()); + let mut bad = deckcraft_engine::cmd::file::save_bytes(&p, "deckcraft").unwrap(); + // Pictures are stored as they are, so flipping a byte breaks the entry's CRC. + let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap(); + bad[at] ^= 0xff; + let (mut app, got) = host_app(false); + app.open_from_host(Some("talk.deckcraft"), &bad); + assert_eq!(path(&app), None); + assert!(app.session.active().unwrap().title().ends_with("(Repaired)")); + app.save(); + assert_eq!(calls(&got), [("talk (Repaired).deckcraft".to_string(), true)]); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5f624d9c0..0f0e664f8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -72,7 +72,7 @@ pub use panels::notes; pub use panels::selpane; pub use panels::thumbs; pub use ribbon::qat; -pub use services::{DownloadFn, Inbox, InboxEvent, InboxFile, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn}; +pub use services::{DownloadFn, HostSaveFn, Inbox, InboxEvent, InboxFile, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn}; use state::{FilePlace, FilePurpose, ImeDescriptorKey, ImeDestination, ImeTerminalStamp}; pub use state::{RECENT_MAX, UiState, ViewMode, ZOOM_RANGE}; @@ -487,5 +487,8 @@ mod datetime_tests; #[cfg(test)] mod inbox_tests; +#[cfg(test)] +mod host_save_tests; + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index fc8eb5d96..b6f8a0f50 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -11,6 +11,10 @@ pub type OpenAsyncFn = Box<dyn FnMut(&str, u64)>; pub type DownloadFn = Box<dyn FnMut(&str, &[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 FnMut(&str, &[u8], bool) -> Result<(), String>>; + /// A file the host finished reading for the request `id` from [`SlideApp::begin_external_file`]. #[derive(Clone, Debug)] pub struct InboxFile { @@ -49,6 +53,15 @@ pub struct Services { pub open_async: Option<OpenAsyncFn>, /// Hand bytes to the user as a named file (browser download). pub download: Option<DownloadFn>, + /// Web, framed by a page that keeps the files (`?host=parent`): Save and Save As hand the + /// presentation to that page instead of downloading it, with whether it is a new file + /// ([`HostSaveFn`]). Save writes a presentation the page sent back under its own name and + /// format, as the desktop app writes back to the file it opened; a presentation the page + /// never sent (an untitled one, a repaired copy) goes as a new `.deckcraft` file, as Save + /// suggests on the desktop. Save As (.pptx or .deckcraft) is always a new file. Either way the + /// presentation is then that file, and the next Save goes to it. Exports and Print still go + /// through `download`, which the host also points at the page: they are copies. + pub host_save: Option<HostSaveFn>, pub inbox: Option<Inbox>, /// Image on the system clipboard (PNG bytes), when the host can read one. pub clipboard_image: Option<Box<dyn FnMut() -> Option<Vec<u8>>>>, diff --git a/packaging/web/README.md b/packaging/web/README.md index 3b4f9a231..e9622f5aa 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -65,12 +65,74 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo - The app fills the iframe and follows its size, so size the iframe and not the app. - Keyboard shortcuts go to the iframe after the user clicks into it, as with any embedded app. - **Cross-origin embeds** work. The web build keeps no preferences between visits; it starts - with the sample deck (or `?blank`) every time. + with the sample deck (or `?blank`) every time. Opening and saving through the page + (`?host=parent`, below) needs the same origin. - **Sandboxed iframes** need at least `sandbox="allow-scripts allow-same-origin allow-downloads allow-popups"`. Without `allow-downloads`, Save and Export (browser - downloads) are blocked. + downloads) are blocked. With `?host=parent`, `allow-scripts allow-same-origin` is enough for + opening and saving (without `allow-same-origin` the frame's origin is `null` and there is no + bridge), and `allow-downloads` isn't needed. - Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page. +## Opening and saving through the page (`?host=parent`) + +A page that keeps the files itself (a Nextcloud app, a file server) can frame DeckCraft with +`?host=parent` on the iframe `src`, hand it a presentation and take back what it saves, instead of +the user downloading. GridCraft, WordCraft and PdfCraft have the same bridge with their own prefix. +The page and DeckCraft must be on the **same origin**: DeckCraft only hears and answers its parent +window on its own origin, and without one it downloads as usual. + +Messages are plain objects with a `type`: + +| Message | Direction | Fields | +|---|---|---| +| `deckcraft:ready` | to the page | `version`. The app runs and listens: send the file now. | +| `deckcraft:open` | from the page | `name` (a bare file name, such as `Talk.pptx`), `bytes` (`ArrayBuffer` or `Uint8Array`). Opens like File › Open: a presentation (.pptx, .potx, .ppsx, .deckcraft) has that name as its file; an outline (.txt, .md, .rtf), a file sent without a name and a damaged file open untitled. | +| `deckcraft:save` | to the page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`), for every file DeckCraft would otherwise download. The page stores it. | +| `deckcraft:dirty` | to the page | `dirty`, whenever unsaved changes appear or go away. | +| `deckcraft:failed` | to the page | `error`, when the app couldn't start (no WebGPU or WebGL2). | + +`saveAs` says whether the file becomes the presentation's file, as on the desktop: + +- **Save** sends the file the page sent, under its own name and format (`Talk.pptx` comes back + as `Talk.pptx`), with `saveAs: false`. DeckCraft keeps only the part after the last `/`, so + `docs/Talk.pptx` also comes back as `Talk.pptx`. A presentation that has no file the page sent + (a new one, an outline, a file sent without a name, a file the user dropped, a repaired copy of + a damaged file) is a new file with `saveAs: true`: Ctrl+S names it `<title>.deckcraft` as Save + suggests on the desktop, and File › Save opens the Save As page first, as on the desktop. +- **Save As** (.pptx or .deckcraft) sends `saveAs: true`. The presentation is now that file; the + next Save sends its name with `saveAs: false`. +- **Exports and Print** (PDF, handouts, pictures, outline, GIF) send `saveAs: false` and are + copies: the presentation keeps its file and its unsaved changes. + +`saveAs: false` doesn't mean "safe to overwrite": an export is `false` under a name the page may +already hold (`Talk.pdf`, or `Talk.pptx` exported from a presentation that is now +`Talk.deckcraft`). Replace a file without asking only when the name is the one the page sent, or +the name of the last `saveAs: true` file it stored for this presentation; ask for anything else. +DeckCraft doesn't learn the name the page finally stores a Save As under, so a page that renames +on a conflict should map later Saves of the old name itself. + +In host mode DeckCraft starts with an empty presentation instead of the sample, and the first +presentation the page sends takes its place. The page guards leaving itself (DeckCraft adds no +guard of its own), and `&author=Name` signs the user's comments. + +```html +<iframe id="deck" src="/deckcraft/?host=parent&author=Ada" style="width: 100%; height: 720px; border: 0;"></iframe> +<script> + const frame = document.getElementById("deck"); + addEventListener("message", async (e) => { + if (e.origin !== location.origin || e.source !== frame.contentWindow) return; + if (e.data.type === "deckcraft:ready") { + const bytes = await (await fetch("/files/Talk.pptx")).arrayBuffer(); + frame.contentWindow.postMessage({ type: "deckcraft:open", name: "Talk.pptx", bytes }, location.origin, [bytes]); + } else if (e.data.type === "deckcraft:save") { + // Store e.data.bytes as e.data.name. Replace silently only when !e.data.saveAs and the name + // is the one sent (or the last saveAs file stored); ask before replacing anything else. + } + }); +</script> +``` + ## Renderer selection and fallback flags DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to @@ -82,6 +144,7 @@ DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and fall | `?webgl` | Force the WebGL2 backend (useful when a WebGPU driver misbehaves) | | `?blank` | Start with an empty presentation instead of the sample deck | | `?show` | Start the slide show immediately (kiosk/embedded decks) | +| `?host=parent` | Open and save through the framing page ([above](#opening-and-saving-through-the-page-hostparent)); `&author=` names the user | For example: `<iframe src="https://example.com/deckcraft/?webgl" ...>`.