feat(web): page host mode, so a page can host WordCraft in its own document (#4366)

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; the bridge holds a Transport (Parent or Page) with one send, and the open, save, dirty, ready and failure handling is shared. ?host=parent is checked first and unchanged, including no bridge for an unframed page. The hosting guide documents both modes.
This commit is contained in:
Helen Kwok committed 2026-11-05 17:11:53 +08:00
1 parent 7a8819c296
commit 5f9bb3f111
6 files changed
+253 -56

No files matched your search

+1 -1
View File
@@ -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", "MessageEvent", "UrlSearchParams"] }
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", "CustomEvent", "CustomEventInit"] }
+136 -50
View File
@@ -1,6 +1,11 @@
//! 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`:
//! Host modes: a page that runs WordCraft (as an `<iframe>`, or in its own document) opens
//! documents in it and stores what it saves instead of the browser downloading. The same
//! messages as GridCraft's and PdfCraft's bridges.
//!
//! ## `?host=parent` — an `<iframe>` (such as a Nextcloud app)
//!
//! The framing page and WordCraft talk over `postMessage` between same-origin windows. Messages
//! are plain objects with a `type`:
//!
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
//! listening; send a file now.
@@ -20,8 +25,21 @@
//! - `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.
//! 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="wordcraft_canvas"
//! 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 `wordcraft:open` on
//! the canvas with `detail: { name, bytes }` to open a document. Events don't bubble, so two
//! editors on one page can't hear each other, and the canvas is the only target WordCraft
//! listens on.
//!
//! In both host modes the host sends the document to edit, so no sample opens, saves are handed
//! to the host rather than downloaded, the host guards leaving the page, and `?author=` names the
//! user for comments and tracked changes.
use std::cell::Cell;
use std::rc::Rc;
@@ -34,17 +52,61 @@ use wordcraft_ui_egui::{Inbox, Services, WordApp};
/// 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` whose object carries 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). `transfer` hands an `ArrayBuffer` to the parent instead of copying it (parent
/// mode only; the page's document already shares the buffer's bytes).
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 => 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();
// The builder methods (`bubbles(..)`, `detail(..)`) are deprecated in web-sys
// 0.3.106 and fail clippy -D warnings; use the setters.
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"))?;
// A listener calling `preventDefault` would be the bool; not a send failure.
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,
transport: Transport,
}
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.
/// 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, 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()?;
@@ -60,16 +122,22 @@ impl Bridge {
if parent.location().origin().ok()? != origin {
return None;
}
Some(Self { window, parent, origin })
Some(Self { transport: Transport::Parent { window, parent, origin } })
}
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
/// The page bridge for `data-host="page"`: the host page runs WordCraft 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() } }
}
/// Hand saved and exported files to the host 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.
// Anything else that would download goes to the host 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.
@@ -77,7 +145,7 @@ impl Bridge {
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())]) {
if reported.get() != Some(dirty) && bridge.transport.send("wordcraft:dirty", &[("dirty", dirty.into())], None).is_ok() {
reported.set(Some(dirty));
}
}));
@@ -92,58 +160,71 @@ impl Bridge {
}
pub fn post_ready(&self) {
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
let _ = self.transport.send("wordcraft:ready", &[("version", env!("CARGO_PKG_VERSION").into())], None);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
let _ = self.transport.send("wordcraft:failed", &[("error", error.into())], None);
}
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());
// `wordcraft:save` carries the bytes as an `ArrayBuffer`; the parent bridge transfers it.
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:?}")))
let fields: [(&str, JsValue); 3] = [("name", name.as_str().into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())];
self.transport.send("wordcraft:save", &fields, Some(&buffer))
}
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
/// Hear `wordcraft:open` — from the same-origin parent (a `message` on the window) or from
/// the host document (a `wordcraft:open` event on the canvas) — 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;
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("wordcraft: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("wordcraft:open") {
return;
Transport::Page { canvas } => {
let on_open = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
// A plain `Event` of the same name carries no detail; only a `CustomEvent`.
let Some(open) = event.dyn_ref::<web_sys::CustomEvent>() else { return };
deliver_open(&inbox, &ctx, &open.detail());
});
if canvas.add_event_listener_with_callback("wordcraft: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(|| "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())
}
}
/// 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) {
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();
}
/// A message object or `CustomEvent` detail with the given fields, or `None` if one couldn't be
/// built (a field that isn't a valid property name, say).
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
@@ -167,3 +248,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:?}")))
}
+22
View File
@@ -0,0 +1,22 @@
//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser
//! (like [`crate::prefs`]): what opts a page into hosting WordCraft in its own document.
/// Does this `data-host` value ask for page host mode? Only the exact value `page` counts;
/// anything else is ignored, so the page host mode stays opt-in. `?host=parent`, the parent
/// bridge's URL opt-in, is answered before this is asked.
pub fn page_host_requested(data_host: Option<&str>) -> bool {
data_host == Some("page")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_exact_page_value_opts_in() {
assert!(page_host_requested(Some("page")));
for off in [None, Some(""), Some("Page"), Some("PAGE"), Some(" page"), Some("page "), Some("parent")] {
assert!(!page_host_requested(off), "{off:?} must keep the page host mode off");
}
}
}
+6 -2
View File
@@ -9,10 +9,14 @@
//! 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.
//! document to open and stores what WordCraft saves (`host`). A page can also host WordCraft 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 comments and tracked changes in
//! either host mode.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
mod hostmode;
#[cfg(any(target_arch = "wasm32", test))]
mod prefs;
#[cfg(any(target_arch = "wasm32", test))]
+12 -3
View File
@@ -28,9 +28,18 @@ 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 };
// Host modes: `?host=parent`, a same-origin page framing WordCraft, opens documents 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 WordCraft in its own document and talks over DOM `CustomEvent`s
// on the canvas (`host`). The host guards leaving the page, so this one doesn't.
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 dirty = Rc::new(Cell::new(false));
let saves = AsyncSaves::default();
+76
View File
@@ -73,6 +73,82 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo
downloads) are blocked.
- Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page.
## The parent bridge (`?host=parent`)
A page framing WordCraft in a **same-origin** iframe can add `?host=parent` to the iframe's URL
(`https://example.com/wordcraft/?host=parent`) and have WordCraft hand its files over instead of
downloading them. The two sides talk `postMessage` with plain window messages that carry a `type`:
| Message | Direction | Fields |
|---|---|---|
| `wordcraft:ready` | app → page | `version`: the app is running and listening; send a file now |
| `wordcraft:open` | page → app | `name`, `bytes` (an `ArrayBuffer` or `Uint8Array`): open like File › Open |
| `wordcraft:save` | app → page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`): every file the app would otherwise download (Save, Save As, Export as PDF, Export Page as PNG). The page stores it and reports failures itself. `saveAs` says whether the file becomes the document's file: `false` after Save (the page's own file, written under its name) and for exports that are only copies (PDF, Markdown, plain text); `true` after Save As and for exports to a format that keeps everything (.docx, .odt, .rtf), after which the next Save carries that name with `false` |
| `wordcraft:dirty` | app → page | `dirty`: unsaved changes appeared or went away |
| `wordcraft:failed` | app → page | `error`: the app couldn't start (no WebGPU or WebGL2, for example) |
In host mode the app opens no sample document, so the framing page should send the file to edit
and guard leaving the page while changes are unsaved. `&author=` on the iframe's URL names the
user for the comments and tracked changes the app creates. Opening `?host=parent` outside an
iframe gives the normal app with downloads.
## Same-document host mode (no iframe)
A host application can also run WordCraft from its own document: it loads the wasm-bindgen module
itself and gives WordCraft one of its own canvases, so there is no iframe and no `?host=parent`.
It opts in with `data-host="page"` on that canvas — the exact value `page` — and the canvas keeps
the id the app looks up (`id="wordcraft_canvas"`).
```html
<canvas id="wordcraft_canvas" data-host="page" style="width: 100%; height: 720px; display: block;"></canvas>
```
The page and the app then talk with DOM `CustomEvent`s on the canvas instead of `postMessage`.
The app dispatches the same messages it would post to a parent — `wordcraft:ready`,
`wordcraft:save`, `wordcraft:dirty`, `wordcraft:failed` — with the message fields (without the
`type`) as the event's `detail`, and it opens a document when the page dispatches a
`wordcraft:open` event with `detail: { name, bytes }`. Events do not bubble, so listen on the
canvas itself (two editors on one page can't hear each other).
Add the listeners before `init`: the app announces itself with `wordcraft:ready` while `init` is
still running, and a file sent in reply would otherwise be missed.
```js
import init from "./wordcraft-web-<hash>.js"; // the wasm-bindgen glue shipped with the app
const canvas = document.getElementById("wordcraft_canvas");
canvas.addEventListener("wordcraft:ready", async () => {
// The app is listening; hand it a document now, if you have one.
const bytes = await documentBytes(); // an ArrayBuffer or Uint8Array
canvas.dispatchEvent(new CustomEvent("wordcraft:open", {
detail: { name: "Report.docx", bytes },
}));
});
// Every file WordCraft would otherwise download (Save, Save As, Export as PDF) arrives here.
canvas.addEventListener("wordcraft:save", (event) => {
const { name, saveAs, bytes } = event.detail; // bytes is an ArrayBuffer
// Store the file under `name`; `saveAs` says whether it becomes the document's file.
// Report your own errors.
storeFile(name, bytes, saveAs);
});
canvas.addEventListener("wordcraft:dirty", (event) => {
unsaved = event.detail.dirty; // warn before leaving, as you like
});
canvas.addEventListener("wordcraft:failed", (event) => {
showError(event.detail.error); // the app couldn't start
});
await init({ module_or_path: "./wordcraft-web-<hash>_bg.wasm" });
```
- Saves are not downloads in this mode: the `bytes` in `wordcraft:save` hold the saved document,
and the host decides where the file goes.
- `?author=` on the page's URL still names the user for new comments and tracked changes.
- An unframed page that uses `?host=parent` keeps the normal app with downloads; only the exact
`data-host` value `page` turns this mode on.
## Renderer selection and fallback flags
WordCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to