feat(web): a host bridge for embedding pages, ?host=parent and data-host="page" (#1356)

A page that embeds the web build can hand it a file and get every save back instead of a browser download, and hears about unsaved changes: ?host=parent for a same-origin iframe (postMessage, the protocol the GridCraft, PdfCraft, WordCraft and CADCraft bridges use) and data-host="page" on the canvas for a page that loads the module into its own document (non-bubbling CustomEvents on the canvas). The bridge holds a Transport (Parent or Page) with one send; the pure rules are unit-tested; the hosting guide documents both modes.
This commit is contained in:
Helen Kwok
2026-11-05 16:43:01 +08:00
parent 2b74d22b0f
commit fc3679047e
7 changed files with 544 additions and 6 deletions
+3
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -31,3 +31,4 @@ wasm-bindgen = "0.2"
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
+273
View File
@@ -0,0 +1,273 @@
//! Host modes: a page that runs FigCraft (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 the other Crafting Apps' bridges.
//!
//! ## `?host=parent` — an `<iframe>` (such as a Nextcloud app)
//!
//! The framing page and FigCraft talk over `postMessage` between same-origin windows. Messages
//! are plain objects with a `type`:
//!
//! - `figcraft:ready` (to the parent): `{ version }`, sent once the app is running and
//! listening; send a file now.
//! - `figcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens exactly as a file picked with File › Open or dropped on the window
//! would (the app's own open queue, with its "Opening as a new file" card). A payload over the
//! browser's own open limit ([`figcraft_ui_egui::browser_can_read`]'s 512 MB) is refused before
//! its bytes are copied, and anything malformed is ignored.
//! - `figcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
//! every file the app hands its download service — in host mode nothing reaches the browser's
//! downloads. The parent stores it and reports failures itself. `saveAs` says whether the file
//! becomes the document's file ([`crate::hostmode::save_as`]):
//! - A Save of the file the host opened, under its own name: `false`.
//! - A document file (`.figcraft` or `.json`, the formats `file.save` and `file.saveAs` write)
//! under a name the host didn't open — Save As, or a Save of a document the host never sent
//! (an untitled one): `true`. It becomes the document's file; the next Save sends its name
//! with `false`. Names compare without case.
//! - Everything else is a copy or export that never becomes the document: `false` — rendered
//! images, SVG and PDF, `.fig` copies, and the variables exports (a `.variables.json`
//! sidecar's `.json` extension doesn't make it a document).
//! - `figcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `figcraft: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.
//!
//! ## `data-host="page"` — the page's own document
//!
//! A page that loads the module into its own document (its own `<canvas id="figcraft_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 `figcraft: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 FigCraft
//! listens on.
//!
//! In host mode the app starts with no document (the home screen) and the host sends the file to
//! edit; `?author=` names the user for comments and board notes.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use figcraft_ui_egui::FigApp;
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
/// The longest user name taken from `?author=`.
const MAX_AUTHOR_CHARS: usize = 200;
/// Files the host sent, on their way to the app's own open queue ([`figcraft_ui_egui::pending_open`]):
/// `(name, bytes)`.
pub type OpenQueue = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// 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 {
transport: Transport,
/// The file the host opened, and after a `saveAs` hand-over the document's file: the name a
/// Save under (`saveAs: false`) is decided against.
host_file: Rc<RefCell<Option<String>>>,
/// The unsaved-changes state last reported to the host.
reported: Rc<Cell<Option<bool>>>,
}
impl Bridge {
/// 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()?;
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 { transport: Transport::Parent { window, parent, origin }, host_file: Rc::default(), reported: Rc::default() })
}
/// The page bridge for `data-host="page"`: the host page runs FigCraft 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() }, host_file: Rc::default(), reported: Rc::default() }
}
/// Hear the files the host sends, and queue each one on `opens` for the app's own open path
/// (the shell drains it into [`figcraft_ui_egui::pending_open`] every frame, so a host file
/// opens like a dropped one).
pub fn listen(&self, opens: OpenQueue, ctx: egui::Context) {
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("figcraft:open") {
return;
}
deliver_open(&opens, &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();
}
}
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(&opens, &ctx, &open.detail());
});
if canvas.add_event_listener_with_callback("figcraft:open", on_open.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_open.forget();
}
}
}
}
/// `?author=`: the host's name for the user signs comments and board notes.
pub fn set_author(app: &mut FigApp, author: &str) {
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
if !author.is_empty() {
app.session.prefs.user_name = author;
}
}
pub fn post_ready(&self) {
let _ = self.transport.send("figcraft:ready", &[("version", env!("CARGO_PKG_VERSION").into())], None);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
let _ = self.transport.send("figcraft:failed", &[("error", error.into())], None);
}
/// Hand a file the app would otherwise download to the host; `false` when nothing was
/// delivered. The `saveAs` rule is this module's documentation's.
pub fn post_save(&self, path: &str, bytes: &[u8]) -> bool {
let name = file_name(path);
let host_file = self.host_file.borrow().clone();
let save_as = crate::hostmode::save_as(&name, host_file.as_deref());
// After a Save As hand-over the document's file is the new one: the next Save of it sends
// its name with `saveAs: false`.
if save_as {
*self.host_file.borrow_mut() = Some(name.clone());
}
// `figcraft:save` carries the bytes as an `ArrayBuffer`; the parent bridge transfers it.
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let fields: [(&str, JsValue); 3] = [("name", name.as_str().into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())];
self.transport.send("figcraft:save", &fields, Some(&buffer)).is_ok()
}
/// Report unsaved changes when they appear or go away, not every frame.
pub fn set_dirty(&self, dirty: bool) {
if self.reported.get() != Some(dirty) && self.transport.send("figcraft:dirty", &[("dirty", dirty.into())], None).is_ok() {
self.reported.set(Some(dirty));
}
}
}
/// The file an open message or event carries, queued for the app's own open path, or nothing when
/// the payload isn't one (no bytes, or over the open limit).
fn deliver_open(opens: &OpenQueue, ctx: &egui::Context, data: &JsValue) {
let name = crate::hostmode::open_name(string(data, "name").as_deref());
let Some(bytes) = payload_bytes(data, &name) else { return };
opens.borrow_mut().push((name, bytes));
ctx.request_repaint();
}
/// The `bytes` of an open message or event as owned bytes, refused over the browser's own open
/// limit ([`figcraft_ui_egui::browser_can_read`]) before they are copied. `bytes` is an
/// `ArrayBuffer` or `Uint8Array`.
fn payload_bytes(data: &JsValue, name: &str) -> Option<Vec<u8>> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
let len = if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
array.length() as f64
} else {
bytes.dyn_ref::<js_sys::ArrayBuffer>()?.byte_length() as f64
};
if figcraft_ui_egui::browser_can_read(name, len).is_err() {
log::warn!("host file {name} is too large to open in the browser; not opened");
return None;
}
Some(if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() { array.to_vec() } else { js_sys::Uint8Array::new(&bytes).to_vec() })
}
/// The last component of `path`, or the whole of it when there is none.
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
/// 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 {
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()
}
/// 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:?}")))
}
+109
View File
@@ -0,0 +1,109 @@
//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser
//! (like [`crate::query`]): what opts a page into hosting FigCraft in its own document, the name
//! the bridge fills in when an open request has none, and whether a handed-over file becomes the
//! document's file.
/// 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 = "Untitled.figcraft";
/// The extensions of files that can be a document's own file: the formats `file.save` and
/// `file.saveAs` write (`file.open` opens both as native documents). A `.fig` file is an export
/// copy instead, and so is everything else the app hands over (rendered images, SVG, PDF,
/// variables).
const DOC_EXTS: &[&str] = &["figcraft", "json"];
/// The suffix `variable.export`'s downloads carry: a `.json` that is a variables sidecar, not a
/// document, so its extension alone can't tell the `saveAs` decision.
const VARIABLES_SIDECAR: &str = ".variables.json";
/// 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. `?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_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()
}
/// Whether the file `name` becomes the document's own file when handed over (`saveAs: true`),
/// given the file the host opened (`None`: the host never sent one). The rule:
///
/// - A document file (`.figcraft` or `.json`, [`DOC_EXTS`]'s formats) under a name the host didn't
/// open — Save As, or a Save of a document the host never sent — is the document's new file:
/// `true`. Names compare without case.
/// - A Save of the host's own file under its name is not: `false`.
/// - Everything else is a copy or export that never becomes the document — rendered images, SVG,
/// PDF, `.fig` copies, variables — and the variables sidecar counts as one whatever its `.json`
/// extension says: `false`.
pub fn save_as(name: &str, host_file: Option<&str>) -> bool {
if name.to_ascii_lowercase().ends_with(VARIABLES_SIDECAR) {
return false;
}
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str);
if !ext.is_some_and(|e| DOC_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e))) {
return false;
}
match host_file {
Some(host) => !host.eq_ignore_ascii_case(name),
None => true,
}
}
#[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");
}
}
#[test]
fn open_names_fall_back_to_the_default_when_blank() {
assert_eq!(open_name(Some("Poster.figcraft")), "Poster.figcraft");
assert_eq!(open_name(Some(" Poster.figcraft ")), " Poster.figcraft ", "a name with its spacing is kept");
assert_eq!(open_name(Some("")), DEFAULT_OPEN_NAME);
assert_eq!(open_name(Some(" ")), DEFAULT_OPEN_NAME);
assert_eq!(open_name(None), DEFAULT_OPEN_NAME);
}
#[test]
fn a_save_of_the_host_s_file_is_not_save_as() {
assert!(!save_as("Poster.figcraft", Some("Poster.figcraft")));
assert!(!save_as("poster.FIGCRAFT", Some("Poster.figcraft")), "names compare without case");
assert!(!save_as("design.json", Some("design.json")));
}
#[test]
fn a_new_document_file_is_save_as() {
assert!(save_as("Poster 2.figcraft", Some("Poster.figcraft")));
assert!(save_as("Poster.json", Some("Poster.figcraft")), "the other format a document's file takes");
assert!(save_as("Untitled.figcraft", None), "the host never sent a file");
assert!(save_as("Poster.figcraft", Some("design.json")), "the opened .json saved as .figcraft is a new file");
}
#[test]
fn copies_and_exports_never_become_the_document_s_file() {
for name in ["Poster.png", "Poster@2x.jpg", "Poster.svg", "Poster.pdf", "Poster.fig", "Poster.csv", "Poster.variables.css"] {
assert!(!save_as(name, Some("Poster.figcraft")), "{name} is a copy");
assert!(!save_as(name, None), "{name} is a copy even without a host file");
}
}
#[test]
fn the_variables_sidecar_is_a_copy_whatever_its_extension() {
assert!(!save_as("Poster.variables.json", Some("Poster.figcraft")));
assert!(!save_as("Poster.variables.json", None));
assert!(!save_as("design.variables.json", Some("design.variables.json")));
}
}
+13 -1
View File
@@ -19,9 +19,17 @@
//! server…), but can't host a session. A page served over `https://` reaches only `wss://`
//! servers and the same computer (browsers block `ws://` from secure pages); serve it over
//! `http://` on a trusted network to reach plain `ws://` hosts on other machines.
//! - host mode: `?host=parent` (a same-origin page embedding FigCraft in an `<iframe>`) or
//! `data-host="page"` on the canvas (the page's own document) hands the page the app's files
//! instead of downloading them and reports unsaved changes (`host`);
//! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) but can't
//! host one. A page served over `https://` can only reach sessions on the same computer
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?control` installs
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it.
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it; `?host=parent`
//! (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the document to open
//! and stores what FigCraft saves. `&author=` names the user for comments and board notes in either
//! host mode.
//!
//! URL fragment: `#join=<invite link or join code>` joins that live session once the page has
//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.
@@ -30,6 +38,10 @@
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(any(target_arch = "wasm32", test))]
mod hostmode;
#[cfg(any(target_arch = "wasm32", test))]
mod kept;
#[cfg(target_arch = "wasm32")]
+57 -5
View File
@@ -32,6 +32,21 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// Host modes: `?host=parent`, a same-origin page framing FigCraft, 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 FigCraft 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();
// Files the host sends, on their way into the app's own open queue each frame.
let opens: crate::host::OpenQueue = std::rc::Rc::default();
let mut options = eframe::WebOptions::default();
// `?webgl`: force the WebGL2 backend (troubleshooting, and headless browsers whose WebGPU
// adapter request returns nothing).
@@ -44,7 +59,7 @@ pub fn start() {
.start(
canvas,
options,
Box::new(|cc| {
Box::new(move |cc| {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("figcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
}
@@ -64,6 +79,11 @@ pub fn start() {
fig.note_recovered(recovered);
if let Err(e) = crate::pasted::install(cc.egui_ctx.clone()) {
log::error!("couldn't listen for pasted images: {e:?}");
// Host mode: the host sends the file to edit, so start with no document (the
// Live sessions: the browser joins sessions a desktop app hosts; whatever arrives
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone(), host: app_bridge.clone() }));
// `?author=`: the host's name for the user signs this visit's comments and board
crate::host::Bridge::set_author(&mut fig, &author);
}
// `#join=<link>`: join that session, as opening an invite link does on the desktop.
let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
@@ -78,10 +98,21 @@ pub fn start() {
log::error!("couldn't install window.figcraft: {e:?}");
}
}
Ok(Box::new(WebShell { fig }))
// The host's files arrive on the app's own open path (a dropped file's).
if let Some(bridge) = &app_bridge {
bridge.listen(opens.clone(), cc.egui_ctx.clone());
}
Ok(Box::new(WebShell { fig, host: app_bridge, opens }))
}),
)
.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(),
@@ -141,20 +172,34 @@ 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)
}
struct WebShell {
fig: FigApp,
/// The host bridge, when the page hosts the app (`?host=parent` or `data-host="page"`).
host: Option<crate::host::Bridge>,
/// Files the host sent, waiting to join the app's own open queue.
opens: crate::host::OpenQueue,
}
impl eframe::App for WebShell {
// eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep
// autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.
/// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
/// Run the browser shell's eframe logic callback: hand the host's files to the app's open
/// queue, guard `background_tick` so hidden tabs still advance autosave, live and control
/// deadlines without painting, and report unsaved changes to the host. `FigApp::ui` does
/// not repeat it.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Every frame, hidden or not, starts here: what the last one left is what a panic keeps.
UNSAVED.with(|u| u.try_borrow_mut().map(|mut u| u.follow(&self.fig.session))).ok();
// Files the host sent join the app's own open queue (a dropped file's): the same
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
// The host hears when unsaved changes appear or go away (crate::host).
if let Some(bridge) = &self.host {
bridge.set_dirty(self.fig.session.docs.iter().any(|d| d.is_dirty()));
}
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
@@ -184,10 +229,13 @@ impl eframe::App for WebShell {
/// over their contents (pickers that need a path return nothing, the trait's defaults); files are
/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as
/// paste events with their text or image (a page can't read the clipboard when it likes).
/// saved as downloads, or handed to the host page in host mode. Copies go to the system clipboard
struct WebServices {
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
reduce_motion: Option<web_sys::MediaQueryList>,
ctx: egui::Context,
/// The host bridge in host mode: files are handed over instead of downloaded ([`crate::host`]).
host: Option<crate::host::Bridge>,
}
impl Services for WebServices {
@@ -204,11 +252,15 @@ impl Services for WebServices {
fn downloads(&self) -> bool {
true
}
/// A Blob behind a temporary `<a download>` link, clicked and removed.
/// A Blob behind a temporary `<a download>` link, clicked and removed; in host mode the file
/// goes to the host page instead ([`crate::host`]).
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
if crate::api::capture(name, bytes) {
return true;
}
if let Some(bridge) = &self.host {
return bridge.post_save(name, bytes);
}
match download(name, bytes) {
Ok(()) => true,
Err(e) => {
+88
View File
@@ -107,6 +107,94 @@ Local test: `python3 -m http.server 8767` inside the folder, then open http://lo
(`allow-popups` lets Help links open in a new tab).
- Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page.
## Host mode (`?host=parent`, or the page's own document)
A page can embed FigCraft so that it hands files over instead of downloading them and reports
unsaved changes. Two opt-in transports carry the same messages:
- **`?host=parent`** on the iframe's URL (`https://example.com/figcraft/?host=parent`): a
**same-origin** framing page talks to the app over `postMessage`. Opened outside an iframe, the
URL gives the normal app with downloads; with a parent on another origin there is no host mode
either (messages to another origin are dropped silently, so saves would be lost while the
document looked saved).
- **`data-host="page"`** (the exact value `page`) on the canvas: a page that loads the module into
its own document gets the same behaviour over DOM `CustomEvent`s on the canvas, with no iframe
(see below).
| Message | Direction | Fields |
|---|---|---|
| `figcraft:ready` | app → page | `version`: the app is running and listening; send a file now |
| `figcraft:open` | page → app | `name`, `bytes` (an `ArrayBuffer` or `Uint8Array`): open like File › Open (a payload over the browser open limit of 512 MB is refused; anything malformed is ignored) |
| `figcraft:save` | app → page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`): every file the app would otherwise download. The page stores it and reports failures itself. `saveAs` says whether the file becomes the document's file: `false` after Save of the file the page sent, under its own name; `true` for a document file (`.figcraft`, `.json`) under a name the page didn't send — Save As, or a Save of a document the page never sent — after which the next Save carries that name with `false`; `false` for copies and exports that don't become the document (rendered images, SVG, PDF, `.fig` copies, the variables exports) |
| `figcraft:dirty` | app → page | `dirty`: unsaved changes appeared or went away |
| `figcraft:failed` | app → page | `error`: the app couldn't start (no WebGPU or WebGL2, for example) |
Files reach the page through the app's download service, the path this web build saves by: Save As
(and a Save of a document with no file yet), the `.fig` copy, the export panel and its dialog,
Export frames as PDF, plugin output and the variables export (Variables panel ▸ Export…). In host
mode each arrives as `figcraft:save` instead of a download.
In host mode the app starts with no document (the home screen), so the page sends the file to edit
and guards leaving the page while changes are unsaved. `&author=` on the URL names the user for
comments and board notes.
### Same-document host mode
The host application loads the wasm-bindgen module itself and gives FigCraft 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="figcraft_canvas"`):
```html
<canvas id="figcraft_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 — `figcraft:ready`, `figcraft:save`,
`figcraft:dirty`, `figcraft:failed` — with the message fields (without the type) as the event's
`detail`, and it opens a document when the page dispatches a `figcraft: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 `figcraft:ready` while `init` is
still running, and a file sent in reply would otherwise be missed.
```js
import init from "./figcraft-web-<hash>.js"; // the wasm-bindgen glue shipped with the app
const canvas = document.getElementById("figcraft_canvas");
canvas.addEventListener("figcraft: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("figcraft:open", {
detail: { name: "Poster.figcraft", bytes },
}));
});
// Every file FigCraft would otherwise download arrives here.
canvas.addEventListener("figcraft: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("figcraft:dirty", (event) => {
unsaved = event.detail.dirty; // warn before leaving, as you like
});
canvas.addEventListener("figcraft:failed", (event) => {
showError(event.detail.error); // the app couldn't start
});
await init({ module_or_path: "./figcraft-web-<hash>_bg.wasm" });
```
- Saves are not downloads in this mode: the `bytes` in `figcraft:save` hold the file, and the host
decides where it goes.
- `?author=` on the page's URL still names the user for comments and board notes.
- 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
FigCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to