mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-07 11:03:15 +00:00
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:
1 parent
2b74d22b0f
commit
fc3679047e
7 files changed
+544
-6
No files matched your search
@@ -3,6 +3,7 @@
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated statusLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (web host bridge: `?host=parent` in an iframe or `data-host="page"` on the canvas hands a page the app's files instead of downloads and reports unsaved changes, documented in the hosting guide; colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -143,6 +144,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
- **2026-10-27** · Batch continue-on-error (M0.9, #1386): `command.batch` and MCP `batch` accept `onError: "continue"`, roll back failed steps individually and return one outcome per step. References to failed steps fail with both step numbers; successful edits stay one undo step, and the default abort mode is unchanged. Engine and MCP regression tests. Numbers unchanged.
|
||||
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
|
||||
- **2026-10-25** · M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged.
|
||||
- **2026-10-26** · Web host bridge: `?host=parent` (a same-origin page embedding FigCraft in an iframe) or `data-host="page"` on the canvas (the page's own document, over non-bubbling `CustomEvent`s) opens the host's file like File ▸ Open (over the browser open limit of 512 MB refused) and takes every file the app would otherwise download (`figcraft:save` with the `saveAs` rule), plus `figcraft:dirty`, `figcraft:ready` and `figcraft:failed`; `?author=` names the user for comments and board notes; host mode starts with no document. Unit tests cover the opt-in rule (only the exact `page` value), the `saveAs` rule and the open-name fallback. Numbers unchanged.
|
||||
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
|
||||
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
|
||||
- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
|
||||
@@ -492,6 +494,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
| 2026-10-27 | minor | Placed images keep their own size (M7, #1360): dropping, Place image, paste and `image.place` put an image at its pixel size and fit only a side past 4096 px (they fitted it into 1000 px); a PNG whose `pHYs` records a whole 2x–4x multiple of 72 dpi (macOS screenshots record 144 dpi) is placed at its point size, so an 800 × 600 px capture comes in at 400 × 300; other densities, such as 300 dpi scans, keep their pixel size; the image data stays at full resolution and an explicit width or height still wins. On macOS, paste reads the clipboard's PNG as is (its density and colour profile kept) and falls back to the decoded image. Numbers unchanged |
|
||||
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
|
||||
| 2026-10-25 | minor | M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged. |
|
||||
| 2026-10-26 | minor | Web host bridge: `?host=parent` (a same-origin page embedding FigCraft in an iframe) or `data-host="page"` on the canvas (the page's own document, over non-bubbling `CustomEvent`s) opens the host's file like File ▸ Open and takes every file the app would otherwise download, with `figcraft:ready`, `figcraft:open`, `figcraft:save` (a `saveAs` rule), `figcraft:dirty` and `figcraft:failed`; `?author=` names the user for comments and board notes; documented in the hosting guide. Numbers unchanged |
|
||||
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
|
||||
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
|
||||
| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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:?}")))
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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,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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user