mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-08 03:54:35 +00:00
pdfcraft-web: window.pdfcraft, the page command API from craft-web-api, opt-in with ?control
?control attaches the UI control channel (the one --control serves) and installs window.pdfcraft: request, execute (ui.command), commands (ui.commands), docInspect (ui.state), uiInspect (ui.inspect) and screenshot. The channel keeps the accessibility tree of every frame, so it stays off unless the page asks. Opening, saving, the file name and the unsaved flag stay with the host messages.
This commit is contained in:
1 parent
f42fda7342
commit
a9e98e0db6
5 files changed
+165
-2
No files matched your search
Generated
+14
-1
@@ -1312,10 +1312,21 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -4754,11 +4765,13 @@ dependencies = [
|
||||
name = "pdfcraft-web"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
"pdfcraft-ui-egui",
|
||||
"serde_json",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -12,9 +12,12 @@ workspace = true
|
||||
[dependencies]
|
||||
pdfcraft-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" }
|
||||
# The page command API (storytold/craft-libs#413): window.pdfcraft over the UI control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen-futures = "0.4"
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! `window.pdfcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the app's
|
||||
//! UI control channel (`pdfcraft_ui_egui::control`), opt-in with `?control`.
|
||||
//!
|
||||
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||
//! `screenshot`, as promises with a timeout). This module is PdfCraft's part: a [`Request`] that
|
||||
//! sends each call through the app's [`ControlClient`] and waits for the reply, and the table that
|
||||
//! names PdfCraft's methods: its control channel drives the UI, so `commands` is `ui.commands`,
|
||||
//! `execute` runs a registry command with `ui.command`, `docInspect` is `ui.state` and `uiInspect`
|
||||
//! is `ui.inspect`.
|
||||
//!
|
||||
//! The channel keeps the accessibility tree of every frame, so it is off unless the page asks for it
|
||||
//! (`?control`), as FigCraft's is. PdfCraft's control requests cannot be withdrawn, so a call the page
|
||||
//! gave up on still runs when the app reaches it, as it does for a desktop client that timed out.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The reply of a control request as the protocol's `{ok, result}` / `{ok: false, error}` object.
|
||||
fn reply_object(reply: Result<Value, String>) -> Value {
|
||||
match reply {
|
||||
Ok(result) => json!({"ok": true, "result": result}),
|
||||
Err(error) => json!({"ok": false, "error": error}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The params of `ui.command`. `execute(command, params)` sends `{command, params}`; `ui.command`
|
||||
/// takes `{id}` (and `params` for `ui.panel`). A request that already names an `id` is the page's own
|
||||
/// `request("ui.command", {id})` and goes through unchanged.
|
||||
fn command_params(params: Value) -> Value {
|
||||
match params.get("command").and_then(Value::as_str) {
|
||||
Some(command) if params.get("id").is_none() => json!({"id": command, "params": params.get("params").cloned().unwrap_or(Value::Null)}),
|
||||
_ => params,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::install;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use pdfcraft_ui_egui::control::{ControlClient, Reply};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use super::{command_params, reply_object};
|
||||
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "pdfcraft";
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// PdfCraft's control methods behind each function.
|
||||
fn methods() -> Methods {
|
||||
Methods {
|
||||
commands: Some("ui.commands"),
|
||||
execute: Some("ui.command"),
|
||||
doc_inspect: Some("ui.state"),
|
||||
ui_inspect: Some("ui.inspect"),
|
||||
screenshot: Some("ui.screenshot"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `window.pdfcraft` on the page, sending its calls through `client`. An existing
|
||||
/// `window.pdfcraft` keeps the functions it already has.
|
||||
pub fn install(client: ControlClient) {
|
||||
let request: Request = Rc::new(move |method, params| {
|
||||
let params = if method == "ui.command" { command_params(params) } else { params };
|
||||
Box::pin(wait(client.send(&method, params)))
|
||||
});
|
||||
if let Err(e) = Rc::new(PageApi::new(APP, methods(), request)).install() {
|
||||
eframe::web_sys::console::warn_1(&format!("PdfCraft: couldn't install window.{APP}: {e:?}").into());
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's reply, polled about once a frame.
|
||||
async fn wait(reply: Receiver<Reply>) -> Value {
|
||||
loop {
|
||||
match reply.try_recv() {
|
||||
Ok(reply) => return reply_object(reply),
|
||||
Err(TryRecvError::Empty) => sleep(POLL_MS).await,
|
||||
Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn execute_becomes_a_ui_command_by_id() {
|
||||
assert_eq!(command_params(json!({"command": "file.word_count", "params": {"a": 1}})), json!({"id": "file.word_count", "params": {"a": 1}}));
|
||||
assert_eq!(command_params(json!({"command": "file.save", "params": {}})), json!({"id": "file.save", "params": {}}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_that_names_an_id_goes_through_unchanged() {
|
||||
let own = json!({"id": "file.save"});
|
||||
assert_eq!(command_params(own.clone()), own);
|
||||
let both = json!({"id": "ui.panel", "command": "x", "params": {"reset": true}});
|
||||
assert_eq!(command_params(both.clone()), both, "an id wins over a command");
|
||||
assert_eq!(command_params(json!({})), json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_become_the_protocols_objects() {
|
||||
assert_eq!(reply_object(Ok(json!({"n": 1}))), json!({"ok": true, "result": {"n": 1}}));
|
||||
assert_eq!(reply_object(Err("boom".to_string())), json!({"ok": false, "error": "boom"}));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod api;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
@@ -68,6 +71,11 @@ fn main() {
|
||||
&"PdfCraft: ?host=parent needs a framing page on the same origin; saves download instead".into(),
|
||||
);
|
||||
}
|
||||
// `?control`: `window.pdfcraft`, the page's command API (`api`). Opt-in: the control
|
||||
// channel keeps the accessibility tree of every frame.
|
||||
if query_param("control").is_some() {
|
||||
api::install(app.attach_control(&cc.egui_ctx));
|
||||
}
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -136,6 +136,18 @@ The messages and both transports are the shared contract of [`craft-web-host`](h
|
||||
- **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.** `pdfcraft:ready`, `pdfcraft:open`, `pdfcraft:save`, `pdfcraft:dirty` and `pdfcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
|
||||
|
||||
### Driving it from the page (`window.pdfcraft`)
|
||||
|
||||
`?control` on the page's URL puts `window.pdfcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app (`index.html?control`, which can be combined with other flags). It is opt-in because the control channel keeps the accessibility tree of every frame. The browser already exposes PdfCraft's canvas (`id="pdfcraft"`) as `window.pdfcraft`, so the functions are added to that element. `pdfcraft.request(method, params)` calls any method of the UI control channel the desktop app serves with `--control` (`ui.state`, `ui.inspect`, `ui.click`, `ui.key`, `ui.set`, `ui.open`…), and `execute(command, params)` (`ui.command`: a registry command id), `commands()` (`ui.commands`), `docInspect(params)` (`ui.state`), `uiInspect(params)` (`ui.inspect`) and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app still runs it when it reaches it, as it does for a desktop client that timed out.
|
||||
|
||||
```js
|
||||
const state = await pdfcraft.docInspect(); // open documents, page, zoom, panels, dialog
|
||||
await pdfcraft.request("ui.set", { key: "show_page_edge_shadows", value: "false" });
|
||||
const { data } = await pdfcraft.screenshot(); // the window as PNG (base64)
|
||||
```
|
||||
|
||||
Opening a PDF, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above.
|
||||
|
||||
## Renderer selection and fallback flags
|
||||
|
||||
PdfCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
|
||||
|
||||
Reference in new issue
Block a user