mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-07 02:51:33 +00:00
Merge pull request #2593 from helenkwok/web/host-page
Web: page host mode, so a page can host GridCraft in its own document (#2592)
This commit is contained in:
7 files changed
+204
-100
No files matched your search
Generated
+10
@@ -995,6 +995,15 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -2186,6 +2195,7 @@ dependencies = [
|
||||
name = "gridcraft-web"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"gridcraft-engine",
|
||||
|
||||
@@ -16,6 +16,8 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -26,4 +28,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", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage", "CustomEvent", "CustomEventInit"] }
|
||||
@@ -1,57 +1,64 @@
|
||||
//! 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. The messages and the two
|
||||
//! transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the other Craft
|
||||
//! apps' bridges, with the `gridcraft:` prefix. This module is GridCraft's part of it.
|
||||
//!
|
||||
//! - `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
|
||||
//! `Uint8Array`. It opens like a workbook picked with File › Open or dropped on the window.
|
||||
//! - `gridcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every
|
||||
//! file GridCraft would otherwise download: Save and Save As (an .xlsx workbook) and Export
|
||||
//! as PDF. The parent stores it and reports failures itself.
|
||||
//! - `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.
|
||||
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
|
||||
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
|
||||
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
|
||||
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
|
||||
//! - `gridcraft:ready` once the app is listening; send a file now. GridCraft reads bytes, so it
|
||||
//! says `file: false` and a host sends `bytes`.
|
||||
//! - `gridcraft:open` (from the host): `{ name, bytes }`. It opens like a workbook picked with
|
||||
//! File › Open or dropped on the window; a file over [`MAX_OPEN_BYTES`] is refused before it is
|
||||
//! copied.
|
||||
//! - `gridcraft:save` (to the host): `{ name, saveAs, bytes }` for every file GridCraft would
|
||||
//! otherwise download: Save and Save As (an .xlsx workbook) and Export as PDF. The host stores
|
||||
//! it and reports failures itself. GridCraft doesn't tell the host which saves make a file the
|
||||
//! document, so `saveAs` is always `false`.
|
||||
//! - `gridcraft:dirty` whenever unsaved changes appear or go away, `gridcraft:failed` 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.
|
||||
//! 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::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use gridcraft_ui_egui::{Inbox, Services, SheetApp};
|
||||
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
|
||||
/// counts as saved under that name, as it would after Save on the desktop.
|
||||
use crate::hostmode::open_name;
|
||||
|
||||
/// The app id in the host messages (`gridcraft:ready`).
|
||||
const APP: &str = "gridcraft";
|
||||
|
||||
/// Saved files that count as a workbook: the document is then clean under the name it was saved as.
|
||||
const WORKBOOK_EXTS: &[&str] = &["xlsx"];
|
||||
|
||||
/// The largest workbook taken from the host page, refused before it is copied.
|
||||
const MAX_OPEN_BYTES: u64 = 512 << 20;
|
||||
|
||||
/// The longest user name taken from `?author=`.
|
||||
const MAX_AUTHOR_CHARS: usize = 200;
|
||||
|
||||
#[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.
|
||||
host: Rc<Host>,
|
||||
/// 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.
|
||||
reported: Rc<Cell<Option<bool>>>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `None` when the page isn't framed (there is no parent to talk to).
|
||||
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()?;
|
||||
Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default() })
|
||||
/// The bridge for this page, or `None` when GridCraft isn't hosted: no `?host=parent` and no
|
||||
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
|
||||
/// with downloads), or a parent on another origin.
|
||||
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
Some(Self { host: Rc::new(Host::connect(APP, canvas)?), saved: Rc::default() })
|
||||
}
|
||||
|
||||
/// Send saved and exported files to the parent instead of downloading them, and hear files
|
||||
/// 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 +79,16 @@ impl Bridge {
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
self.send(&[("type", "gridcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
self.send(&[("type", "gridcraft:failed".into()), ("error", error.into())]);
|
||||
let _ = self.host.post_failed(error, 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 (the
|
||||
/// crate does not repeat a state it already reported).
|
||||
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))
|
||||
@@ -91,76 +99,45 @@ impl Bridge {
|
||||
doc.title = name.clone();
|
||||
}
|
||||
}
|
||||
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())]) {
|
||||
self.reported.set(Some(dirty));
|
||||
}
|
||||
self.host.post_dirty(app.session.documents().iter().any(|d| d.is_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 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:?}")))?;
|
||||
let name = file_name(path);
|
||||
self.host.post_save(&name, bytes, None, false)?;
|
||||
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;
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
deliver_open(&inbox, &ctx, &name, &data);
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("gridcraft:open") {
|
||||
return;
|
||||
}
|
||||
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 carries, pushed onto the inbox for the next frame, like a dropped
|
||||
/// file. A host's file is untrusted input: an oversized one is refused before it is copied into
|
||||
/// the app's memory (the data's own length, not the host's claim).
|
||||
fn deliver_open(inbox: &Inbox, ctx: &egui::Context, name: &str, data: &FileData) {
|
||||
let FileData::Bytes(array) = data else {
|
||||
log::warn!("host file {name} came as a File; GridCraft reads bytes only; not opened");
|
||||
return;
|
||||
};
|
||||
if data.size() > MAX_OPEN_BYTES as f64 {
|
||||
log::warn!("host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20);
|
||||
return;
|
||||
}
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((open_name(name), array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser
|
||||
//! (like [`crate::prefs`]): the name the bridge fills in when a host's open request has none.
|
||||
//! Which pages host the app is `craft-web-host`'s.
|
||||
|
||||
/// The file name used when an open request carries no usable one.
|
||||
pub const DEFAULT_OPEN_NAME: &str = "Book.xlsx";
|
||||
|
||||
/// The name `craft-web-host` gives an open request that carries no usable one (it cleans a host's
|
||||
/// name and falls back to this).
|
||||
const NAMELESS: &str = "document";
|
||||
|
||||
/// A usable file name for an open request: the cleaned name the crate hands over, or
|
||||
/// [`DEFAULT_OPEN_NAME`] when the host sent none.
|
||||
pub fn open_name(name: &str) -> String {
|
||||
if name == NAMELESS { DEFAULT_OPEN_NAME.to_string() } else { name.to_string() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_names_fall_back_to_the_default_when_the_host_sent_none() {
|
||||
assert_eq!(open_name("Budget.xlsx"), "Budget.xlsx");
|
||||
assert_eq!(open_name("Sheet2!Book.xlsx"), "Sheet2!Book.xlsx");
|
||||
assert_eq!(open_name("document"), DEFAULT_OPEN_NAME, "what the crate says for a missing or blank name");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -37,9 +37,12 @@ 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 };
|
||||
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing GridCraft (such
|
||||
// as a Nextcloud app), opens 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.
|
||||
let bridge = crate::host::Bridge::detect(&canvas);
|
||||
let app_bridge = bridge.clone();
|
||||
let result = eframe::WebRunner::new()
|
||||
.start(
|
||||
@@ -48,7 +51,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}));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Hosting GridCraft for the web
|
||||
|
||||
|
||||
`gridcraft-web-<version>.zip` (from the GitHub release, or `packaging/web/package.sh`) holds a
|
||||
static site in `gridcraft-web-<version>/`:
|
||||
|
||||
@@ -77,6 +78,85 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo
|
||||
one. Use them when the host window already shows the file name and owns the files, so the sheet's
|
||||
own Open or Save As cannot replace what the host thinks is open. Everything stays on by default.
|
||||
|
||||
### The parent bridge (`?host=parent`)
|
||||
|
||||
A page framing GridCraft in a **same-origin** iframe can add `?host=parent` to the iframe's URL
|
||||
(`https://example.com/gridcraft/?host=parent`) and have GridCraft hand its files over instead of
|
||||
downloading them. The two sides talk `postMessage` with plain window messages that carry a
|
||||
`type`:
|
||||
|
||||
| Message | Direction | Fields |
|
||||
|---|---|---|
|
||||
| `gridcraft:ready` | app → page | `version`, `appVersion`: the app is listening; send a file now |
|
||||
| `gridcraft:open` | page → app | `name`, `bytes` (an `ArrayBuffer` or `Uint8Array`): open like File › Open |
|
||||
| `gridcraft:save` | app → page | `name`, `bytes` (a transferred `ArrayBuffer`): every file the app would otherwise download (Save, Save As, Export as PDF). The page stores it and reports failures itself. |
|
||||
| `gridcraft:dirty` | app → page | `dirty`: unsaved changes appeared or went away |
|
||||
| `gridcraft:failed` | app → page | `error`: the app couldn't start |
|
||||
|
||||
In host mode the app starts with an empty workbook instead of a sample, so the framing page
|
||||
should guard leaving the page while changes are unsaved. `&author=` names the user for the
|
||||
notes and comments the app creates. Opening `?host=parent` outside an iframe gives the normal
|
||||
app with downloads.
|
||||
|
||||
### Embedding without an iframe (page host mode)
|
||||
|
||||
A page that loads the GridCraft module into its own document — its own `<canvas
|
||||
id="gridcraft_canvas">` inside a component, sized to a box beside the page's own header and
|
||||
controls — opts in with `data-host="page"` on that canvas. The behaviour is the same as the
|
||||
parent bridge, except the transport is DOM `CustomEvent`s on the canvas instead of
|
||||
`postMessage`: the app dispatches `gridcraft:ready`, `gridcraft:save`, `gridcraft:dirty` and
|
||||
`gridcraft:failed` on the canvas with the message fields above 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; listen on the
|
||||
canvas itself.
|
||||
|
||||
```html
|
||||
<canvas id="gridcraft_canvas" data-host="page" style="width: 100%; height: 600px; border: 0;"></canvas>
|
||||
```
|
||||
|
||||
```js
|
||||
import init from "./gridcraft-web-<hash>.js"; // the wasm-bindgen glue shipped with the app
|
||||
|
||||
// Listen first: the app announces itself while `init` runs.
|
||||
const canvas = document.getElementById("gridcraft_canvas");
|
||||
|
||||
canvas.addEventListener("gridcraft:ready", async () => {
|
||||
// Open a workbook of the page's own, as with File › Open.
|
||||
const bytes = await workbookBytes(); // an ArrayBuffer or Uint8Array
|
||||
canvas.dispatchEvent(new CustomEvent("gridcraft:open", {
|
||||
detail: { name: "Budget.xlsx", bytes },
|
||||
}));
|
||||
});
|
||||
|
||||
// Every file GridCraft would otherwise download (Save, Save As, Export as PDF) arrives here.
|
||||
canvas.addEventListener("gridcraft:save", (event) => {
|
||||
const { name, bytes } = event.detail; // bytes is an ArrayBuffer
|
||||
storeFile(name, bytes);
|
||||
});
|
||||
|
||||
canvas.addEventListener("gridcraft:dirty", (event) => {
|
||||
warnBeforeLeaving(event.detail.dirty);
|
||||
});
|
||||
canvas.addEventListener("gridcraft:failed", (event) => {
|
||||
showError(event.detail.error);
|
||||
});
|
||||
|
||||
await init({ module_or_path: "./gridcraft-web-<hash>_bg.wasm" });
|
||||
```
|
||||
|
||||
As with the parent bridge, the app starts with an empty workbook instead of a sample, and
|
||||
`?author=` on the page's URL names the user for new notes and comments. The `data-host` value
|
||||
must be exactly `page`; anything else is ignored, and the app runs as a standalone page with
|
||||
downloads.
|
||||
|
||||
The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `gridcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. GridCraft reads bytes, so a host sends `bytes`, not a `File`.
|
||||
|
||||
### Trust and limits
|
||||
|
||||
- **Who is trusted.** In page host mode the page and GridCraft share one document, so any script on the page can drive the app: open files in it and receive what it saves. Use this mode only for a page you control. The iframe mode (`?host=parent`) answers only the parent window, on the page's own origin.
|
||||
- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MiB) and a larger file is refused, and a name is cut to 255 characters.
|
||||
- **The event and message names are the interface.** `gridcraft:ready`, `gridcraft:open`, `gridcraft:save`, `gridcraft:dirty` and `gridcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
|
||||
|
||||
## Renderer selection
|
||||
|
||||
GridCraft's web build is the same egui app as the desktop one, compiled to wasm
|
||||
|
||||
Reference in new issue
Block a user