Web: page host mode, so a page can host GridCraft in its own document (#2592)

data-host="page" on the canvas opts a page that loads the module into its own document into the host bridge. The ?host=parent messages travel as non-bubbling CustomEvents on the canvas; Bridge holds a Transport (Parent or Page) and shares the save, dirty and ready logic. Unframed ?host=parent still gets no bridge. Hosting guide documents both modes.
This commit is contained in:
Helen Kwok
2026-11-05 17:03:44 +08:00
parent 1fb191571b
commit d628abb7b0
6 changed files with 279 additions and 51 deletions
+141 -43
View File
@@ -1,5 +1,10 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! GridCraft in an `<iframe>` (such as a Nextcloud app). Messages are plain objects with a `type`:
//! Host modes: a page that runs GridCraft (as an iframe, or in its own document) opens workbooks
//! in it and stores what it saves instead of the browser downloading.
//!
//! ## `?host=parent` — an `<iframe>` (such as a Nextcloud app)
//!
//! The framing page and GridCraft talk over `postMessage` between same-origin windows. Messages
//! are plain objects with a `type`:
//!
//! - `gridcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now.
//! - `gridcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
@@ -10,8 +15,21 @@
//! - `gridcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `gridcraft: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 page
//! starts with an empty workbook instead of a sample, and the parent guards leaving the page.
//! Only the parent window on the page's own origin is heard or answered.
//!
//! ## `data-host="page"` — the page's own document
//!
//! A page that loads the module into its own document (its own `<canvas id="gridcraft_canvas">`
//! with `data-host="page"`, no iframe) gets the same behaviour over DOM `CustomEvent`s on the
//! canvas instead of `postMessage`: the same four events outward (`ready`, `save`, `dirty`,
//! `failed`, with the message fields as the event's `detail`), and the page dispatches
//! `gridcraft:open` on the canvas with `detail: { name, bytes }` to open a workbook. Events don't
//! bubble, so two editors on one page can't hear each other, and the canvas is the only target
//! GridCraft listens on.
//!
//! In both host modes the page starts with an empty workbook instead of a sample, saves and PDF
//! exports are handed to the page rather than downloaded, and `?author=` names the user for new
//! notes and comments.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
@@ -21,26 +39,69 @@ use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
/// Saving one of these hands the whole workbook to the parent, which stores it: the workbook
use crate::hostmode::open_name;
/// Saving one of these hands the whole workbook to the host, which stores it: the workbook
/// counts as saved under that name, as it would after Save on the desktop.
const WORKBOOK_EXTS: &[&str] = &["xlsx"];
/// The longest user name taken from `?author=`.
const MAX_AUTHOR_CHARS: usize = 200;
/// How a host page is reached. The modes behave the same; they differ only in how an event goes
/// out ([`Transport::send`]) and how an open request comes in ([`Bridge::listen`]).
#[derive(Clone)]
enum Transport {
/// `?host=parent`: `postMessage` between same-origin windows.
Parent { window: web_sys::Window, parent: web_sys::Window, origin: String },
/// `data-host="page"`: DOM `CustomEvent`s on the editor's own canvas.
Page { canvas: web_sys::HtmlCanvasElement },
}
impl Transport {
/// Hand an event to the host. In parent mode this is a `postMessage` with a `type` field; in
/// page mode a `CustomEvent` of that name on the canvas, whose `detail` holds the fields and
/// which never bubbles (two editors on one page must not hear each other).
fn send(&self, event: &str, fields: &[(&str, JsValue)], transfer: Option<&js_sys::ArrayBuffer>) -> Result<(), String> {
match self {
Transport::Parent { parent, origin, .. } => {
let mut message_fields = Vec::with_capacity(fields.len() + 1);
message_fields.push(("type", JsValue::from_str(event)));
message_fields.extend_from_slice(fields);
let message = message(&message_fields).ok_or_else(|| "couldn't build the message".to_string())?;
let sent = match transfer {
Some(buffer) => parent.post_message_with_transfer(&message, origin, &js_sys::Array::of1(buffer)),
// `None` is only ever passed for the parent mode's small events; the page mode
// can't transfer anyway (below).
None => parent.post_message(&message, origin),
};
sent.map_err(|e| js_error(&e, "couldn't post the message"))
}
Transport::Page { canvas } => {
let detail = message(fields).ok_or_else(|| "couldn't build the event detail".to_string())?;
let init = web_sys::CustomEventInit::new();
init.set_bubbles(false);
init.set_detail(&detail);
let custom = web_sys::CustomEvent::new_with_event_init_dict(event, &init).map_err(|e| js_error(&e, "couldn't create the event"))?;
canvas.dispatch_event(&custom).map_err(|e| js_error(&e, "couldn't dispatch the event"))?;
Ok(())
}
}
}
}
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
/// Names of the files handed to the parent since the last frame.
transport: Transport,
/// Names of the files handed to the host since the last frame.
saved: Rc<RefCell<Vec<String>>>,
/// The unsaved-changes state last reported to the parent.
/// The unsaved-changes state last reported to the host.
reported: Rc<Cell<Option<bool>>>,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to).
/// The parent bridge for `?host=parent`; `None` when the page isn't framed (there is no
/// parent to talk to), so opening that URL directly stays the normal app with downloads.
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
@@ -48,10 +109,16 @@ impl Bridge {
return None;
}
let origin = window.location().origin().ok()?;
Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default() })
Some(Self { transport: Transport::Parent { window, parent, origin }, saved: Rc::default(), reported: Rc::default() })
}
/// Send saved and exported files to the parent instead of downloading them, and hear files
/// The page bridge for `data-host="page"`: the host page runs GridCraft in its own document
/// and hears the editor's DOM `CustomEvent`s on this canvas.
pub fn for_page(canvas: &web_sys::HtmlCanvasElement) -> Self {
Self { transport: Transport::Page { canvas: canvas.clone() }, saved: Rc::default(), reported: Rc::default() }
}
/// Send saved and exported files to the host instead of downloading them, and hear files
/// from it.
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
self.listen(inbox, ctx);
@@ -72,15 +139,15 @@ impl Bridge {
}
pub fn post_ready(&self) {
self.send(&[("type", "gridcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
let _ = self.transport.send("gridcraft:ready", &[("version", env!("CARGO_PKG_VERSION").into())], None);
}
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "gridcraft:failed".into()), ("error", error.into())]);
let _ = self.transport.send("gridcraft:failed", &[("error", error.into())], None);
}
/// After each frame: a workbook handed to the parent this frame counts as saved under the
/// name it was saved as, and the parent hears when unsaved changes appear or go away.
/// After each frame: a workbook handed to the host this frame counts as saved under the
/// name it was saved as, and the host hears when unsaved changes appear or go away.
pub fn sync(&self, app: &mut SheetApp) {
let saved = std::mem::take(&mut *self.saved.borrow_mut());
if let Some(name) = saved.iter().rev().find(|name| is_workbook(name))
@@ -92,55 +159,81 @@ impl Bridge {
}
}
let dirty = app.session.documents().iter().any(|d| d.is_dirty());
if self.reported.get() != Some(dirty) && self.send(&[("type", "gridcraft:dirty".into()), ("dirty", dirty.into())]) {
if self.reported.get() != Some(dirty) && self.transport.send("gridcraft:dirty", &[("dirty", dirty.into())], None).is_ok() {
self.reported.set(Some(dirty));
}
}
fn post_save(&self, 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(|| path.to_string());
let name = file_name(path);
// `gridcraft:save` carries the bytes as an `ArrayBuffer`; the parent bridge transfers it.
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[("type", "gridcraft:save".into()), ("name", name.as_str().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:?}")))?;
self.transport.send("gridcraft:save", &[("name", name.as_str().into()), ("bytes", buffer.clone().into())], Some(&buffer))?;
self.saved.borrow_mut().push(name);
Ok(())
}
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;
match &self.transport {
Transport::Parent { window, parent, origin } => {
let parent = parent.clone();
let origin = 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("gridcraft:open") {
return;
}
deliver_open(&inbox, &ctx, &data);
});
if 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();
}
}
let data = event.data();
if string(&data, "type").as_deref() != Some("gridcraft:open") {
return;
Transport::Page { canvas } => {
let on_open = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
let Some(open) = event.dyn_ref::<web_sys::CustomEvent>() else { return };
let detail = open.detail();
deliver_open(&inbox, &ctx, &detail);
});
if canvas.add_event_listener_with_callback("gridcraft:open", on_open.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_open.forget();
}
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Book.xlsx".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())
/// The file an open message or event carries, pushed onto the inbox for the next frame, like a
/// dropped file.
fn deliver_open(inbox: &Inbox, ctx: &egui::Context, data: &JsValue) {
if let Some((name, bytes)) = open_payload(data) {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
}
/// The file an open message or event carries: a usable name and the bytes.
fn open_payload(data: &JsValue) -> Option<(String, Vec<u8>)> {
let name = open_name(string(data, "name").as_deref());
let bytes = bytes(data)?;
Some((name, bytes))
}
fn is_workbook(name: &str) -> bool {
name.rsplit_once('.').is_some_and(|(_, ext)| WORKBOOK_EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e)))
}
fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
/// A message object or `CustomEvent` detail with the given fields, or `None` if one couldn't be
/// set.
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
@@ -164,3 +257,8 @@ fn bytes(data: &JsValue) -> Option<Vec<u8>> {
None
}
}
/// A `JsValue` error as a string, after `what` went wrong.
fn js_error(e: &JsValue, what: &str) -> String {
format!("{what}: {}", e.as_string().unwrap_or_else(|| format!("{e:?}")))
}
+45
View File
@@ -0,0 +1,45 @@
//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser
//! (like [`crate::prefs`]): what opts a page into hosting GridCraft in its own document, and the
//! name the bridge fills in when a host's open request has none.
/// The one `data-host` value that opts a page in: the exact string `"page"`.
pub const PAGE_HOST: &str = "page";
/// The file name used when an open request carries no usable one.
pub const DEFAULT_OPEN_NAME: &str = "Book.xlsx";
/// Does this `data-host` value ask for page host mode? Only the exact value [`PAGE_HOST`] counts;
/// anything else is ignored, so the page host mode stays opt-in.
pub fn page_host_requested(data_host: Option<&str>) -> bool {
data_host == Some(PAGE_HOST)
}
/// A usable file name for an open request: the host's name when it isn't blank, else the
/// [`DEFAULT_OPEN_NAME`].
pub fn open_name(name: Option<&str>) -> String {
name.filter(|n| !n.trim().is_empty()).unwrap_or(DEFAULT_OPEN_NAME).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_exact_page_value_opts_in() {
assert!(page_host_requested(Some("page")));
assert!(!page_host_requested(Some("Page")));
assert!(!page_host_requested(Some(" page")));
assert!(!page_host_requested(Some("parent")));
assert!(!page_host_requested(Some("")));
assert!(!page_host_requested(None));
}
#[test]
fn open_names_fall_back_to_the_default_when_blank() {
assert_eq!(open_name(Some("Budget.xlsx")), "Budget.xlsx");
assert_eq!(open_name(Some(" Sheet2!Book.xlsx ")), " Sheet2!Book.xlsx ");
assert_eq!(open_name(Some("")), DEFAULT_OPEN_NAME);
assert_eq!(open_name(Some(" ")), DEFAULT_OPEN_NAME);
assert_eq!(open_name(None), DEFAULT_OPEN_NAME);
}
}
+8 -4
View File
@@ -10,15 +10,19 @@
//!
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample;
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! workbook to open and stores what GridCraft saves (`host`), and `&author=` names the user for
//! notes and comments. `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab
//! undrawn for a host that embeds the sheet in its own window (see `Chrome`). Preferences (theme,
//! interface size, editing options…) are kept in `localStorage` (`prefs`).
//! workbook to open and stores what GridCraft saves (`host`). A page can host GridCraft in its
//! own document (no iframe) with `data-host="page"` on the canvas, talking over DOM `CustomEvent`s
//! on the canvas. `&author=` names the user for notes and comments in either host mode.
//! `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab undrawn for a host
//! that embeds the sheet in its own window (see `Chrome`). Preferences (theme, interface size,
//! editing options…) are kept in `localStorage` (`prefs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(any(target_arch = "wasm32", test))]
mod hostmode;
#[cfg(any(target_arch = "wasm32", test))]
mod prefs;
#[cfg(target_arch = "wasm32")]
mod web;
+12 -3
View File
@@ -38,8 +38,17 @@ pub fn start() {
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
// `?host=parent`: a same-origin page framing GridCraft (such as a Nextcloud app) opens
// workbooks in it and stores what it saves (`host`).
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
// workbooks in it and stores what it saves (`host`). Unframed there is no parent, so that
// URL on its own stays the normal app with downloads. `data-host="page"` on the canvas
// opts a page in instead: it runs GridCraft in its own document and talks over DOM
// `CustomEvent`s on the canvas (`host`).
let bridge = if query_param("host").as_deref() == Some("parent") {
crate::host::Bridge::new()
} else if crate::hostmode::page_host_requested(canvas.get_attribute("data-host").as_deref()) {
Some(crate::host::Bridge::for_page(&canvas))
} else {
None
};
let app_bridge = bridge.clone();
let result = eframe::WebRunner::new()
.start(
@@ -48,7 +57,7 @@ pub fn start() {
Box::new(move |cc| {
let inbox: Inbox = Inbox::default();
let mut session = Session::new();
// In host mode the parent sends the workbook to edit; no sample meanwhile.
// In host mode the host sends the workbook to edit; no sample meanwhile.
let q = if app_bridge.is_some() { String::new() } else { query() };
if let Some(name) = q.split(['?', '&']).find_map(|kv| kv.strip_prefix("sample=")) {
let _ = session.execute("file.new", serde_json::json!({"sample": name}));