mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-08 03:54:35 +00:00
Merge pull request #6497 from helenkwok/web/page-api
pdfcraft-web: window.pdfcraft, the page command API from craft-web-api, opt-in with ?control
This commit is contained in:
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 may still run (or be rejected) 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` needs a file system, so on the web it answers with an error and a file is dropped on the page instead), 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 may still reach it later and run it (or reject it), as it does for a desktop client that timed out, so a timed-out call is not guaranteed to have been skipped.
|
||||
|
||||
```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