feat(sdk): add @zseven-w/op-web-sdk TS core (read-only web viewer)
Plan 2 of the web embedding SDK: a self-contained, framework-agnostic TypeScript core wrapping the Plan-1 wasm Viewer. createViewer -> OpViewer with load, read-only snapshots (document/pages/pageCount/activePage), pan/zoom navigation (+ wheel), SVG export, load/viewportchange events, and destroy. Read-only: no .op editing/mutation. Self-contained: zero @zseven-w/pen-* deps (types vendored from jian-ops-schema ts-rs), tsup build, vitest+jsdom tests that mock the wasm seam. 10/10 tests; tsc clean; post-destroy use-after-free guarded. React+Vue adapters follow in Plan 3.
This commit is contained in:
parent
00526dec31
commit
20971e04b4
2
packages/op-web-sdk/.gitignore
vendored
Normal file
2
packages/op-web-sdk/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/dist/
|
||||
/wasm/
|
||||
198
packages/op-web-sdk/README.md
Normal file
198
packages/op-web-sdk/README.md
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# @zseven-w/op-web-sdk
|
||||
|
||||
Read-only OpenPencil `.op` file viewer SDK for the web, backed by a Rust/wasm renderer.
|
||||
|
||||
> **Editing is not supported.** This SDK provides a read-only view of `.op` documents.
|
||||
> For full editing capability use the [OpenPencil app](https://openpencil.app).
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @zseven-w/op-web-sdk
|
||||
# or
|
||||
npm install @zseven-w/op-web-sdk
|
||||
```
|
||||
|
||||
After installing, copy the wasm assets into your project's public directory:
|
||||
|
||||
```bash
|
||||
cp -r node_modules/@zseven-w/op-web-sdk/wasm ./public/op-wasm
|
||||
```
|
||||
|
||||
Or run the sync script from a monorepo checkout (requires the Rust wasm toolchain):
|
||||
|
||||
```bash
|
||||
bun run sync-wasm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createViewer } from '@zseven-w/op-web-sdk';
|
||||
|
||||
const canvas = document.getElementById('my-canvas') as HTMLCanvasElement;
|
||||
|
||||
// createViewer initialises the wasm module, binds it to the canvas, and returns
|
||||
// a read-only OpViewer. Pass `doc` to load a document immediately.
|
||||
const viewer = await createViewer({
|
||||
canvas,
|
||||
// Optional: override the default wasm asset URL.
|
||||
wasmUrl: '/op-wasm/op_web_sdk_bg.wasm',
|
||||
});
|
||||
|
||||
// Load a .op document from a fetch response:
|
||||
const resp = await fetch('/designs/my-design.op');
|
||||
const bytes = new Uint8Array(await resp.arrayBuffer());
|
||||
viewer.load(bytes);
|
||||
|
||||
// Read document metadata:
|
||||
const doc = viewer.document; // PenDocument
|
||||
const pages = viewer.pages; // PenPage[]
|
||||
console.log(doc.name, viewer.pageCount);
|
||||
|
||||
// Control the viewport:
|
||||
viewer.setZoom(1.5);
|
||||
viewer.panTo(200, 100);
|
||||
viewer.zoomToFit(canvas.clientWidth, canvas.clientHeight);
|
||||
|
||||
// Export the current view to SVG:
|
||||
const svgBytes = viewer.export({ format: 'svg' });
|
||||
const blob = new Blob([svgBytes], { type: 'image/svg+xml' });
|
||||
|
||||
// Listen to events:
|
||||
const unsub = viewer.on('viewportchange', () => {
|
||||
console.log('viewport:', viewer.viewport);
|
||||
});
|
||||
|
||||
// Clean up:
|
||||
viewer.destroy();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API reference
|
||||
|
||||
### `createViewer(options): Promise<OpViewer>`
|
||||
|
||||
Initialises the wasm module and returns a bound `OpViewer`.
|
||||
|
||||
| Option | Type | Description |
|
||||
|---|---|---|
|
||||
| `canvas` | `HTMLCanvasElement` | Canvas to render into (required). |
|
||||
| `doc` | `string \| Uint8Array` | Initial document to load (optional). |
|
||||
| `wasmUrl` | `string` | Override the `.wasm` asset URL (optional). |
|
||||
|
||||
---
|
||||
|
||||
### `OpViewer`
|
||||
|
||||
#### Document
|
||||
|
||||
| Member | Signature | Description |
|
||||
|---|---|---|
|
||||
| `load` | `(src: string \| Uint8Array): void` | Load or reload a document from JSON string or binary blob. Fires the `'load'` event. |
|
||||
| `document` | `PenDocument` | Snapshot of the full parsed document. |
|
||||
| `pages` | `PenPage[]` | Snapshot of the pages array. |
|
||||
| `pageCount` | `number` | Total number of pages in the document. |
|
||||
| `activePage` | `number` | Zero-based index of the currently active page. |
|
||||
|
||||
> `setActivePage` is not in the v1 read-only surface (single-page rendering). Multi-page
|
||||
> navigation is deferred to a future release.
|
||||
|
||||
#### Viewport
|
||||
|
||||
| Member | Signature | Description |
|
||||
|---|---|---|
|
||||
| `viewport` | `Viewport` | Current pan + zoom state (`{ panX, panY, zoom }`). |
|
||||
| `setViewport` | `(v: Viewport): void` | Set pan and zoom simultaneously. |
|
||||
| `setZoom` | `(z: number): void` | Change zoom level, keeping current pan. |
|
||||
| `panTo` | `(panX: number, panY: number): void` | Pan to a position, keeping current zoom. |
|
||||
| `zoomToFit` | `(w: number, h: number): void` | Zoom to fit the given canvas dimensions. |
|
||||
|
||||
#### Export
|
||||
|
||||
| Member | Signature | Description |
|
||||
|---|---|---|
|
||||
| `export` | `(opts: { format: 'svg' }): Uint8Array` | Export the current document. Only `'svg'` is supported in v1. |
|
||||
|
||||
#### Events
|
||||
|
||||
| Member | Signature | Description |
|
||||
|---|---|---|
|
||||
| `on` | `(event: ViewerEvent, cb: () => void): () => void` | Subscribe to a viewer event. Returns an unsubscribe function. |
|
||||
| `off` | `(event: ViewerEvent, cb: () => void): void` | Unsubscribe a specific callback. |
|
||||
|
||||
**ViewerEvent values:** `'load'` | `'viewportchange'`
|
||||
|
||||
#### Lifecycle
|
||||
|
||||
| Member | Signature | Description |
|
||||
|---|---|---|
|
||||
| `destroy` | `(): void` | Remove event listeners, detach from the canvas, free the wasm instance. |
|
||||
|
||||
---
|
||||
|
||||
## Types
|
||||
|
||||
```ts
|
||||
import type { PenDocument, PenPage, Viewport, ViewerEvent } from '@zseven-w/op-web-sdk';
|
||||
```
|
||||
|
||||
`PenDocument` and `PenPage` are generated from the canonical Rust schema
|
||||
(`crates/op-web-sdk/bindings/ops.ts` via ts-rs). They are vendored into
|
||||
`src/ops-types.ts` and re-exported from the package entry point.
|
||||
|
||||
---
|
||||
|
||||
## Wasm assets note
|
||||
|
||||
The package ships a `wasm/` directory containing the compiled wasm bundle. You must
|
||||
make these files accessible at a URL your browser can fetch. The default URL is
|
||||
resolved relative to the page; override it via the `wasmUrl` option in `createViewer`.
|
||||
|
||||
To re-sync the wasm assets after a Rust build:
|
||||
|
||||
```bash
|
||||
# From packages/op-web-sdk/:
|
||||
bun run sync-wasm
|
||||
```
|
||||
|
||||
This rebuilds `crates/op-web-sdk` (requires the Rust wasm toolchain + wasm-bindgen),
|
||||
copies the outputs to `wasm/`, and refreshes `src/ops-types.ts` from the latest
|
||||
generated bindings.
|
||||
|
||||
---
|
||||
|
||||
## Editing boundary
|
||||
|
||||
This SDK is intentionally **read-only**. It provides:
|
||||
|
||||
- Document parsing and typed access (`document`, `pages`)
|
||||
- GPU-accelerated wasm rendering on a canvas
|
||||
- Viewport control (pan, zoom, fit)
|
||||
- SVG export
|
||||
- Event subscriptions
|
||||
|
||||
It does **not** support:
|
||||
|
||||
- Node creation, deletion, or mutation
|
||||
- Selection or drag interactions
|
||||
- Layer panel, property panel, or toolbar UI
|
||||
- AI / MCP integrations
|
||||
|
||||
For the full editing experience, including AI-powered design generation and
|
||||
the complete node editing surface, use the [OpenPencil app](https://openpencil.app)
|
||||
or the `@zseven-w/pen-sdk` package (internal monorepo SDK).
|
||||
|
||||
React and Vue adapter packages (`@zseven-w/op-web-sdk-react`,
|
||||
`@zseven-w/op-web-sdk-vue`) are planned for a future release.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
207
packages/op-web-sdk/demo/index.html
Normal file
207
packages/op-web-sdk/demo/index.html
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<!DOCTYPE html>
|
||||
<!--
|
||||
OpenPencil Web SDK — browser demo.
|
||||
|
||||
Prerequisites (run from packages/op-web-sdk/):
|
||||
1. bun install
|
||||
2. bun run build (produces dist/index.js)
|
||||
3. bun run sync-wasm (populates wasm/ and src/ops-types.ts; requires Rust wasm toolchain)
|
||||
|
||||
Then serve this directory with any static server, e.g.:
|
||||
npx serve packages/op-web-sdk
|
||||
# or
|
||||
python3 -m http.server --directory packages/op-web-sdk 8080
|
||||
|
||||
Open http://localhost:8080/demo/index.html.
|
||||
|
||||
This demo is USER-RUN (deferred verification) — jsdom cannot execute wasm.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenPencil Web SDK Demo</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #0f0f0f;
|
||||
color: #e4e4e7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem 1rem;
|
||||
min-height: 100dvh;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
h1 { font-size: 1.25rem; font-weight: 600; color: #a1a1aa; }
|
||||
#canvas {
|
||||
border: 1px solid #3f3f46;
|
||||
border-radius: 8px;
|
||||
background: #18181b;
|
||||
width: min(900px, 100%);
|
||||
height: 540px;
|
||||
display: block;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
button {
|
||||
background: #27272a;
|
||||
border: 1px solid #3f3f46;
|
||||
color: #e4e4e7;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: background 150ms;
|
||||
}
|
||||
button:hover { background: #3f3f46; }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
#log {
|
||||
width: min(900px, 100%);
|
||||
background: #18181b;
|
||||
border: 1px solid #3f3f46;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
.log-entry { line-height: 1.6; }
|
||||
.log-entry.error { color: #f87171; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>OpenPencil Web SDK — read-only viewer demo</h1>
|
||||
|
||||
<canvas id="canvas" width="900" height="540"></canvas>
|
||||
|
||||
<div class="controls">
|
||||
<button id="btn-load">Load fixture (.op)</button>
|
||||
<button id="btn-zoom-in">Zoom in</button>
|
||||
<button id="btn-zoom-out">Zoom out</button>
|
||||
<button id="btn-zoom-fit">Zoom to fit</button>
|
||||
<button id="btn-export">Export SVG</button>
|
||||
<button id="btn-doc-info">Log document info</button>
|
||||
</div>
|
||||
|
||||
<div id="log"><div class="log-entry">Ready — click "Load fixture" to begin.</div></div>
|
||||
|
||||
<!--
|
||||
Import createViewer from the built ESM bundle.
|
||||
Run `bun run build` from packages/op-web-sdk first.
|
||||
-->
|
||||
<script type="module">
|
||||
import { createViewer } from '../dist/index.js';
|
||||
|
||||
const canvas = document.getElementById('canvas');
|
||||
const log = document.getElementById('log');
|
||||
|
||||
function addLog(msg, isError = false) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'log-entry' + (isError ? ' error' : '');
|
||||
el.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
|
||||
log.appendChild(el);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
// Minimal fixture document — a single page with one rectangle.
|
||||
const FIXTURE_DOC = JSON.stringify({
|
||||
version: '1',
|
||||
name: 'SDK Demo',
|
||||
children: [
|
||||
{
|
||||
type: 'rectangle',
|
||||
id: '1',
|
||||
name: 'Demo rect',
|
||||
x: 100,
|
||||
y: 80,
|
||||
width: 320,
|
||||
height: 200,
|
||||
fill: [{ type: 'solid', color: '#6366f1', opacity: 1, explain: null, blendMode: null }],
|
||||
stroke: null,
|
||||
effects: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let viewer = null;
|
||||
|
||||
async function initViewer() {
|
||||
if (viewer) return viewer;
|
||||
addLog('Initialising wasm viewer…');
|
||||
viewer = await createViewer({ canvas });
|
||||
viewer.on('load', () => addLog('Event: load fired'));
|
||||
viewer.on('viewportchange', () => {
|
||||
const { panX, panY, zoom } = viewer.viewport;
|
||||
addLog(`Event: viewportchange panX=${panX.toFixed(1)} panY=${panY.toFixed(1)} zoom=${zoom.toFixed(3)}`);
|
||||
});
|
||||
addLog('Viewer ready.');
|
||||
return viewer;
|
||||
}
|
||||
|
||||
document.getElementById('btn-load').addEventListener('click', async () => {
|
||||
try {
|
||||
const v = await initViewer();
|
||||
v.load(FIXTURE_DOC);
|
||||
addLog(`Loaded document "${v.document.name ?? '(unnamed)'"} — ${v.pageCount} page(s).`);
|
||||
} catch (err) {
|
||||
addLog(String(err), true);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-zoom-in').addEventListener('click', async () => {
|
||||
try {
|
||||
const v = await initViewer();
|
||||
v.setZoom(v.viewport.zoom * 1.25);
|
||||
} catch (err) { addLog(String(err), true); }
|
||||
});
|
||||
|
||||
document.getElementById('btn-zoom-out').addEventListener('click', async () => {
|
||||
try {
|
||||
const v = await initViewer();
|
||||
v.setZoom(v.viewport.zoom * 0.8);
|
||||
} catch (err) { addLog(String(err), true); }
|
||||
});
|
||||
|
||||
document.getElementById('btn-zoom-fit').addEventListener('click', async () => {
|
||||
try {
|
||||
const v = await initViewer();
|
||||
v.zoomToFit(canvas.width, canvas.height);
|
||||
addLog('zoomToFit called.');
|
||||
} catch (err) { addLog(String(err), true); }
|
||||
});
|
||||
|
||||
document.getElementById('btn-export').addEventListener('click', async () => {
|
||||
try {
|
||||
const v = await initViewer();
|
||||
const bytes = v.export({ format: 'svg' });
|
||||
const blob = new Blob([bytes], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'export.svg';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
addLog(`Exported SVG (${bytes.byteLength} bytes).`);
|
||||
} catch (err) { addLog(String(err), true); }
|
||||
});
|
||||
|
||||
document.getElementById('btn-doc-info').addEventListener('click', async () => {
|
||||
try {
|
||||
const v = await initViewer();
|
||||
const doc = v.document;
|
||||
const pages = v.pages;
|
||||
addLog(`document.name="${doc.name}" version="${doc.version}" pageCount=${v.pageCount} activePage=${v.activePage}`);
|
||||
pages.forEach((p, i) => addLog(` page[${i}]: id="${p.id}" name="${p.name}" children=${p.children.length}`));
|
||||
} catch (err) { addLog(String(err), true); }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
21
packages/op-web-sdk/package.json
Normal file
21
packages/op-web-sdk/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "@zseven-w/op-web-sdk",
|
||||
"version": "0.8.0",
|
||||
"description": "Read-only OpenPencil .op viewer SDK for the web (wasm-backed)",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"files": ["dist", "wasm"],
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" }
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"sync-wasm": "bash scripts/sync-wasm.sh"
|
||||
},
|
||||
"devDependencies": { "tsup": "^8.0.0", "typescript": "^5.7.2", "vitest": "^3.0.0", "jsdom": "^25.0.0" }
|
||||
}
|
||||
25
packages/op-web-sdk/scripts/sync-wasm.sh
Executable file
25
packages/op-web-sdk/scripts/sync-wasm.sh
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build the Plan-1 wasm bundle and copy its output into this package's wasm/.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||
bash "${ROOT}/crates/op-web-sdk/tools/build-wasm.sh"
|
||||
DST="$(cd "$(dirname "$0")/.." && pwd)/wasm"
|
||||
mkdir -p "${DST}"
|
||||
cp -R "${ROOT}/crates/op-web-sdk/pkg/." "${DST}/"
|
||||
echo "synced wasm bundle → ${DST}"
|
||||
# Vendor the ts-rs generated types into the package so it is self-contained.
|
||||
# Post-process: remove the dangling serde_json import and inline JsonValue so
|
||||
# the vendored file compiles without the missing ./serde_json/JsonValue module.
|
||||
PKGROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cp "${ROOT}/crates/op-web-sdk/bindings/ops.ts" "${PKGROOT}/src/ops-types.ts"
|
||||
perl -i -0pe '
|
||||
# Remove the ts-rs generated import for the missing serde_json helper.
|
||||
s/import type \{ JsonValue \} from "\.\/serde_json\/JsonValue";\n//g;
|
||||
# Inject the GENERATED header comment and the inline JsonValue definition
|
||||
# right after the first comment block (the ts-rs file header line), but only
|
||||
# when the inline block is not already present (idempotent).
|
||||
unless (/\/\/ JsonValue inlined/) {
|
||||
s|(// This file was generated.*?\n)|\1\n// JsonValue inlined from the ts-rs serde_json helper (the original imports from\n// \.\/serde_json\/JsonValue which is not part of this vendored package).\ntype JsonValue = null \| boolean \| number \| string \| JsonValue[] \| \{ \[key: string\]: JsonValue \};\n|s;
|
||||
}
|
||||
' "${PKGROOT}/src/ops-types.ts"
|
||||
echo "synced ops-types → ${PKGROOT}/src/ops-types.ts"
|
||||
26
packages/op-web-sdk/src/events.ts
Normal file
26
packages/op-web-sdk/src/events.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Typed event emitter for OpViewer lifecycle and viewport change events.
|
||||
|
||||
/** Events emitted by OpViewer. */
|
||||
export type ViewerEvent = 'load' | 'viewportchange';
|
||||
|
||||
/** Minimal typed pub/sub emitter used internally by OpViewer. */
|
||||
export class Emitter {
|
||||
private map = new Map<ViewerEvent, Set<() => void>>();
|
||||
|
||||
/** Subscribe to an event. Returns an unsubscribe function. */
|
||||
on(e: ViewerEvent, cb: () => void): () => void {
|
||||
let set = this.map.get(e);
|
||||
if (!set) { set = new Set(); this.map.set(e, set); }
|
||||
set.add(cb);
|
||||
return () => this.off(e, cb);
|
||||
}
|
||||
|
||||
/** Unsubscribe a specific callback from an event. */
|
||||
off(e: ViewerEvent, cb: () => void): void { this.map.get(e)?.delete(cb); }
|
||||
|
||||
/** Fire all listeners registered for the given event. */
|
||||
emit(e: ViewerEvent): void { this.map.get(e)?.forEach((cb) => cb()); }
|
||||
|
||||
/** Remove all listeners for all events. */
|
||||
clear(): void { this.map.clear(); }
|
||||
}
|
||||
5
packages/op-web-sdk/src/index.ts
Normal file
5
packages/op-web-sdk/src/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Public entry for the OpenPencil read-only web SDK core.
|
||||
export const VERSION = '0.8.0';
|
||||
export { createViewer, OpViewer } from './viewer.js';
|
||||
export type { Viewport, CreateViewerOptions, PenDocument, PenPage } from './types.js';
|
||||
export type { ViewerEvent } from './events.js';
|
||||
526
packages/op-web-sdk/src/ops-types.ts
Normal file
526
packages/op-web-sdk/src/ops-types.ts
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
// GENERATED by jian-ops-schema ts-rs (crates/op-web-sdk/bindings/ops.ts). Do not edit by hand; re-sync via scripts/sync-wasm.sh.
|
||||
|
||||
// JsonValue inlined from the ts-rs serde_json helper (the original imports from
|
||||
// ./serde_json/JsonValue which is not part of this vendored package).
|
||||
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
/**
|
||||
* A single Action is a 1-key object: `{ "<action_name>": <body> }`.
|
||||
*
|
||||
* Examples (all are valid JSON `Action`s):
|
||||
* - `{ "set": { "$state.count": "$state.count + 1" } }`
|
||||
* - `{ "fetch": { "url": "/api/x", "into": "$state.u" } }`
|
||||
* - `{ "push": "/detail/42" }`
|
||||
*
|
||||
* The body shape per action is NOT validated here — see `jian-core::action`.
|
||||
*/
|
||||
export type Action = { [key in string]?: JsonValue };
|
||||
|
||||
export type AlignItems = "start" | "center" | "end" | "stretch";
|
||||
|
||||
export type AppConfig = { name: string, version: string, id: string, entry: string | null, capabilities: Array<Capability> | null, theme: string | null, orientation: Orientation | null, backgroundColor: string | null, icon: string | null,
|
||||
/**
|
||||
* C19 splash-frame config.
|
||||
*/
|
||||
splash: SplashConfig | null,
|
||||
/**
|
||||
* C18 ASP web handshake postMessage origin allowlist.
|
||||
* Only consulted by the web host; strict match; no wildcards.
|
||||
*/
|
||||
aspAllowedOrigins: Array<string> | null,
|
||||
/**
|
||||
* Auto-update backend descriptor — same idea as `app.icon`:
|
||||
* the schema declares the source of truth (which release feed
|
||||
* to consult), the host crate translates it into a concrete
|
||||
* `Updater` impl. `None` (the default) means the host's own
|
||||
* fallback wins (typically `NullUpdater`). Only consulted by
|
||||
* hosts that compile with their respective updater feature
|
||||
* (`jian-host-desktop/updater`).
|
||||
*
|
||||
* TS-side, the type widens to a generic discriminated record so
|
||||
* third-party hosts can declare their own kinds without forking
|
||||
* `ops.ts`; the Rust side is the typed `UpdaterConfig` struct
|
||||
* below.
|
||||
*/
|
||||
updater: { kind: string; [key: string]: unknown } | null, };
|
||||
|
||||
export type AppLifecycleHooks = { onLaunch: Array<Action> | null, onResume: Array<Action> | null, onBackground: Array<Action> | null, onTerminate: Array<Action> | null, };
|
||||
|
||||
export type BlendMode = "normal" | "darken" | "multiply" | "screen" | "overlay" | "lighten" | "difference" | "hue" | "saturation" | "color" | "luminosity";
|
||||
|
||||
export type BlurBody = { radius: number, };
|
||||
|
||||
/**
|
||||
* Boolean that may also be an expression.
|
||||
*/
|
||||
export type BoolOrExpression = boolean | string;
|
||||
|
||||
export type Capability = "storage" | "network" | "camera" | "microphone" | "location" | "notifications" | "clipboard" | "biometric" | "file_system" | "haptic";
|
||||
|
||||
/**
|
||||
* Checkbox with an optional adjacent `label`. `checked` two-way binds
|
||||
* via `bindings.bind:value`.
|
||||
*/
|
||||
export type CheckboxNode = { width: SizingBehavior | null, height: SizingBehavior | null, checked: BoolOrExpression | null, label: string | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
export type CornerRadius = number | [number, number, number, number];
|
||||
|
||||
/**
|
||||
* One named colour from the design-md colour palette.
|
||||
*/
|
||||
export type DesignMdColor = {
|
||||
/**
|
||||
* Human label, e.g. "Primary".
|
||||
*/
|
||||
name: string,
|
||||
/**
|
||||
* `#RRGGBB` hex value.
|
||||
*/
|
||||
hex: string,
|
||||
/**
|
||||
* How the colour is used, e.g. "buttons and links".
|
||||
*/
|
||||
role: string, };
|
||||
|
||||
/**
|
||||
* A structured design-system brief attached to a [`PenDocument`].
|
||||
*
|
||||
* [`PenDocument`]: crate::document::PenDocument
|
||||
*/
|
||||
export type DesignMdSpec = {
|
||||
/**
|
||||
* Original markdown source — kept verbatim for round-trip fidelity.
|
||||
*/
|
||||
raw: string, projectName: string | null, visualTheme: string | null, colorPalette: Array<DesignMdColor> | null, typography: DesignMdTypography | null, componentStyles: string | null, layoutPrinciples: string | null, generationNotes: string | null, };
|
||||
|
||||
/**
|
||||
* Typography guidance from the design-md typography section.
|
||||
*/
|
||||
export type DesignMdTypography = { fontFamily: string | null, headings: string | null, body: string | null,
|
||||
/**
|
||||
* Free-form scale description — usually the whole section text.
|
||||
*/
|
||||
scale: string | null, };
|
||||
|
||||
export type EllipseNode = { width: SizingBehavior | null, height: SizingBehavior | null, cornerRadius: number | null, innerRadius: number | null, startAngle: number | null, sweepAngle: number | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
/**
|
||||
* All supported event hook keys. Note: input events (`onChange`, `onSubmit`, `onFocus`,
|
||||
* `onBlur`) apply only to input-kind nodes. `on_key` is keyboard, `on_reach_end`
|
||||
* is list-scroll-end, etc.
|
||||
*/
|
||||
export type EventHandlers = { onTap: Array<Action> | null, onDoubleTap: Array<Action> | null, onLongPress: Array<Action> | null, onPanStart: Array<Action> | null, onPanUpdate: Array<Action> | null, onPanEnd: Array<Action> | null, onScaleStart: Array<Action> | null, onScaleUpdate: Array<Action> | null, onScaleEnd: Array<Action> | null, onRotateStart: Array<Action> | null, onRotateUpdate: Array<Action> | null, onRotateEnd: Array<Action> | null, onHoverEnter: Array<Action> | null, onHoverLeave: Array<Action> | null, onChange: Array<Action> | null, onSubmit: Array<Action> | null, onFocus: Array<Action> | null, onBlur: Array<Action> | null, onKey: Array<Action> | null, onScroll: Array<Action> | null, onReachEnd: Array<Action> | null, };
|
||||
|
||||
/**
|
||||
* A Tier 1 expression source — represented as a raw string.
|
||||
*
|
||||
* Parsing and validation are the responsibility of `jian-core::expression::parser`
|
||||
* (Plan 2). The schema crate only guarantees the string is present; content-level
|
||||
* correctness is deferred.
|
||||
*/
|
||||
export type Expression = string;
|
||||
|
||||
export type FontStyleKind = "normal" | "italic";
|
||||
|
||||
export type FontWeight = number | string;
|
||||
|
||||
/**
|
||||
* Forward declaration of PenNode union — defined in `node/mod.rs`.
|
||||
* We accept `Vec<super::PenNode>` as children.
|
||||
*/
|
||||
export type FrameNode = { children: Array<PenNode> | null, imageSearchQuery: string | null, reusable: boolean | null, slot: Array<string> | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, width: SizingBehavior | null, height: SizingBehavior | null, layout: LayoutMode | null, gap: NumberOrExpression | null, padding: Padding | null, justifyContent: JustifyContent | null, alignItems: AlignItems | null, clipContent: boolean | null, cornerRadius: CornerRadius | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, };
|
||||
|
||||
export type GestureOverrides = {
|
||||
/**
|
||||
* When true, this node and its subtree bypass the Arena and receive raw pointer events.
|
||||
*/
|
||||
rawPointer: boolean | null, disabled: Expression | null, scrollBehavior: ScrollBehavior | null,
|
||||
/**
|
||||
* Override drag threshold in logical pixels (default 8).
|
||||
*/
|
||||
dragThreshold: number | null,
|
||||
/**
|
||||
* Override long-press duration in ms (default 500).
|
||||
*/
|
||||
longPressDuration: number | null,
|
||||
/**
|
||||
* Author-explicit Tab-traversal opt-in.
|
||||
*
|
||||
* `Some(true)` — node enters the focus chain regardless of its
|
||||
* semantic role.
|
||||
* `Some(false)` — node is excluded even if its `semantics.role`
|
||||
* would otherwise auto-include it (e.g. a decorative `Input`).
|
||||
* `None` — falls back to the role heuristic (`Button` / `Link`
|
||||
* / `Input` are auto-included; everything else is opt-in).
|
||||
*/
|
||||
focusable: boolean | null, };
|
||||
|
||||
export type GradientStop = { offset: number, color: string, };
|
||||
|
||||
export type GroupNode = { children: Array<PenNode> | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, width: SizingBehavior | null, height: SizingBehavior | null, layout: LayoutMode | null, gap: NumberOrExpression | null, padding: Padding | null, justifyContent: JustifyContent | null, alignItems: AlignItems | null, clipContent: boolean | null, cornerRadius: CornerRadius | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, };
|
||||
|
||||
export type IconFontNode = { iconFontName: string, iconFontFamily: string | null, width: SizingBehavior | null, height: SizingBehavior | null, fill: Array<PenFill> | null, stroke: PenStroke | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
export type ImageFillBody = { url: string, mode: ImageFillMode | null, originalSize: ImageOriginalSize | null, transform: ImageTransform | null, explain: string | null, opacity: number | null, exposure: number | null, contrast: number | null, saturation: number | null, temperature: number | null, tint: number | null, highlights: number | null, shadows: number | null, };
|
||||
|
||||
export type ImageFillMode = "fill" | "fit" | "crop" | "tile" | "stretch";
|
||||
|
||||
export type ImageFitMode = "fill" | "fit" | "crop" | "tile";
|
||||
|
||||
export type ImageNode = { src: string, objectFit: ImageFitMode | null, width: SizingBehavior | null, height: SizingBehavior | null, cornerRadius: CornerRadius | null, effects: Array<PenEffect> | null, exposure: number | null, contrast: number | null, saturation: number | null, temperature: number | null, tint: number | null, highlights: number | null, shadows: number | null, imagePrompt: string | null, imageSearchQuery: string | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
export type ImageOriginalSize = { width: number, height: number, };
|
||||
|
||||
export type ImageTransform = { m00: number, m01: number, m02: number, m10: number, m11: number, m12: number, };
|
||||
|
||||
export type JustifyContent = "start" | "center" | "end" | "space_between" | "space_around";
|
||||
|
||||
export type LayoutMode = "none" | "vertical" | "horizontal";
|
||||
|
||||
export type LineNode = { x2: number | null, y2: number | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
export type LinearGradientBody = { angle: number | null, stops: Array<GradientStop>, explain: string | null, opacity: number | null, blendMode: BlendMode | null, };
|
||||
|
||||
export type LiveRegion = "off" | "polite" | "assertive";
|
||||
|
||||
/**
|
||||
* An ABI version string for the Tier 3 WASM module. The only recognised
|
||||
* value today is `jian.wasm.v1`; Jian rejects unknown ABI at load time.
|
||||
*/
|
||||
export type LogicAbi = string;
|
||||
|
||||
export type LogicModuleRef = { id: string, source: string, integrity: string | null, abi: LogicAbi, capabilities: Array<Capability> | null, };
|
||||
|
||||
/**
|
||||
* Declarative per-node navigation: clicking the node pushes/replaces/pops a route.
|
||||
* Equivalent to `events.on_tap = [{"push": "..."}]` but more editor-discoverable.
|
||||
*/
|
||||
export type NavigationRoute = { "push": string } | { "replace": string } | { "pop": null };
|
||||
|
||||
export type NodeLifecycleHooks = { onMount: Array<Action> | null, onUnmount: Array<Action> | null, };
|
||||
|
||||
/**
|
||||
* Numeric input with optional +/- steppers. Precise complement to
|
||||
* `slider`; `value` two-way binds via `bindings.bind:value`. When
|
||||
* omitted, `min`/`max`/`step` default to none/none/1 at runtime.
|
||||
*/
|
||||
export type NumberInputNode = { width: SizingBehavior | null, height: SizingBehavior | null,
|
||||
/**
|
||||
* Placeholder shown when `value` is empty.
|
||||
*/
|
||||
placeholder: string | null, value: NumberOrExpression | null,
|
||||
/**
|
||||
* Lucide glyph drawn at the left content edge. See `TextInputNode`.
|
||||
*/
|
||||
leadingIcon: string | null,
|
||||
/**
|
||||
* Lucide glyph drawn at the right content edge. See `TextInputNode`.
|
||||
*/
|
||||
trailingIcon: string | null, min: number | null, max: number | null, step: number | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
/**
|
||||
* Opacity can be a number or a `$variable` reference string.
|
||||
*/
|
||||
export type NumberOrExpression = number | string;
|
||||
|
||||
export type Orientation = "portrait" | "landscape" | "auto";
|
||||
|
||||
export type Padding = number | [number, number] | [number, number, number, number] | string;
|
||||
|
||||
export type PageLifecycleHooks = { onEnter: Array<Action> | null, onLeave: Array<Action> | null, onForeground: Array<Action> | null, onBackground: Array<Action> | null, };
|
||||
|
||||
export type PathNode = { iconId: string | null, d: string | null, anchors: Array<PenPathAnchor> | null, closed: boolean | null, width: SizingBehavior | null, height: SizingBehavior | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
export type PenDocument = {
|
||||
/**
|
||||
* Document format version stored in files since v0.x. Always present.
|
||||
*/
|
||||
version: string, name: string | null,
|
||||
/**
|
||||
* Wire shape: axis-name → ordered theme names. Frozen from v0.x.
|
||||
*/
|
||||
themes: { [key in string]?: Array<string> } | null, variables: { [key in string]?: VariableDefinition } | null, pages: Array<PenPage> | null,
|
||||
/**
|
||||
* Default-on-deserialize so a multi-page document that carries only
|
||||
* `pages` (no top-level `children`) still loads — the TS web app's
|
||||
* whole-document sync (`document.post.ts`) accepts `{version, pages}`
|
||||
* without a `children` array. Always serialized (even empty `[]`).
|
||||
*/
|
||||
children: Array<PenNode>,
|
||||
/**
|
||||
* "1.0" when any v1 extension is present; undefined ⇒ legacy v0.x.
|
||||
*/
|
||||
formatVersion: string | null,
|
||||
/**
|
||||
* App id (reverse-DNS). Required when `app` is set; otherwise optional.
|
||||
*/
|
||||
id: string | null, app: AppConfig | null, routes: RoutesConfig | null, state: { [key in string]?: StateEntry } | null, lifecycle: AppLifecycleHooks | null, logicModules: Array<LogicModuleRef> | null,
|
||||
/**
|
||||
* Per-document design-system brief (the "design.md"). Optional —
|
||||
* absent on documents that never authored one.
|
||||
*/
|
||||
designMd: DesignMdSpec | null, };
|
||||
|
||||
export type PenEffect = { "type": "blur" } & BlurBody | { "type": "background_blur" } & BlurBody | { "type": "shadow" } & ShadowBody;
|
||||
|
||||
export type PenFill = { "type": "solid" } & SolidFillBody | { "type": "linear_gradient" } & LinearGradientBody | { "type": "radial_gradient" } & RadialGradientBody | { "type": "image" } & ImageFillBody;
|
||||
|
||||
/**
|
||||
* Union of all concrete node types.
|
||||
* Tag is the JSON `"type"` field.
|
||||
*/
|
||||
export type PenNode = { "type": "frame" } & FrameNode | { "type": "group" } & GroupNode | { "type": "rectangle" } & RectangleNode | { "type": "ellipse" } & EllipseNode | { "type": "line" } & LineNode | { "type": "polygon" } & PolygonNode | { "type": "path" } & PathNode | { "type": "text" } & TextNode | { "type": "text_input" } & TextInputNode | { "type": "image" } & ImageNode | { "type": "icon_font" } & IconFontNode | { "type": "text_area" } & TextAreaNode | { "type": "select" } & SelectNode | { "type": "switch" } & SwitchNode | { "type": "checkbox" } & CheckboxNode | { "type": "slider" } & SliderNode | { "type": "radio_group" } & RadioGroupNode | { "type": "number_input" } & NumberInputNode | { "type": "progress" } & ProgressNode | { "type": "tabs" } & TabsNode | { "type": "ref" } & RefNode;
|
||||
|
||||
export type PenPage = { id: string, name: string, children: Array<PenNode>, state: { [key in string]?: StateEntry } | null, lifecycle: PageLifecycleHooks | null, };
|
||||
|
||||
export type PenPathAnchor = { x: number, y: number, handleIn: PenPathHandle | null, handleOut: PenPathHandle | null, pointType: PenPathPointType | null, };
|
||||
|
||||
export type PenPathHandle = { x: number, y: number, };
|
||||
|
||||
export type PenPathPointType = "corner" | "mirrored" | "independent";
|
||||
|
||||
export type PenStroke = { thickness: StrokeThickness, align: StrokeAlign | null, join: StrokeJoin | null, cap: StrokeCap | null, dashPattern: Array<number> | null, dashOffset: number | null, fill: Array<PenFill> | null, };
|
||||
|
||||
export type PolygonNode = { polygonCount: number, width: SizingBehavior | null, height: SizingBehavior | null, cornerRadius: number | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
export type PrimitiveType = "int" | "float" | "number" | "string" | "bool" | "array" | "object" | "date";
|
||||
|
||||
/**
|
||||
* Progress indicator. Display-only (not focusable/keyboard-driven):
|
||||
* `value` is read from the state graph via `bindings.value`. `max`
|
||||
* defaults to 100; `indeterminate` shows an animated unknown-progress
|
||||
* state and ignores `value`.
|
||||
*/
|
||||
export type ProgressNode = { width: SizingBehavior | null, height: SizingBehavior | null, value: NumberOrExpression | null, max: number | null, indeterminate: boolean | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
export type RadialGradientBody = { cx: number | null, cy: number | null, radius: number | null, stops: Array<GradientStop>, explain: string | null, opacity: number | null, blendMode: BlendMode | null, };
|
||||
|
||||
/**
|
||||
* Single-choice radio group. Renders one radio per `option`; the
|
||||
* selected option `value` two-way binds via `bindings.bind:value`.
|
||||
*/
|
||||
export type RadioGroupNode = { width: SizingBehavior | null, height: SizingBehavior | null,
|
||||
/**
|
||||
* Currently selected option `value`.
|
||||
*/
|
||||
value: string | null, options: Array<SelectOption> | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
export type RectangleNode = { children: Array<PenNode> | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, width: SizingBehavior | null, height: SizingBehavior | null, layout: LayoutMode | null, gap: NumberOrExpression | null, padding: Padding | null, justifyContent: JustifyContent | null, alignItems: AlignItems | null, clipContent: boolean | null, cornerRadius: CornerRadius | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, };
|
||||
|
||||
export type RefNode = { ref: string, descendants: { [key in string]?: JsonValue } | null, children: Array<PenNode> | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
export type RouteSpec = { pageId: string, preload: boolean | null, guards: Array<Action> | null,
|
||||
/**
|
||||
* Path-parameter type declarations (v1.0 additive — 2026-04-24).
|
||||
* Keys correspond to `:param` placeholders in the route path
|
||||
* (e.g. path `/detail/:id` → key `id`). The AI Action Surface
|
||||
* uses these types when synthesising the JsonSchema for the
|
||||
* derived `open_*(p)` action; runtime does strict type-checking
|
||||
* on incoming values rather than silent coercion.
|
||||
*/
|
||||
params: { [key in string]?: StateType } | null, };
|
||||
|
||||
export type RoutesConfig = { entry: string, routes: { [key in string]?: RouteSpec }, transitions: { [key in string]?: Transition } | null, };
|
||||
|
||||
export type ScrollBehavior = "auto" | "contain" | "none";
|
||||
|
||||
/**
|
||||
* Dropdown select. The runtime pops an option list; the selected
|
||||
* option `value` two-way binds via `bindings.bind:value`.
|
||||
*/
|
||||
export type SelectNode = { width: SizingBehavior | null, height: SizingBehavior | null,
|
||||
/**
|
||||
* Shown when no option is selected.
|
||||
*/
|
||||
placeholder: string | null,
|
||||
/**
|
||||
* Currently selected option `value`.
|
||||
*/
|
||||
value: string | null, options: Array<SelectOption> | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
/**
|
||||
* A single dropdown option: the persisted `value` and its display `label`.
|
||||
*/
|
||||
export type SelectOption = { value: string, label: string, };
|
||||
|
||||
export type SemanticAction = { name: string, label: string, handler: Array<Action>, };
|
||||
|
||||
export type SemanticRole = "button" | "link" | "image" | "text" | "heading" | "input" | "list" | "list_item" | "header" | "nav" | "main" | "dialog" | "alert";
|
||||
|
||||
export type SemanticsMeta = { role: SemanticRole | null, label: string | null, hint: string | null, liveRegion: LiveRegion | null, disabled: Expression | null, actions: Array<SemanticAction> | null,
|
||||
/**
|
||||
* Author-stable override for the auto-derived AI action name.
|
||||
* When set, the resulting action name is `<scope>.<aiName>`
|
||||
* without the auto `_<hash4>` suffix and survives slug recomputes
|
||||
* across builds. See `2026-04-24-ai-action-surface.md` §3.3-3.4.
|
||||
*/
|
||||
aiName: string | null,
|
||||
/**
|
||||
* Tool description shown to external AI agents. Overrides the
|
||||
* auto-generated default; lets authors steer what a model "sees"
|
||||
* without changing visible UI text.
|
||||
*/
|
||||
aiDescription: string | null,
|
||||
/**
|
||||
* `true` permanently hides the node's derived action from the AI
|
||||
* surface (StaticHidden). Defaults to `false`. ConfirmGated /
|
||||
* StateGated availability are decided dynamically and do **not**
|
||||
* require this flag — see ai-action-surface.md §4.
|
||||
*/
|
||||
aiHidden: boolean | null,
|
||||
/**
|
||||
* Historical `aiName` values still accepted by `execute_action`
|
||||
* for transparent migration after a rename. Aliases are honoured
|
||||
* at execute time (with `audit reason_code: "alias_used"`) but
|
||||
* not surfaced by `list_available_actions`. See §9.
|
||||
*/
|
||||
aiAliases: Array<string> | null, };
|
||||
|
||||
export type ShadowBody = { inner: boolean | null, offsetX: number, offsetY: number, blur: number, spread: number, color: string, };
|
||||
|
||||
export type SidedThickness = { top: number | null, right: number | null, bottom: number | null, left: number | null, };
|
||||
|
||||
/**
|
||||
* Sizing value: a number, a fixed keyword, or an arbitrary string (typically `$variable` ref).
|
||||
*/
|
||||
export type SizingBehavior = number | SizingKeyword | string;
|
||||
|
||||
export type SizingKeyword = "fit_content" | "fill_container";
|
||||
|
||||
/**
|
||||
* Range slider. `value` two-way binds via `bindings.bind:value`;
|
||||
* `min`/`max`/`step` default to 0/100/1 at runtime when omitted.
|
||||
*/
|
||||
export type SliderNode = { width: SizingBehavior | null, height: SizingBehavior | null, min: number | null, max: number | null, step: number | null, value: NumberOrExpression | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
export type SolidFillBody = { color: string, explain: string | null, opacity: number | null, blendMode: BlendMode | null, };
|
||||
|
||||
export type SplashConfig = { background: string | null, image: string | null, text: string | null, minDurationMs: number | null, };
|
||||
|
||||
export type StateEntry = { type: StateType, default: JsonValue | null, description: string | null, persist: boolean | null, };
|
||||
|
||||
/**
|
||||
* Recursive state type description.
|
||||
*/
|
||||
export type StateType = PrimitiveType | { oneOf: Array<StateType>, } | { array: StateType, } | { object: { [key in string]?: StateType }, };
|
||||
|
||||
export type StrokeAlign = "inside" | "center" | "outside";
|
||||
|
||||
export type StrokeCap = "none" | "round" | "square";
|
||||
|
||||
export type StrokeJoin = "miter" | "bevel" | "round";
|
||||
|
||||
export type StrokeThickness = number | [number, number, number, number] | SidedThickness;
|
||||
|
||||
export type StyleOverride = { fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, opacity: number | null, };
|
||||
|
||||
export type StyledTextSegment = { text: string, fontFamily: string | null, fontSize: number | null, fontWeight: number | null, fontStyle: FontStyleKind | null, fill: string | null, underline: boolean | null, strikethrough: boolean | null, href: string | null, };
|
||||
|
||||
/**
|
||||
* On/off toggle. `checked` two-way binds via `bindings.bind:value`.
|
||||
*/
|
||||
export type SwitchNode = { width: SizingBehavior | null, height: SizingBehavior | null, checked: BoolOrExpression | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
/**
|
||||
* Tabbed panel switcher. Unlike the leaf widgets this is a CONTAINER:
|
||||
* `children[i]` is the panel for `tabs[i]`. The active tab `value`
|
||||
* two-way binds via `bindings.bind:value`; only the active panel is
|
||||
* painted at runtime.
|
||||
*/
|
||||
export type TabsNode = { width: SizingBehavior | null, height: SizingBehavior | null,
|
||||
/**
|
||||
* Tab bar entries; `value` keys the active tab, `label` is shown.
|
||||
*/
|
||||
tabs: Array<SelectOption> | null,
|
||||
/**
|
||||
* Currently active tab `value`.
|
||||
*/
|
||||
value: string | null,
|
||||
/**
|
||||
* Panel subtrees, one per tab (parallel to `tabs` by index).
|
||||
*/
|
||||
children: Array<PenNode> | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
export type TextAlign = "left" | "center" | "right" | "justify";
|
||||
|
||||
export type TextAlignVertical = "top" | "middle" | "bottom";
|
||||
|
||||
/**
|
||||
* Multi-line writable text input. Like `text_input` but wraps and
|
||||
* scrolls vertically; two-way binds via `bindings.bind:value`.
|
||||
*/
|
||||
export type TextAreaNode = { width: SizingBehavior | null, height: SizingBehavior | null,
|
||||
/**
|
||||
* Placeholder shown when `value` is empty.
|
||||
*/
|
||||
placeholder: string | null,
|
||||
/**
|
||||
* Initial value. Two-way binding lives on `bindings.bind:value`.
|
||||
*/
|
||||
value: string | null,
|
||||
/**
|
||||
* Lucide glyph drawn at the left content edge. See `TextInputNode`.
|
||||
*/
|
||||
leadingIcon: string | null,
|
||||
/**
|
||||
* Lucide glyph drawn at the right content edge. See `TextInputNode`.
|
||||
*/
|
||||
trailingIcon: string | null,
|
||||
/**
|
||||
* Visible-line window before the content scrolls (chat-style).
|
||||
*/
|
||||
maxVisibleLines: number | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
export type TextContent = string | Array<StyledTextSegment>;
|
||||
|
||||
export type TextGrowth = "auto" | "fixed-width" | "fixed-width-height";
|
||||
|
||||
/**
|
||||
* Single-line text input. Forms / counters need a writable input
|
||||
* surface that two-way binds via `bindings.bind:value`. The walker
|
||||
* renders a styled rectangle + caret placeholder; full IME and
|
||||
* selection-painter wiring lands in the desktop host (Plan 8) once
|
||||
* the gesture arena gains `Focus` recognizers.
|
||||
*/
|
||||
export type TextInputNode = { width: SizingBehavior | null, height: SizingBehavior | null,
|
||||
/**
|
||||
* Placeholder shown when `value` is empty. Static text — author
|
||||
* `bindings.placeholder` if it needs to react to state.
|
||||
*/
|
||||
placeholder: string | null,
|
||||
/**
|
||||
* Initial value. Two-way binding lives on `bindings.bind:value`,
|
||||
* which derive lifts into a `set_*` action and the runtime keeps
|
||||
* in sync with the state graph.
|
||||
*/
|
||||
value: string | null,
|
||||
/**
|
||||
* Lucide glyph drawn at the left content edge (e.g. `mail`, `lock`).
|
||||
* The painter insets the text/caret past it so the whole box stays
|
||||
* one interactive node. `None` = no leading icon.
|
||||
*/
|
||||
leadingIcon: string | null,
|
||||
/**
|
||||
* Lucide glyph drawn at the right content edge (e.g. `eye` for a
|
||||
* password reveal). Decorative in Phase 1 (no toggle behaviour).
|
||||
*/
|
||||
trailingIcon: string | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
export type TextNode = { width: SizingBehavior | null, height: SizingBehavior | null, content: TextContent, fontFamily: string | null, fontSize: number | null, fontWeight: FontWeight | null, fontStyle: FontStyleKind | null, letterSpacing: number | null, lineHeight: number | null, textAlign: TextAlign | null, textAlignVertical: TextAlignVertical | null, textGrowth: TextGrowth | null, underline: boolean | null, strikethrough: boolean | null, fill: Array<PenFill> | null, effects: Array<PenEffect> | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, };
|
||||
|
||||
export type ThemedValue = { value: VariableScalar, theme: { [key in string]?: string } | null, };
|
||||
|
||||
export type Transition = "push" | "fade" | "modal" | "none";
|
||||
|
||||
export type VariableDefinition = { type: VariableKind, value: VariableValue, };
|
||||
|
||||
export type VariableKind = "color" | "number" | "boolean" | "string";
|
||||
|
||||
export type VariableScalar = boolean | number | string;
|
||||
|
||||
export type VariableValue = VariableScalar | Array<ThemedValue>;
|
||||
|
||||
/**
|
||||
* Authored overrides for the four auto-derived interaction states.
|
||||
*/
|
||||
export type WidgetStates = { hover: StyleOverride | null, pressed: StyleOverride | null, focused: StyleOverride | null, disabled: StyleOverride | null, };
|
||||
15
packages/op-web-sdk/src/types.ts
Normal file
15
packages/op-web-sdk/src/types.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Public types for the OpenPencil read-only web SDK.
|
||||
export interface Viewport {
|
||||
panX: number;
|
||||
panY: number;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export interface CreateViewerOptions {
|
||||
canvas: HTMLCanvasElement;
|
||||
doc?: string | Uint8Array;
|
||||
wasmUrl?: string;
|
||||
}
|
||||
|
||||
// Re-export generated document types from the vendored ops schema.
|
||||
export type { PenDocument, PenPage } from './ops-types.js';
|
||||
141
packages/op-web-sdk/src/viewer.ts
Normal file
141
packages/op-web-sdk/src/viewer.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// OpViewer wrapper around the wasm Viewer class, providing read-only document snapshots.
|
||||
import { ensureWasm, WasmViewer, _export } from './wasm.js';
|
||||
import type { CreateViewerOptions, Viewport } from './types.js';
|
||||
import type { PenDocument, PenPage } from './ops-types.js';
|
||||
import { Emitter } from './events.js';
|
||||
import type { ViewerEvent } from './events.js';
|
||||
|
||||
let canvasSeq = 0;
|
||||
|
||||
/** Ensure the canvas element has an id, assigning one if absent. */
|
||||
function ensureCanvasId(canvas: HTMLCanvasElement): string {
|
||||
if (!canvas.id) canvas.id = `op-web-sdk-canvas-${++canvasSeq}`;
|
||||
return canvas.id;
|
||||
}
|
||||
|
||||
/** Convert a string-or-binary source to UTF-8 text for the wasm load_str call. */
|
||||
function toText(src: string | Uint8Array): string {
|
||||
return typeof src === 'string' ? src : new TextDecoder().decode(src);
|
||||
}
|
||||
|
||||
/** Read-only wrapper around the wasm Viewer. Returns typed PenDocument / PenPage snapshots. */
|
||||
export class OpViewer {
|
||||
/** @internal */ wheelHandler?: (e: WheelEvent) => void;
|
||||
/** @internal */ canvas?: HTMLCanvasElement;
|
||||
private readonly emitter = new Emitter();
|
||||
private destroyed = false;
|
||||
|
||||
/** @internal */ constructor(private readonly inner: InstanceType<typeof WasmViewer>) {}
|
||||
|
||||
/** Throw if the viewer has already been destroyed. */
|
||||
private assertLive(): void {
|
||||
if (this.destroyed) throw new Error('op-web-sdk: viewer has been destroyed');
|
||||
}
|
||||
|
||||
/** Subscribe to a viewer event. Returns an unsubscribe function. */
|
||||
on(event: ViewerEvent, cb: () => void): () => void { return this.emitter.on(event, cb); }
|
||||
|
||||
/** Unsubscribe a specific callback from a viewer event. */
|
||||
off(event: ViewerEvent, cb: () => void): void { this.emitter.off(event, cb); }
|
||||
|
||||
/** @internal Fire an event — used by createViewer for wheel-driven viewport changes. */
|
||||
emit(event: ViewerEvent): void { this.emitter.emit(event); }
|
||||
|
||||
/** Load (or reload) a document from a JSON string or binary blob. */
|
||||
load(src: string | Uint8Array): void {
|
||||
this.assertLive();
|
||||
this.inner.load_str(toText(src));
|
||||
this.inner.push_scene();
|
||||
this.emitter.emit('load');
|
||||
}
|
||||
|
||||
/** Parsed document object. Returns a typed PenDocument snapshot. */
|
||||
get document(): PenDocument {
|
||||
this.assertLive();
|
||||
return JSON.parse(this.inner.document_json()) as PenDocument;
|
||||
}
|
||||
|
||||
/** Parsed pages array. Returns a typed PenPage[] snapshot. */
|
||||
get pages(): PenPage[] {
|
||||
this.assertLive();
|
||||
return JSON.parse(this.inner.pages_json()) as PenPage[];
|
||||
}
|
||||
|
||||
/** Total number of pages in the loaded document. */
|
||||
get pageCount(): number {
|
||||
this.assertLive();
|
||||
return this.inner.page_count();
|
||||
}
|
||||
|
||||
/** Zero-based index of the currently active page. */
|
||||
get activePage(): number {
|
||||
this.assertLive();
|
||||
return this.inner.active_page_index();
|
||||
}
|
||||
|
||||
/** Current viewport state parsed from wasm (snake_case → camelCase). */
|
||||
get viewport(): Viewport {
|
||||
this.assertLive();
|
||||
const v = JSON.parse(this.inner.viewport_json()) as { pan_x: number; pan_y: number; zoom: number };
|
||||
return { panX: v.pan_x, panY: v.pan_y, zoom: v.zoom };
|
||||
}
|
||||
|
||||
/** Set viewport pan and zoom simultaneously. */
|
||||
setViewport(v: Viewport): void { this.assertLive(); this.inner.set_viewport(v.panX, v.panY, v.zoom); this.emitter.emit('viewportchange'); }
|
||||
|
||||
/** Change zoom level while keeping current pan position. */
|
||||
setZoom(z: number): void { this.assertLive(); const c = this.viewport; this.inner.set_viewport(c.panX, c.panY, z); this.emitter.emit('viewportchange'); }
|
||||
|
||||
/** Pan to a new position while keeping current zoom level. */
|
||||
panTo(panX: number, panY: number): void { this.assertLive(); const c = this.viewport; this.inner.set_viewport(panX, panY, c.zoom); this.emitter.emit('viewportchange'); }
|
||||
|
||||
/** Zoom to fit the given width/height into the canvas viewport. */
|
||||
zoomToFit(w: number, h: number): void { this.assertLive(); this.inner.zoom_to_fit(w, h); this.emitter.emit('viewportchange'); }
|
||||
|
||||
/** Export the current document to SVG bytes.
|
||||
* Only 'svg' is supported in v1; any other format throws immediately. */
|
||||
export(opts: { format: 'svg' }): Uint8Array {
|
||||
this.assertLive();
|
||||
if (opts.format !== 'svg') throw new Error(`op-web-sdk: format "${opts.format}" not supported in v1 (use 'svg')`);
|
||||
return _export(this.inner, 'svg');
|
||||
}
|
||||
|
||||
/** Tear down the viewer: remove the wheel listener, detach from the canvas,
|
||||
* free the wasm instance, and clear all event subscriptions.
|
||||
* Idempotent — safe to call more than once. */
|
||||
destroy(): void {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
if (this.canvas && this.wheelHandler) this.canvas.removeEventListener('wheel', this.wheelHandler);
|
||||
this.inner.detach();
|
||||
this.inner.free();
|
||||
this.emitter.clear();
|
||||
}
|
||||
|
||||
/** @internal Expose the underlying wasm instance for sub-class access. */
|
||||
get _inner() {
|
||||
return this.inner;
|
||||
}
|
||||
}
|
||||
|
||||
/** Initialise the wasm module, create a Viewer bound to the given canvas,
|
||||
* optionally load an initial document, and return an OpViewer. */
|
||||
export async function createViewer(opts: CreateViewerOptions): Promise<OpViewer> {
|
||||
await ensureWasm(opts.wasmUrl);
|
||||
const inner = new WasmViewer();
|
||||
const viewer = new OpViewer(inner);
|
||||
if (opts.doc !== undefined) viewer.load(opts.doc);
|
||||
const id = ensureCanvasId(opts.canvas);
|
||||
await inner.attach_canvas(id);
|
||||
// Store canvas reference and attach a non-passive wheel listener to forward
|
||||
// scroll/pinch events to the wasm renderer with cursor-relative coordinates.
|
||||
viewer.canvas = opts.canvas;
|
||||
viewer.wheelHandler = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const r = opts.canvas.getBoundingClientRect();
|
||||
inner.forward_wheel(e.deltaX, e.deltaY, e.ctrlKey || e.metaKey, e.clientX - r.left, e.clientY - r.top);
|
||||
viewer.emit('viewportchange');
|
||||
};
|
||||
opts.canvas.addEventListener('wheel', viewer.wheelHandler, { passive: false });
|
||||
return viewer;
|
||||
}
|
||||
25
packages/op-web-sdk/src/wasm-types.d.ts
vendored
Normal file
25
packages/op-web-sdk/src/wasm-types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Type declarations for the virtual wasm glue module.
|
||||
// The virtual specifier 'virtual:op_web_sdk_wasm' is resolved by the Vite plugin
|
||||
// in vitest.config.ts (to the test stub) and in tsup.config.ts (to the real bundle).
|
||||
// Using a virtual specifier avoids requiring a physical wasm/ file for tsc or Vitest.
|
||||
declare module 'virtual:op_web_sdk_wasm' {
|
||||
export class Viewer {
|
||||
constructor();
|
||||
free(): void;
|
||||
load_str(src: string): void;
|
||||
page_count(): number;
|
||||
active_page_index(): number;
|
||||
attach_canvas(canvas_id: string): Promise<void>;
|
||||
detach(): void;
|
||||
mark_dirty(): void;
|
||||
push_scene(): void;
|
||||
set_viewport(pan_x: number, pan_y: number, zoom: number): void;
|
||||
zoom_to_fit(w: number, h: number): void;
|
||||
forward_wheel(dx: number, dy: number, ctrl_or_meta: boolean, cursor_x: number, cursor_y: number): void;
|
||||
document_json(): string;
|
||||
pages_json(): string;
|
||||
viewport_json(): string;
|
||||
}
|
||||
export function _export(viewer: Viewer, format: string): Uint8Array;
|
||||
export default function init(module_or_path?: unknown): Promise<unknown>;
|
||||
}
|
||||
13
packages/op-web-sdk/src/wasm.ts
Normal file
13
packages/op-web-sdk/src/wasm.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Single seam to the Plan-1 wasm bundle. All SDK code imports the wasm
|
||||
// Viewer through here so unit tests can mock this module.
|
||||
import init, { Viewer as WasmViewer, _export } from 'virtual:op_web_sdk_wasm';
|
||||
|
||||
let started: Promise<unknown> | null = null;
|
||||
|
||||
/** Initialise the wasm module exactly once. `url` overrides the .wasm asset URL. */
|
||||
export async function ensureWasm(url?: string): Promise<void> {
|
||||
if (!started) started = init(url ? { module_or_path: url } : undefined);
|
||||
await started;
|
||||
}
|
||||
|
||||
export { WasmViewer, _export };
|
||||
20
packages/op-web-sdk/test/events.test.ts
Normal file
20
packages/op-web-sdk/test/events.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MockViewer } from './mock-wasm.js';
|
||||
let current: MockViewer;
|
||||
vi.mock('../src/wasm.js', () => ({
|
||||
ensureWasm: vi.fn(async () => {}), WasmViewer: vi.fn(() => { current = new MockViewer(); return current; }), _export: vi.fn(),
|
||||
}));
|
||||
import { createViewer } from '../src/index.js';
|
||||
|
||||
describe('events', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
it('fires viewportchange on setZoom and stops after off', async () => {
|
||||
const v = await createViewer({ canvas: document.createElement('canvas') });
|
||||
const cb = vi.fn();
|
||||
const off = v.on('viewportchange', cb);
|
||||
v.setZoom(2);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
off(); v.setZoom(3);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
36
packages/op-web-sdk/test/export-destroy.test.ts
Normal file
36
packages/op-web-sdk/test/export-destroy.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MockViewer, mockExport } from './mock-wasm.js';
|
||||
let current: MockViewer;
|
||||
vi.mock('../src/wasm.js', () => ({
|
||||
ensureWasm: vi.fn(async () => {}), WasmViewer: vi.fn(() => { current = new MockViewer(); return current; }), _export: mockExport,
|
||||
}));
|
||||
import { createViewer } from '../src/index.js';
|
||||
|
||||
describe('export + destroy', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
it('exports svg bytes and rejects png', async () => {
|
||||
const v = await createViewer({ canvas: document.createElement('canvas') });
|
||||
expect(v.export({ format: 'svg' })).toBeInstanceOf(Uint8Array);
|
||||
expect(() => v.export({ format: 'png' as 'svg' })).toThrow();
|
||||
});
|
||||
it('destroy detaches, frees, and removes the wheel listener', async () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const v = await createViewer({ canvas });
|
||||
v.destroy();
|
||||
expect(current.detach).toHaveBeenCalled();
|
||||
expect(current.free).toHaveBeenCalled();
|
||||
canvas.dispatchEvent(new WheelEvent('wheel', { bubbles: true }));
|
||||
expect(current.forward_wheel).not.toHaveBeenCalled();
|
||||
});
|
||||
it('calling a method after destroy throws the use-after-free error', async () => {
|
||||
const v = await createViewer({ canvas: document.createElement('canvas') });
|
||||
v.destroy();
|
||||
expect(() => v.setZoom(2)).toThrow('op-web-sdk: viewer has been destroyed');
|
||||
});
|
||||
it('double-destroy is a no-op and does not call free twice', async () => {
|
||||
const v = await createViewer({ canvas: document.createElement('canvas') });
|
||||
v.destroy();
|
||||
expect(() => v.destroy()).not.toThrow();
|
||||
expect(current.free).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
27
packages/op-web-sdk/test/mock-wasm.ts
Normal file
27
packages/op-web-sdk/test/mock-wasm.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { vi } from 'vitest';
|
||||
// In-memory stand-in for the wasm Viewer so unit tests run in jsdom.
|
||||
export class MockViewer {
|
||||
private docJson = '{}';
|
||||
private pagesJson = '[]';
|
||||
private vp = { pan_x: 0, pan_y: 0, zoom: 1 };
|
||||
free = vi.fn();
|
||||
load_str = vi.fn((src: string) => { this.docJson = src; this.pagesJson = '[{"id":"p","name":"P","children":[]}]'; });
|
||||
page_count = vi.fn(() => 1);
|
||||
active_page_index = vi.fn(() => 0);
|
||||
attach_canvas = vi.fn(async () => {});
|
||||
detach = vi.fn();
|
||||
mark_dirty = vi.fn();
|
||||
push_scene = vi.fn();
|
||||
set_viewport = vi.fn((x: number, y: number, z: number) => { this.vp = { pan_x: x, pan_y: y, zoom: z }; });
|
||||
zoom_to_fit = vi.fn(() => { this.vp = { pan_x: 0, pan_y: 0, zoom: 2 }; });
|
||||
forward_wheel = vi.fn();
|
||||
document_json = vi.fn(() => this.docJson);
|
||||
pages_json = vi.fn(() => this.pagesJson);
|
||||
viewport_json = vi.fn(() => JSON.stringify(this.vp));
|
||||
}
|
||||
export const mockExport = vi.fn((_v: unknown, format: string) => {
|
||||
// Use a typed-array literal instead of TextEncoder to avoid jsdom cross-realm
|
||||
// Uint8Array instanceof failures (TextEncoder in jsdom returns a Node-realm Uint8Array).
|
||||
if (format === 'svg') return new Uint8Array([60, 115, 118, 103, 47, 62]); // '<svg/>'
|
||||
throw new Error('not available');
|
||||
});
|
||||
26
packages/op-web-sdk/test/navigation.test.ts
Normal file
26
packages/op-web-sdk/test/navigation.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MockViewer } from './mock-wasm.js';
|
||||
let current: MockViewer;
|
||||
vi.mock('../src/wasm.js', () => ({
|
||||
ensureWasm: vi.fn(async () => {}),
|
||||
WasmViewer: vi.fn(() => { current = new MockViewer(); return current; }),
|
||||
_export: vi.fn(),
|
||||
}));
|
||||
import { createViewer } from '../src/index.js';
|
||||
|
||||
describe('navigation', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
it('setZoom keeps pan and changes zoom; viewport reflects it', async () => {
|
||||
const v = await createViewer({ canvas: document.createElement('canvas') });
|
||||
v.setViewport({ panX: 3, panY: 4, zoom: 1 });
|
||||
v.setZoom(2);
|
||||
expect(current.set_viewport).toHaveBeenLastCalledWith(3, 4, 2);
|
||||
expect(v.viewport).toEqual({ panX: 3, panY: 4, zoom: 2 });
|
||||
});
|
||||
it('forwards wheel events to the wasm viewer', async () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
await createViewer({ canvas });
|
||||
canvas.dispatchEvent(new WheelEvent('wheel', { deltaX: 5, deltaY: -10, ctrlKey: true, bubbles: true }));
|
||||
expect(current.forward_wheel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
8
packages/op-web-sdk/test/scaffold.test.ts
Normal file
8
packages/op-web-sdk/test/scaffold.test.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { VERSION } from '../src/index.js';
|
||||
|
||||
describe('package scaffold', () => {
|
||||
it('exposes a VERSION string', () => {
|
||||
expect(typeof VERSION).toBe('string');
|
||||
});
|
||||
});
|
||||
22
packages/op-web-sdk/test/viewer.test.ts
Normal file
22
packages/op-web-sdk/test/viewer.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MockViewer } from './mock-wasm.js';
|
||||
let current: MockViewer;
|
||||
vi.mock('../src/wasm.js', () => ({
|
||||
ensureWasm: vi.fn(async () => {}),
|
||||
WasmViewer: vi.fn(() => { current = new MockViewer(); return current; }),
|
||||
_export: vi.fn(),
|
||||
}));
|
||||
import { createViewer } from '../src/index.js';
|
||||
|
||||
function makeCanvas() { return document.createElement('canvas'); }
|
||||
|
||||
describe('createViewer + snapshots', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
it('loads the doc and exposes page count', async () => {
|
||||
const v = await createViewer({ canvas: makeCanvas(), doc: '{"version":"1.0","pages":[{"id":"p","name":"P","children":[]}]}' });
|
||||
expect(current.load_str).toHaveBeenCalled();
|
||||
expect(v.pageCount).toBe(1);
|
||||
expect(v.activePage).toBe(0);
|
||||
expect(Array.isArray(v.pages)).toBe(true);
|
||||
});
|
||||
});
|
||||
31
packages/op-web-sdk/test/wasm-stub.ts
Normal file
31
packages/op-web-sdk/test/wasm-stub.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Committed test stub for the wasm glue module.
|
||||
// Vitest resolves '../wasm/op_web_sdk.js' to this file via the alias in
|
||||
// vitest.config.ts so tests pass on a fresh clone with no wasm/ dir present.
|
||||
|
||||
/** Minimal stand-in for the wasm-bindgen Viewer class. */
|
||||
export class Viewer {
|
||||
free(): void {}
|
||||
load_str(_src: string): void {}
|
||||
page_count(): number { return 0; }
|
||||
active_page_index(): number { return 0; }
|
||||
async attach_canvas(_canvas_id: string): Promise<void> {}
|
||||
detach(): void {}
|
||||
mark_dirty(): void {}
|
||||
push_scene(): void {}
|
||||
set_viewport(_pan_x: number, _pan_y: number, _zoom: number): void {}
|
||||
zoom_to_fit(_w: number, _h: number): void {}
|
||||
forward_wheel(_dx: number, _dy: number, _ctrl_or_meta: boolean, _cursor_x: number, _cursor_y: number): void {}
|
||||
document_json(): string { return '{}'; }
|
||||
pages_json(): string { return '[]'; }
|
||||
viewport_json(): string { return '{}'; }
|
||||
}
|
||||
|
||||
/** Minimal stand-in for the wasm _export function. */
|
||||
export function _export(_viewer: Viewer, _format: string): Uint8Array {
|
||||
return new Uint8Array();
|
||||
}
|
||||
|
||||
/** Async no-op standing in for the wasm-bindgen init default export. */
|
||||
export default async function init(_module_or_path?: unknown): Promise<Record<string, never>> {
|
||||
return {};
|
||||
}
|
||||
19
packages/op-web-sdk/test/wasm.test.ts
Normal file
19
packages/op-web-sdk/test/wasm.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock the virtual wasm module (resolved to test/wasm-stub.ts by vitest.config.ts).
|
||||
// vi.mock hoists before imports, so the spy is in place when ensureWasm loads.
|
||||
vi.mock('virtual:op_web_sdk_wasm', () => {
|
||||
const init = vi.fn(async () => ({}));
|
||||
return { default: init, Viewer: class {}, _export: vi.fn(() => new Uint8Array()) };
|
||||
});
|
||||
|
||||
import { ensureWasm } from '../src/wasm.js';
|
||||
import initGlue from 'virtual:op_web_sdk_wasm';
|
||||
|
||||
describe('ensureWasm', () => {
|
||||
it('calls the wasm init exactly once across two calls', async () => {
|
||||
await ensureWasm();
|
||||
await ensureWasm();
|
||||
expect(initGlue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
11
packages/op-web-sdk/tsconfig.json
Normal file
11
packages/op-web-sdk/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler",
|
||||
"strict": true, "declaration": true, "skipLibCheck": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vitest/globals"], "noEmit": true,
|
||||
"paths": {
|
||||
"virtual:op_web_sdk_wasm": ["./test/wasm-stub.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
25
packages/op-web-sdk/tsup.config.ts
Normal file
25
packages/op-web-sdk/tsup.config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
// The wasm glue is imported via the virtual specifier `virtual:op_web_sdk_wasm`
|
||||
// so vitest (resolveId plugin) and tsc (tsconfig paths) resolve it to a stub
|
||||
// without the real bundle present. For the production build, alias it to the
|
||||
// real wasm-bindgen glue at `wasm/op_web_sdk.js`, which `sync-wasm.sh`
|
||||
// populates before `bun run build`. (Build is deferred-verification: it needs
|
||||
// tsup installed and the synced bundle on disk.)
|
||||
esbuildOptions(options) {
|
||||
options.alias = {
|
||||
...options.alias,
|
||||
'virtual:op_web_sdk_wasm': resolve(here, 'wasm/op_web_sdk.js'),
|
||||
};
|
||||
},
|
||||
});
|
||||
33
packages/op-web-sdk/vitest.config.ts
Normal file
33
packages/op-web-sdk/vitest.config.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { defineConfig, type Plugin } from 'vitest/config';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
// Absolute path to the committed test stub.
|
||||
const wasmStubPath = path.resolve(__dirname, 'test/wasm-stub.ts');
|
||||
|
||||
// The virtual module specifier used in src/wasm.ts and test files.
|
||||
const VIRTUAL_WASM_ID = 'virtual:op_web_sdk_wasm';
|
||||
|
||||
/**
|
||||
* Vite plugin that resolves the virtual wasm specifier to the committed test
|
||||
* stub during tests, so import-analysis succeeds on a fresh clone with no
|
||||
* wasm/ dir present. The real bundle is resolved differently at build time
|
||||
* (by tsup/externals or by sync-wasm.sh populating wasm/).
|
||||
*/
|
||||
function wasmStubPlugin(): Plugin {
|
||||
return {
|
||||
name: 'wasm-stub',
|
||||
enforce: 'pre',
|
||||
resolveId(id) {
|
||||
if (id === VIRTUAL_WASM_ID) return wasmStubPath;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [wasmStubPlugin()],
|
||||
test: { environment: 'jsdom', globals: true },
|
||||
});
|
||||
Loading…
Reference in a new issue