mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 20:29:33 -06:00
Merge pull request #1358 from helenkwok/web/embedding-bridge
feat(web): a host bridge for embedding pages, ?host=parent and data-host="page" (#1356)
This commit is contained in:
Generated
+10
@@ -1157,6 +1157,15 @@ dependencies = [
|
||||
"zip 9.1.1",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -2121,6 +2130,7 @@ name = "figcraft-web"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"figcraft-collab-net",
|
||||
|
||||
+3
-1
File diff suppressed because one or more lines are too long
@@ -19,6 +19,8 @@ figcraft-format = { workspace = true }
|
||||
figcraft-ui-egui = { workspace = true }
|
||||
figcraft-collab-net = { workspace = true }
|
||||
figcraft-text = { 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" }
|
||||
base64 = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -30,4 +32,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
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", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage", "Event", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! 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 messages
|
||||
//! and the two transports are `craft-web-host`'s (storytold/craft-libs): the same contract as
|
||||
//! the other Craft apps' bridges. This module is FigCraft's part of it.
|
||||
//!
|
||||
//! - `?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) uses DOM
|
||||
//! `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present.
|
||||
//! - `figcraft:ready` (to the host) once the app is running and listening; send a file now.
|
||||
//! - `figcraft:open` (from the host): `{ name, bytes }`. 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 reads whole files, so it says `file: false` in
|
||||
//! `ready` and a host sends `bytes`.
|
||||
//! - `figcraft:save` (to the host): `{ name, saveAs, bytes }` for every file the app hands its
|
||||
//! download service; in host mode nothing reaches the browser's downloads. `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` whenever unsaved changes appear or go away, `figcraft:failed` when the app
|
||||
//! couldn't start.
|
||||
//!
|
||||
//! 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::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use figcraft_ui_egui::FigApp;
|
||||
|
||||
/// The app id in the host messages (`figcraft:ready`).
|
||||
const APP: &str = "figcraft";
|
||||
|
||||
/// 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>)>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
host: Rc<Host>,
|
||||
/// 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>>>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// The bridge for this page, or `None` when FigCraft 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 connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
Some(Self { host: Rc::new(Host::connect(APP, canvas)?), host_file: 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) {
|
||||
let host_file = self.host_file.clone();
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
deliver_open(&opens, &ctx, &host_file, &name, &data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `?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.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
/// 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.host.post_failed(error, 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());
|
||||
let sent = self.host.post_save(&name, bytes, None, save_as).is_ok();
|
||||
// After a Save As hand-over the host has the new file: the next Save of it sends its name
|
||||
// with `saveAs: false`. A failed send changes nothing, so a retry is still a Save As.
|
||||
if sent && save_as {
|
||||
*self.host_file.borrow_mut() = Some(name);
|
||||
}
|
||||
sent
|
||||
}
|
||||
|
||||
/// Report unsaved changes when they appear or go away, not every frame (the crate does not
|
||||
/// repeat a state it already reported).
|
||||
pub fn set_dirty(&self, dirty: bool) {
|
||||
self.host.post_dirty(dirty);
|
||||
}
|
||||
}
|
||||
|
||||
/// The file an open message carries, queued for the app's own open path, or nothing when the
|
||||
/// payload isn't one (a `File` the app doesn't read, or over the open limit).
|
||||
fn deliver_open(opens: &OpenQueue, ctx: &egui::Context, host_file: &RefCell<Option<String>>, name: &str, data: &FileData) {
|
||||
let name = crate::hostmode::open_name(name);
|
||||
let FileData::Bytes(array) = data else {
|
||||
log::warn!("host file {name} came as a File; FigCraft reads bytes only; not opened");
|
||||
return;
|
||||
};
|
||||
if figcraft_ui_egui::browser_can_read(&name, data.size()).is_err() {
|
||||
log::warn!("host file {name} is too large to open in the browser; not opened");
|
||||
return;
|
||||
}
|
||||
// The document's file is now the one the host sent: a Save of it under this name is
|
||||
// `saveAs: false`.
|
||||
*host_file.borrow_mut() = Some(name.clone());
|
||||
opens.borrow_mut().push((name, array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser
|
||||
//! (like [`crate::query`]): the name the bridge fills in when an open request has none, and whether
|
||||
//! a handed-over file becomes the document's file. 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 = "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";
|
||||
|
||||
/// 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() }
|
||||
}
|
||||
|
||||
/// 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 open_names_fall_back_to_the_default_when_the_host_sent_none() {
|
||||
assert_eq!(open_name("Poster.figcraft"), "Poster.figcraft");
|
||||
assert_eq!(open_name("Poster 2.json"), "Poster 2.json");
|
||||
assert_eq!(open_name("document"), DEFAULT_OPEN_NAME, "what the crate says for a missing or blank 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")));
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -32,6 +32,15 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// Host modes (`craft-web-host`): `?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.
|
||||
let bridge = crate::host::Bridge::connect(&canvas);
|
||||
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,12 +53,15 @@ 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);
|
||||
}
|
||||
let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
|
||||
let mut session = Session::new();
|
||||
// Host mode: the host sends the file to edit, so start with no document (the
|
||||
// home screen), as the desktop app does without a file; otherwise the session
|
||||
// starts with one new document.
|
||||
let mut session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
|
||||
// The host names its own server: the default for sharing, codes and the team list.
|
||||
if let Some(server) = page_meta("figcraft-server").as_deref().and_then(crate::query::page_server) {
|
||||
session.prefs.publish_server = server;
|
||||
@@ -60,11 +72,18 @@ pub fn start() {
|
||||
// whatever arrives on the socket wakes the page for a frame.
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
|
||||
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
|
||||
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone(), host: app_bridge.clone() }));
|
||||
fig.note_recovered(recovered);
|
||||
if let Err(e) = crate::pasted::install(cc.egui_ctx.clone()) {
|
||||
log::error!("couldn't listen for pasted images: {e:?}");
|
||||
}
|
||||
// `?author=`: the host's name for the user signs this visit's comments and board
|
||||
// notes (nothing is stored in the browser, so nothing needs keeping).
|
||||
if app_bridge.is_some()
|
||||
&& let Some(author) = query_param("author")
|
||||
{
|
||||
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();
|
||||
if let Some(link) = crate::query::join_link(&hash) {
|
||||
@@ -78,10 +97,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 +171,45 @@ 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
|
||||
// "Opening as a new file" card, and `file.open` with the contents once read.
|
||||
for (name, bytes) in std::mem::take(&mut *self.opens.borrow_mut()) {
|
||||
let read: figcraft_ui_egui::ReadFuture = Box::pin(std::future::ready(Ok(bytes)));
|
||||
self.fig.ui.pending_opens.push(figcraft_ui_egui::pending_open::PendingOpen {
|
||||
label: name.clone(),
|
||||
params: serde_json::json!({ "name": name }),
|
||||
command: "file.open",
|
||||
target: None,
|
||||
read: Some(read),
|
||||
});
|
||||
}
|
||||
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) {
|
||||
@@ -182,12 +237,15 @@ impl eframe::App for WebShell {
|
||||
|
||||
/// Browser services. Files are opened and imported through the browser's file picker, which hands
|
||||
/// 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
|
||||
/// through egui's output; pastes arrive as paste events with their text or image (a page can't
|
||||
/// read the clipboard when it likes).
|
||||
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 +262,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) => {
|
||||
|
||||
@@ -101,3 +101,6 @@ sync_github_name = true
|
||||
|
||||
[people.aleksei-suholita]
|
||||
real_name = "Oleksii Sukholita"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
@@ -107,6 +107,104 @@ 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`, `appVersion`: 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.
|
||||
`data-host="page"` turns this mode on only when `?host=parent` is absent: if `?host=parent` is
|
||||
present it takes precedence, so a same-origin framed page uses the parent bridge, while an
|
||||
unframed or cross-origin page gets no bridge and keeps normal 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 `figcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. FigCraft reads bytes, so a host sends `bytes`, not a `File`.
|
||||
|
||||
### Trust and limits
|
||||
|
||||
- **Who is trusted.** In page host mode the page and FigCraft 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 MB) and a larger file is refused, and a name is cut to 255 characters.
|
||||
- **The event and message names are the interface.** `figcraft:ready`, `figcraft:open`, `figcraft:save`, `figcraft:dirty` and `figcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
|
||||
|
||||
## Renderer selection and fallback flags
|
||||
|
||||
FigCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
|
||||
|
||||
Reference in New Issue
Block a user