feat(sdk): add op-web-sdk read-only web viewer crate
Plan 1 of the web embedding SDK (TS-retirement Phase 2): a wasm Viewer that parses a .op document, renders it read-only via CanvasKit by reusing op-editor-ui's canvas_viewport, supports pan/zoom navigation, exposes read-only JSON snapshots, and exports SVG. Type-gen reuses jian-ops-schema's ts-rs export. Additive: new crate plus surgical cold pub exposures (CanvasViewport::from_scene in op-editor-ui; pub mod canvaskit + pub init_backend in op-host-web). No TS deleted. 17 tests; wasm 2.2 MiB gzip (0 env.* imports); clippy -D warnings clean.
This commit is contained in:
parent
70e6e76e29
commit
75ec668463
108
.github/workflows/web-sdk-bundle.yml
vendored
Normal file
108
.github/workflows/web-sdk-bundle.yml
vendored
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
name: op-web-sdk bundle build + size gate
|
||||
|
||||
# Builds the op-web-sdk WASM package (canvaskit feature), asserts 0 env.*
|
||||
# imports and gzip size <= 6 MiB, then uploads pkg/ as the `op-web-sdk-bundle`
|
||||
# artifact. This is the CI counterpart of the local
|
||||
# `crates/op-web-sdk/tools/build-wasm.sh` developer gate.
|
||||
#
|
||||
# Mirrors `wasm-bundle-build.yml` (op-host-web gate) verbatim except for the
|
||||
# crate path, artifact name, and env-var ceiling override name. In particular,
|
||||
# this job does NOT use wasm-pack: wasm-pack 0.13.1 is broken on stable Rust
|
||||
# (schema mismatch) and introduces a floating binary install risk. Instead we
|
||||
# use the same cargo + wasm-bindgen-cli (version-pinned from Cargo.lock) +
|
||||
# wasm-opt pipeline that `wasm-bundle-build.yml` uses.
|
||||
#
|
||||
# Authoritative recipe mirrored from `crates/op-web-sdk/tools/build-wasm.sh`:
|
||||
# prerequisites: cargo, wasm-bindgen, wasm-opt, node, gzip (NO wasm-pack, NO EMSDK)
|
||||
# 1. cargo build -p op-web-sdk --target wasm32-unknown-unknown
|
||||
# --features canvaskit --release
|
||||
# 2. wasm-bindgen --target web --out-dir crates/op-web-sdk/pkg <target.wasm>
|
||||
# 3. assert 0 env.* imports (LinkError guard)
|
||||
# 4. wasm-opt -Oz with rustc-emitted WebAssembly feature flags, then
|
||||
# gzip size <= ceiling (default 6291456 bytes = 6 MiB, overridable via
|
||||
# OP_WEB_SDK_WASM_GZIP_LIMIT_BYTES)
|
||||
# The job calls the script directly — it is the single source of truth for the
|
||||
# gate logic.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'crates/op-web-sdk/**'
|
||||
- 'deny.toml'
|
||||
- '.github/workflows/web-sdk-bundle.yml'
|
||||
push:
|
||||
branches: ['**']
|
||||
paths:
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'crates/op-web-sdk/**'
|
||||
- '.github/workflows/web-sdk-bundle.yml'
|
||||
# Allow on-demand runs so a release can produce the artifact without a code
|
||||
# change (e.g. to re-bundle against an updated toolchain).
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-web-sdk-bundle:
|
||||
name: build + size-gate op-web-sdk wasm bundle
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: '1.94'
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: web-sdk-bundle
|
||||
|
||||
- name: Install wasm-bindgen-cli (pinned to the locked version)
|
||||
run: |
|
||||
# Pin the CLI to the same version as the `wasm-bindgen` crate in
|
||||
# Cargo.lock so the generated JS glue matches the linked runtime —
|
||||
# a version skew between the two causes a "schema version mismatch"
|
||||
# panic at mount time. Read the locked version straight out of
|
||||
# Cargo.lock (the [[package]] block whose name is exactly `wasm-bindgen`).
|
||||
# This mirrors wasm-bundle-build.yml exactly (same awk extractor).
|
||||
version="$(awk '/^name = "wasm-bindgen"$/{found=1; next} found && /^version = /{gsub(/[" ]/,"",$3); print $3; exit}' Cargo.lock)"
|
||||
if [ -z "$version" ]; then
|
||||
echo "::error::could not resolve wasm-bindgen version from Cargo.lock"
|
||||
exit 1
|
||||
fi
|
||||
echo "Installing wasm-bindgen-cli $version"
|
||||
cargo install wasm-bindgen-cli --version "$version" --locked
|
||||
|
||||
- name: Install binaryen (wasm-opt)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y binaryen
|
||||
wasm-opt --version
|
||||
|
||||
- name: Verify node + gzip are present (script prerequisites)
|
||||
run: |
|
||||
node --version
|
||||
gzip --version | head -n1
|
||||
wasm-bindgen --version
|
||||
|
||||
# Single source of truth: run the crate-local gate script verbatim. It
|
||||
# runs cargo build (canvaskit) -> wasm-bindgen --target web ->
|
||||
# 0-env-import assert -> wasm-opt -Oz -> gzip size <= ceiling, and exits
|
||||
# non-zero on any breach.
|
||||
- name: Build + size-gate the op-web-sdk bundle
|
||||
run: bash crates/op-web-sdk/tools/build-wasm.sh
|
||||
|
||||
- name: Upload op-web-sdk-bundle artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: op-web-sdk-bundle
|
||||
# Upload the full wasm-pack output directory: wasm + JS glue + .d.ts.
|
||||
path: crates/op-web-sdk/pkg
|
||||
if-no-files-found: error
|
||||
# Keep briefly so a downstream release / Docker build can pull it
|
||||
# without rebuilding.
|
||||
retention-days: 14
|
||||
17
Cargo.lock
generated
17
Cargo.lock
generated
|
|
@ -3390,6 +3390,23 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "op-web-sdk"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"jian-ops-schema",
|
||||
"op-editor-core",
|
||||
"op-editor-ui",
|
||||
"op-host-web",
|
||||
"op-pen-loader",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
|
|
|
|||
|
|
@ -387,6 +387,37 @@ impl<'a> CanvasViewport<'a> {
|
|||
frame_labels: collect_frame_labels(state),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only construction for the embedding SDK: paints `scene` at
|
||||
/// `viewport` with no selection / tool / editing affordances. Frame
|
||||
/// labels are derived from the scene's top-level node names rather
|
||||
/// than an `EditorState`, so no editor is required to render.
|
||||
///
|
||||
/// All editor-specific fields (selection, pen draft, text-edit,
|
||||
/// hover, guides) are left empty/default so the widget paints a
|
||||
/// clean viewer layer — no overlays, no interactive affordances.
|
||||
pub fn from_scene(scene: &'a LayoutScene, viewport: DocViewport, theme: Theme) -> Self {
|
||||
let canvas_background = theme.canvas_surface;
|
||||
Self {
|
||||
id: WidgetId::new(4000),
|
||||
viewport,
|
||||
scene,
|
||||
selected: String::new(),
|
||||
selected_set: Vec::new(),
|
||||
tool: op_editor_core::Tool::Select,
|
||||
pen_in_progress: None,
|
||||
pen_cursor_doc: None,
|
||||
pen_dragging_handle: false,
|
||||
active_guides: Vec::new(),
|
||||
text_editing: None,
|
||||
text_edit_input: Default::default(),
|
||||
canvas_background,
|
||||
theme,
|
||||
now_ms: 0,
|
||||
hovered: None,
|
||||
frame_labels: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Root-frame name labels (TS `drawFrameLabelColored` over
|
||||
|
|
@ -720,3 +751,7 @@ pub(super) fn paint_dashed_rect(cx: &mut PaintCx<'_>, rect: Rect, color: Color,
|
|||
#[cfg(test)]
|
||||
#[path = "canvas_viewport_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "canvas_viewport_from_scene_tests.rs"]
|
||||
mod from_scene_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
//! Gate spike test: proves `CanvasViewport::from_scene` can paint from a
|
||||
//! `LayoutScene` + `DocViewport` without an `EditorState`.
|
||||
//!
|
||||
//! Step 1 (RED): run `cargo test -p op-editor-ui from_scene_paints -- --nocapture`
|
||||
//! and confirm FAIL because `from_scene` is not yet defined.
|
||||
//!
|
||||
//! Step 4 (GREEN): after `from_scene` is added, the same run must PASS.
|
||||
|
||||
use crate::layout_scene::{LayoutScene, ScenePage};
|
||||
use crate::theme::Theme;
|
||||
use crate::widgets::canvas_viewport::CanvasViewport;
|
||||
use crate::widgets::{PaintCx, Widget};
|
||||
use crate::{Color, Point2D, Rect, RenderBackend, TextLayout};
|
||||
use op_editor_core::Viewport as DocViewport;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Counting backend — records fill_rect calls to verify paint happened.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct CaptureBackend {
|
||||
fill_rect_calls: usize,
|
||||
}
|
||||
|
||||
impl CaptureBackend {
|
||||
fn new(_width: u32, _height: u32) -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn fill_rect_count(&self) -> usize {
|
||||
self.fill_rect_calls
|
||||
}
|
||||
|
||||
fn paint_cx(&mut self) -> PaintCx<'_> {
|
||||
PaintCx {
|
||||
backend: self,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderBackend for CaptureBackend {
|
||||
fn begin_frame(&mut self) {}
|
||||
fn end_frame(&mut self) {}
|
||||
|
||||
fn fill_rect(&mut self, _rect: Rect, _color: Color) {
|
||||
self.fill_rect_calls += 1;
|
||||
}
|
||||
|
||||
fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {}
|
||||
fn draw_text(&mut self, _: &TextLayout, _: Point2D) {}
|
||||
fn clip_rect(&mut self, _: Rect) {}
|
||||
fn save(&mut self) {}
|
||||
fn restore(&mut self) {}
|
||||
fn translate(&mut self, _: Point2D) {}
|
||||
|
||||
fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {}
|
||||
|
||||
fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) {}
|
||||
fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {}
|
||||
fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {}
|
||||
|
||||
fn resize(&mut self, _: u32, _: u32) {}
|
||||
fn dpi_scale(&self) -> f32 {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gate spike test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn from_scene_paints_background_and_nodes_without_editor_state() {
|
||||
let scene = LayoutScene {
|
||||
pages: vec![ScenePage {
|
||||
id: "p".into(),
|
||||
name: "Page".into(),
|
||||
children: vec![],
|
||||
}],
|
||||
active_page_index: 0,
|
||||
};
|
||||
let vp = DocViewport {
|
||||
pan_x: 0.0,
|
||||
pan_y: 0.0,
|
||||
zoom: 1.0,
|
||||
};
|
||||
let view = CanvasViewport::from_scene(&scene, vp, Theme::dark());
|
||||
let mut backend = CaptureBackend::new(800, 600);
|
||||
{
|
||||
let mut cx = backend.paint_cx();
|
||||
view.paint(&mut cx, Rect::xywh(0.0, 0.0, 800.0, 600.0));
|
||||
}
|
||||
assert!(
|
||||
backend.fill_rect_count() >= 1,
|
||||
"expected at least the canvas background fill, got {}",
|
||||
backend.fill_rect_count()
|
||||
);
|
||||
}
|
||||
|
|
@ -604,7 +604,7 @@ impl RenderBackend for CanvasKitBackend {
|
|||
}
|
||||
|
||||
/// Initialise CanvasKit on `canvas_id` and return a ready `CanvasKitBackend`.
|
||||
pub(crate) async fn init_backend(
|
||||
pub async fn init_backend(
|
||||
canvas_id: &str,
|
||||
dpr: f32,
|
||||
logical_w: u32,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
// stub baseline (compile coverage only).
|
||||
mod a11y_dom;
|
||||
#[cfg(feature = "canvaskit")]
|
||||
mod canvaskit;
|
||||
pub mod canvaskit;
|
||||
pub mod event;
|
||||
// Hidden IME-capture input (#54). Pure web_sys — compiles under BOTH the
|
||||
// production `canvaskit` build (where the mount wires composition→apply_ime)
|
||||
|
|
|
|||
2
crates/op-web-sdk/.gitignore
vendored
Normal file
2
crates/op-web-sdk/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/pkg/
|
||||
/bindings/
|
||||
48
crates/op-web-sdk/Cargo.toml
Normal file
48
crates/op-web-sdk/Cargo.toml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
[package]
|
||||
name = "op-web-sdk"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
description = "OpenPencil read-only web embedding SDK — wasm Viewer (parse + render + navigate + export)"
|
||||
|
||||
[lib]
|
||||
name = "op_web_sdk"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
op-editor-ui = { path = "../op-editor-ui" }
|
||||
op-editor-core = { path = "../op-editor-core" }
|
||||
op-pen-loader = { path = "../op-pen-loader", default-features = false }
|
||||
jian-ops-schema = { path = "../../vendor/jian/crates/jian-ops-schema" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1" }
|
||||
# Pin matches op-host-web — last wasm-bindgen-cli release compatible with
|
||||
# the workspace's Rust 1.85 toolchain.
|
||||
wasm-bindgen = "=0.2.117"
|
||||
console_error_panic_hook = "0.1"
|
||||
|
||||
# CanvasKit render backend — reuse op-host-web's CanvasKitBackend + init_backend
|
||||
# so op-web-sdk never re-declares the op_ck_bridge.js JS module binding.
|
||||
op-host-web = { path = "../op-host-web", optional = true, default-features = false, features = ["canvaskit"] }
|
||||
# Async rAF helpers for the RAF loop
|
||||
wasm-bindgen-futures = { version = "0.4", optional = true }
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3"
|
||||
optional = true
|
||||
features = [
|
||||
"Document",
|
||||
"Element",
|
||||
"HtmlCanvasElement",
|
||||
"Window",
|
||||
]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# `canvaskit` enables the CanvasKit GPU render path via op-host-web.
|
||||
canvaskit = [
|
||||
"dep:op-host-web",
|
||||
"dep:wasm-bindgen-futures",
|
||||
"dep:web-sys",
|
||||
]
|
||||
77
crates/op-web-sdk/smoke/index.html
Normal file
77
crates/op-web-sdk/smoke/index.html
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>op-web-sdk smoke test</title>
|
||||
<style>
|
||||
body { margin: 0; background: #111; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
h1 { color: #ddd; font-family: sans-serif; margin-bottom: 12px; }
|
||||
canvas { border: 1px solid #333; display: block; }
|
||||
#status { color: #aaa; font-family: monospace; margin-top: 10px; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>OpenPencil Web SDK — smoke</h1>
|
||||
<!--
|
||||
Canvas element: 800×600 logical pixels.
|
||||
wasm-pack build command:
|
||||
wasm-pack build crates/op-web-sdk --target web --features canvaskit --out-dir smoke/pkg
|
||||
Then serve: python3 -m http.server 8080 from crates/op-web-sdk/
|
||||
Open: http://localhost:8080/smoke/index.html
|
||||
-->
|
||||
<canvas id="c" width="800" height="600" style="width:800px;height:600px;"></canvas>
|
||||
<div id="status">Loading WASM…</div>
|
||||
|
||||
<!-- Minimal fixture: a single-page .op document with one red rectangle. -->
|
||||
<script id="fixture" type="application/json">
|
||||
{
|
||||
"version": "1.0",
|
||||
"pages": [
|
||||
{
|
||||
"id": "page-1",
|
||||
"name": "Page 1",
|
||||
"children": [
|
||||
{
|
||||
"id": "rect-1",
|
||||
"kind": "rectangle",
|
||||
"name": "Red Box",
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"width": 200,
|
||||
"height": 120,
|
||||
"fillColor": "#e84040",
|
||||
"strokeColor": null,
|
||||
"strokeWidth": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import init, { Viewer } from './pkg/op_web_sdk.js';
|
||||
|
||||
const status = document.getElementById('status');
|
||||
|
||||
try {
|
||||
// 1. Initialise the wasm module.
|
||||
await init();
|
||||
status.textContent = 'WASM initialised — loading document…';
|
||||
|
||||
// 2. Construct the viewer and load the fixture document.
|
||||
const viewer = new Viewer();
|
||||
const fixture = document.getElementById('fixture').textContent;
|
||||
viewer.load_str(fixture);
|
||||
status.textContent = `Document loaded — pages: ${viewer.page_count()} — attaching canvas…`;
|
||||
|
||||
// 3. Attach the canvas and start the rAF render loop.
|
||||
await viewer.attach_canvas('c');
|
||||
status.textContent = `Rendering page 1 of ${viewer.page_count()} via CanvasKit ✓`;
|
||||
} catch (err) {
|
||||
status.textContent = `ERROR: ${err}`;
|
||||
console.error(err);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
36
crates/op-web-sdk/src/document.rs
Normal file
36
crates/op-web-sdk/src/document.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Document parsing and page snapshot accessors for Viewer.
|
||||
use crate::Viewer;
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
|
||||
impl Viewer {
|
||||
/// Parse a canonical `.op` JSON string (best-effort, legacy-tolerant).
|
||||
/// Immediately rebuilds the cached `LayoutScene` from the loaded document.
|
||||
/// Internal Rust API; the JS-facing wrapper is `Viewer::load_str` (maps the
|
||||
/// error to a `JsValue` so it surfaces as a thrown exception).
|
||||
pub fn load(&mut self, src: &str) -> Result<(), String> {
|
||||
let loaded = op_pen_loader::load_canonical(src).map_err(|e| format!("{e:?}"))?;
|
||||
self.doc = Some(loaded.value);
|
||||
self.active_page = 0;
|
||||
self.rebuild_scene();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only page accessors. Exported to JS so consumers (and the smoke page)
|
||||
/// can query the loaded document without a full snapshot round-trip.
|
||||
#[wasm_bindgen]
|
||||
impl Viewer {
|
||||
/// Number of pages in the loaded document.
|
||||
/// Returns 0 when no document is loaded; at least 1 otherwise.
|
||||
pub fn page_count(&self) -> usize {
|
||||
self.doc
|
||||
.as_ref()
|
||||
.map(|d| d.pages.as_ref().map(|p| p.len()).unwrap_or(1).max(1))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Index of the currently active (visible) page (0-based).
|
||||
pub fn active_page_index(&self) -> usize {
|
||||
self.active_page
|
||||
}
|
||||
}
|
||||
11
crates/op-web-sdk/src/document_tests.rs
Normal file
11
crates/op-web-sdk/src/document_tests.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Tests for Viewer document parsing and page snapshot accessors.
|
||||
const DOC: &str =
|
||||
r#"{"version":"1.0","pages":[{"id":"p1","name":"Page 1","children":[]}]}"#;
|
||||
|
||||
#[test]
|
||||
fn load_parses_pages() {
|
||||
let mut v = super::Viewer::placeholder();
|
||||
v.load(DOC).expect("parse");
|
||||
assert_eq!(v.page_count(), 1);
|
||||
assert_eq!(v.active_page_index(), 0);
|
||||
}
|
||||
39
crates/op-web-sdk/src/export.rs
Normal file
39
crates/op-web-sdk/src/export.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// SVG (and future format) export for the read-only Viewer.
|
||||
//
|
||||
// Only SVG is available in standalone v1. PNG / PDF require a rendering
|
||||
// daemon; calling `export("png")` returns an explicit error message so
|
||||
// callers get a clear signal rather than a silent panic or opaque failure.
|
||||
|
||||
use crate::Viewer;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
impl Viewer {
|
||||
/// Serialize the active page to an SVG string.
|
||||
///
|
||||
/// Returns `Err` when no document has been loaded, `rebuild_scene` has
|
||||
/// not been called yet, or the scene has no renderable content.
|
||||
pub fn export_svg(&self) -> Result<String, String> {
|
||||
let scene = self
|
||||
.scene
|
||||
.as_ref()
|
||||
.ok_or_else(|| "no scene — call load() then rebuild_scene() first".to_string())?;
|
||||
op_editor_ui::svg_export::serialize_active_page_svg(scene)
|
||||
}
|
||||
}
|
||||
|
||||
/// Export the active page in the requested format.
|
||||
///
|
||||
/// `"svg"` — returns UTF-8 SVG bytes.
|
||||
/// `"png"` / `"pdf"` — returns an explicit error; use a daemon for raster/PDF.
|
||||
#[wasm_bindgen]
|
||||
pub fn export(viewer: &Viewer, format: String) -> Result<Vec<u8>, JsValue> {
|
||||
match format.to_lowercase().as_str() {
|
||||
"svg" => viewer
|
||||
.export_svg()
|
||||
.map(|s| s.into_bytes())
|
||||
.map_err(|e| JsValue::from_str(&e)),
|
||||
_ => Err(JsValue::from_str(
|
||||
"format not available in standalone v1; use SVG or a daemon",
|
||||
)),
|
||||
}
|
||||
}
|
||||
17
crates/op-web-sdk/src/export_tests.rs
Normal file
17
crates/op-web-sdk/src/export_tests.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Tests for Viewer::export_svg and the wasm `export` dispatcher.
|
||||
|
||||
#[test]
|
||||
fn export_svg_emits_svg_root() {
|
||||
let mut v = super::Viewer::placeholder();
|
||||
v.load(r##"{"version":"1.0","pages":[{"id":"p","name":"P","children":[{"type":"rectangle","id":"r","x":0,"y":0,"width":10,"height":10,"fill":[{"type":"solid","color":"#ff0000"}]}]}]}"##).unwrap();
|
||||
v.rebuild_scene();
|
||||
let svg = v.export_svg().expect("svg");
|
||||
assert!(svg.trim_start().starts_with("<svg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_svg_fails_without_scene() {
|
||||
let v = super::Viewer::placeholder();
|
||||
let result = v.export_svg();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
94
crates/op-web-sdk/src/lib.rs
Normal file
94
crates/op-web-sdk/src/lib.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
//! OpenPencil read-only web embedding SDK. Parses a `.op` document and
|
||||
//! renders it to a `<canvas>` via CanvasKit, with pan/zoom navigation,
|
||||
//! read-only snapshots, and SVG export. No editing.
|
||||
mod viewer_host;
|
||||
mod document;
|
||||
mod scene;
|
||||
mod navigation;
|
||||
mod export;
|
||||
mod snapshot;
|
||||
#[cfg(feature = "canvaskit")]
|
||||
mod render;
|
||||
|
||||
use op_editor_core::Viewport as DocViewport;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Read-only viewer handle. Owns the parsed document + paint scene.
|
||||
#[wasm_bindgen]
|
||||
pub struct Viewer {
|
||||
/// Parsed canonical `.op` document; `None` until `load` is called.
|
||||
doc: Option<jian_ops_schema::PenDocument>,
|
||||
/// Index of the currently visible page (0-based).
|
||||
active_page: usize,
|
||||
/// Paint-only layout scene built from the loaded document.
|
||||
/// `None` until a document is loaded and `rebuild_scene` runs.
|
||||
scene: Option<op_editor_ui::layout_scene::LayoutScene>,
|
||||
/// Current pan/zoom state. Defaults to identity (origin pan, 100% zoom).
|
||||
/// Mutated by `set_viewport`, `zoom_to_fit`, and `forward_wheel`.
|
||||
viewport: DocViewport,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl Viewer {
|
||||
/// Construct an empty viewer (no document loaded yet).
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Viewer {
|
||||
doc: None,
|
||||
active_page: 0,
|
||||
scene: None,
|
||||
viewport: DocViewport::IDENTITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a canonical `.op` JSON string and render. Wraps `load` for JS.
|
||||
pub fn load_str(&mut self, src: &str) -> Result<(), JsValue> {
|
||||
self.load(src).map_err(|e| JsValue::from_str(&e))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Viewer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only helpers — not part of the wasm public surface.
|
||||
#[cfg(test)]
|
||||
impl Viewer {
|
||||
/// Construct an empty viewer for in-crate tests. Identical to `new()`;
|
||||
/// kept as a named alias so test call sites don't need `Default::default()`.
|
||||
pub(crate) fn placeholder() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op `mark_dirty` when the canvaskit render path is not compiled in.
|
||||
/// The real implementation lives in `render.rs` (feature = "canvaskit").
|
||||
#[cfg(not(feature = "canvaskit"))]
|
||||
impl Viewer {
|
||||
pub(crate) fn mark_dirty(&self) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod scaffold_tests {
|
||||
#[test]
|
||||
fn viewer_placeholder_constructs() {
|
||||
let _v = super::Viewer::placeholder();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod document_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod scene_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod navigation_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod export_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod snapshot_tests;
|
||||
109
crates/op-web-sdk/src/navigation.rs
Normal file
109
crates/op-web-sdk/src/navigation.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! Pan / zoom navigation for the read-only Viewer.
|
||||
//!
|
||||
//! Pure viewport state management (no browser dependencies) so the math is
|
||||
//! unit-testable without the `canvaskit` feature. The wheel handler that
|
||||
//! integrates with the render state lives in the `canvaskit` feature block at
|
||||
//! the bottom of this file.
|
||||
|
||||
use op_editor_core::Viewport as DocViewport;
|
||||
use op_editor_ui::{Point2D, Rect};
|
||||
|
||||
use crate::Viewer;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core viewport accessors (feature-independent).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl Viewer {
|
||||
/// Set the pan/zoom state and push the update into the live render state.
|
||||
pub fn set_viewport(&mut self, pan_x: f32, pan_y: f32, zoom: f32) {
|
||||
self.viewport = DocViewport { pan_x, pan_y, zoom };
|
||||
// Push to the RAF pump so it repaints with the new viewport.
|
||||
// mark_dirty is redundant here (push_viewport sets the dirty flag),
|
||||
// but kept as a belt-and-suspenders no-op for the non-canvaskit path.
|
||||
self.push_viewport();
|
||||
self.mark_dirty();
|
||||
}
|
||||
|
||||
/// Return the current viewport as `(pan_x, pan_y, zoom)`.
|
||||
pub fn viewport(&self) -> (f32, f32, f32) {
|
||||
(self.viewport.pan_x, self.viewport.pan_y, self.viewport.zoom)
|
||||
}
|
||||
|
||||
/// Fit the active page's content into a `w × h` canvas (in CSS px).
|
||||
///
|
||||
/// Computes the union AABB of all top-level nodes on the active page
|
||||
/// (via `LayoutScene::content_bounds`), then calls `Viewport::fit_to`
|
||||
/// to centre and scale the content within the canvas with a 40 px
|
||||
/// padding margin. Updates `self.viewport` and pushes to the live render
|
||||
/// state so the RAF pump picks up the new pan/zoom on the next frame.
|
||||
/// A no-op when no scene is loaded or the page has no nodes with
|
||||
/// positive size.
|
||||
pub fn zoom_to_fit(&mut self, w: f32, h: f32) {
|
||||
let Some(scene) = self.scene.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(bounds) = scene.content_bounds() else {
|
||||
return;
|
||||
};
|
||||
// Guard: content must have positive area.
|
||||
if bounds.size.x <= 0.0 || bounds.size.y <= 0.0 {
|
||||
return;
|
||||
}
|
||||
self.viewport.fit_to(
|
||||
Rect {
|
||||
origin: Point2D::new(bounds.origin.x, bounds.origin.y),
|
||||
size: Point2D::new(bounds.size.x, bounds.size.y),
|
||||
},
|
||||
w,
|
||||
h,
|
||||
40.0,
|
||||
);
|
||||
// Push to the RAF pump so it repaints with the fitted viewport.
|
||||
self.push_viewport();
|
||||
self.mark_dirty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// No-op push_viewport stub for builds without the canvaskit render path.
|
||||
// Mirrors the mark_dirty no-op pattern in lib.rs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(not(feature = "canvaskit"))]
|
||||
impl Viewer {
|
||||
pub(crate) fn push_viewport(&self) {}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wheel + push_viewport — browser-only (canvaskit feature).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(feature = "canvaskit")]
|
||||
impl Viewer {
|
||||
/// Process a DOM `wheel` event from JS.
|
||||
///
|
||||
/// When `ctrl_or_meta` is true (pinch-to-zoom or Ctrl+wheel) the event
|
||||
/// zooms about `(cursor_x, cursor_y)` in canvas-local px. Otherwise it
|
||||
/// pans by `(dx, dy)` CSS px (typical scroll or two-finger pan).
|
||||
///
|
||||
/// After updating the viewport, the new state is pushed into the live
|
||||
/// render state so the RAF pump picks it up on the next animation frame.
|
||||
pub fn forward_wheel(&mut self, dx: f32, dy: f32, ctrl_or_meta: bool, cursor_x: f32, cursor_y: f32) {
|
||||
if ctrl_or_meta {
|
||||
// Zoom about cursor. Negate dy so scroll-up zooms in.
|
||||
self.viewport.zoom_at(Point2D::new(cursor_x, cursor_y), -dy);
|
||||
} else {
|
||||
// Pan in the scroll direction.
|
||||
self.viewport.pan(-dx, -dy);
|
||||
}
|
||||
self.push_viewport();
|
||||
}
|
||||
|
||||
/// Push the current `self.viewport` into the live `RenderInner` so the
|
||||
/// RAF pump reads the updated value on the next frame.
|
||||
pub(crate) fn push_viewport(&self) {
|
||||
use crate::render::push_viewport_to_render;
|
||||
push_viewport_to_render(self.viewport);
|
||||
}
|
||||
}
|
||||
72
crates/op-web-sdk/src/navigation_tests.rs
Normal file
72
crates/op-web-sdk/src/navigation_tests.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// Navigation tests — pure math, no `canvaskit` feature required.
|
||||
|
||||
#[test]
|
||||
fn zoom_to_fit_sets_nonzero_zoom_for_loaded_doc() {
|
||||
let mut v = super::Viewer::placeholder();
|
||||
v.load(r#"{"version":"1.0","pages":[{"id":"p","name":"P","children":[{"type":"rectangle","id":"r","x":0,"y":0,"width":100,"height":50}]}]}"#).unwrap();
|
||||
v.rebuild_scene();
|
||||
v.zoom_to_fit(800.0, 600.0);
|
||||
let (_, _, zoom) = v.viewport();
|
||||
assert!(zoom > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_viewport_round_trips() {
|
||||
let mut v = super::Viewer::placeholder();
|
||||
v.set_viewport(10.0, -20.0, 2.5);
|
||||
let (px, py, z) = v.viewport();
|
||||
assert!((px - 10.0).abs() < 1e-5);
|
||||
assert!((py + 20.0).abs() < 1e-5);
|
||||
assert!((z - 2.5).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_fit_noop_when_no_scene() {
|
||||
let mut v = super::Viewer::placeholder();
|
||||
// No doc loaded — should not panic and zoom stays at 1.0 (identity).
|
||||
v.zoom_to_fit(800.0, 600.0);
|
||||
let (_, _, zoom) = v.viewport();
|
||||
assert!((zoom - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// Regression test: set_viewport must be visible via viewport() after the call.
|
||||
/// The push_viewport path is canvaskit-gated; without canvaskit the push_viewport
|
||||
/// stub is a no-op, but the Viewer's own viewport field is always updated.
|
||||
#[test]
|
||||
fn set_viewport_reflected_by_accessor() {
|
||||
let mut v = super::Viewer::placeholder();
|
||||
v.set_viewport(50.0, 75.0, 1.5);
|
||||
let (px, py, z) = v.viewport();
|
||||
assert!((px - 50.0).abs() < 1e-5, "pan_x must be reflected");
|
||||
assert!((py - 75.0).abs() < 1e-5, "pan_y must be reflected");
|
||||
assert!((z - 1.5).abs() < 1e-5, "zoom must be reflected");
|
||||
// Note: the push to RenderInner.viewport (so the RAF pump renders the new
|
||||
// viewport) is tested via the canvaskit integration path; here we verify
|
||||
// that the Viewer-side state is updated and the push path does not panic.
|
||||
}
|
||||
|
||||
/// Finding (3): zoom_to_fit must be a no-op (no panic, identity viewport)
|
||||
/// when the loaded document's only node has zero size.
|
||||
#[test]
|
||||
fn zoom_to_fit_noop_for_zero_size_content() {
|
||||
let mut v = super::Viewer::placeholder();
|
||||
// Node with 0×0 size — content_bounds yields zero-area AABB.
|
||||
v.load(r#"{"version":"1.0","pages":[{"id":"p","name":"P","children":[{"type":"rectangle","id":"r","x":0,"y":0,"width":0,"height":0}]}]}"#).unwrap();
|
||||
v.rebuild_scene();
|
||||
let (pan_x_before, pan_y_before, zoom_before) = v.viewport();
|
||||
v.zoom_to_fit(800.0, 600.0);
|
||||
let (pan_x_after, pan_y_after, zoom_after) = v.viewport();
|
||||
// Viewport must be unchanged when content has no positive area.
|
||||
assert!(
|
||||
(pan_x_after - pan_x_before).abs() < 1e-5,
|
||||
"pan_x must not change for zero-size content"
|
||||
);
|
||||
assert!(
|
||||
(pan_y_after - pan_y_before).abs() < 1e-5,
|
||||
"pan_y must not change for zero-size content"
|
||||
);
|
||||
assert!(
|
||||
(zoom_after - zoom_before).abs() < 1e-5,
|
||||
"zoom must not change for zero-size content"
|
||||
);
|
||||
}
|
||||
269
crates/op-web-sdk/src/render.rs
Normal file
269
crates/op-web-sdk/src/render.rs
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
//! CanvasKit render loop for the read-only `Viewer`.
|
||||
//!
|
||||
//! `attach_canvas` initialises a `CanvasKitBackend` from `op-host-web` and
|
||||
//! installs a `requestAnimationFrame` pump that repaints the document scene
|
||||
//! whenever the `dirty` flag is set. The widget facade is entirely delegated
|
||||
//! to `viewer_host::paint_scene` — this file owns only the lifecycle glue.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
use op_editor_core::Viewport as DocViewport;
|
||||
use op_editor_ui::theme::Theme;
|
||||
use op_editor_ui::RenderBackend;
|
||||
use op_host_web::canvaskit::{CanvasKitBackend, init_backend};
|
||||
|
||||
use crate::Viewer;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-instance render state.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Live render state for one mounted Viewer.
|
||||
struct RenderInner {
|
||||
backend: CanvasKitBackend,
|
||||
/// Physical width of the canvas element (CSS pixels).
|
||||
logical_w: f32,
|
||||
/// Physical height of the canvas element (CSS pixels).
|
||||
logical_h: f32,
|
||||
/// Set to `true` after any state change that requires a repaint.
|
||||
dirty: Cell<bool>,
|
||||
/// The most recent layout scene to paint. Updated by `push_scene` so
|
||||
/// `load_str()` + `mark_dirty()` shows the newly loaded document rather
|
||||
/// than the stale snapshot captured at `attach_canvas` time.
|
||||
scene: Option<op_editor_ui::layout_scene::LayoutScene>,
|
||||
/// Current pan/zoom viewport. Updated by `push_viewport_to_render` on
|
||||
/// every `set_viewport` / `forward_wheel` call so the pump always reads
|
||||
/// the live value rather than the snapshot captured at attach time.
|
||||
viewport: DocViewport,
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread-local render slot — one Viewer can be mounted at a time.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
thread_local! {
|
||||
/// The active render state. `None` until `attach_canvas` succeeds.
|
||||
static RENDER: RefCell<Option<Rc<RefCell<RenderInner>>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wasm-bindgen impl on Viewer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl Viewer {
|
||||
/// Attach a `<canvas>` element and start the rAF render loop.
|
||||
///
|
||||
/// Reads the canvas's current pixel dimensions to set the logical viewport.
|
||||
/// Subsequent calls to `mark_dirty()` (or an internal repaint trigger)
|
||||
/// will cause a repaint on the next animation frame.
|
||||
///
|
||||
/// If a previous rAF pump is running it is stopped before the new one
|
||||
/// starts: clearing the `RENDER` thread-local slot causes the old pump to
|
||||
/// self-terminate on its next tick, so only one pump is ever live.
|
||||
pub async fn attach_canvas(&self, canvas_id: String) -> Result<(), JsValue> {
|
||||
// Tear down any existing render state so the previous rAF pump sees an
|
||||
// empty slot on its next tick and terminates, preventing two concurrent
|
||||
// pumps from racing on begin_frame / end_frame.
|
||||
self.detach();
|
||||
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
let window =
|
||||
web_sys::window().ok_or_else(|| JsValue::from_str("attach_canvas: no window"))?;
|
||||
let document = window
|
||||
.document()
|
||||
.ok_or_else(|| JsValue::from_str("attach_canvas: no document"))?;
|
||||
let canvas: web_sys::HtmlCanvasElement = document
|
||||
.get_element_by_id(&canvas_id)
|
||||
.ok_or_else(|| JsValue::from_str("attach_canvas: canvas not found"))?
|
||||
.dyn_into()
|
||||
.map_err(|_| JsValue::from_str("attach_canvas: element is not a <canvas>"))?;
|
||||
|
||||
// Derive logical size from the canvas element's current CSS client area.
|
||||
let css_w = canvas.client_width().max(1) as u32;
|
||||
let css_h = canvas.client_height().max(1) as u32;
|
||||
// Use a device-pixel-ratio of 1.0 when the canvas has no physical size
|
||||
// set yet; the caller can resize the element and call again.
|
||||
let dev_w = canvas.width().max(1) as f32;
|
||||
let css_w_f = css_w as f32;
|
||||
let dpr = (dev_w / css_w_f).max(1.0);
|
||||
|
||||
let backend = init_backend(&canvas_id, dpr, css_w, css_h).await?;
|
||||
|
||||
// Snapshot the current scene and viewport into the shared state so
|
||||
// the pump can paint immediately. Subsequent `load_str` + `push_scene`
|
||||
// and `set_viewport` / `forward_wheel` calls update the fields without
|
||||
// re-attaching the canvas.
|
||||
let initial_scene = self.scene().cloned();
|
||||
let initial_viewport = self.viewport;
|
||||
|
||||
let inner = Rc::new(RefCell::new(RenderInner {
|
||||
backend,
|
||||
logical_w: css_w as f32,
|
||||
logical_h: css_h as f32,
|
||||
dirty: Cell::new(true),
|
||||
scene: initial_scene,
|
||||
viewport: initial_viewport,
|
||||
}));
|
||||
|
||||
// Install the render slot so the rAF closure can reach it.
|
||||
RENDER.with(|slot| {
|
||||
*slot.borrow_mut() = Some(inner.clone());
|
||||
});
|
||||
|
||||
let inner_for_raf = inner.clone();
|
||||
start_raf_pump(inner_for_raf);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Detach the CanvasKit backend, stopping the rAF loop.
|
||||
///
|
||||
/// Safe to call when not attached — it is a no-op in that case.
|
||||
pub fn detach(&self) {
|
||||
RENDER.with(|slot| {
|
||||
slot.borrow_mut().take();
|
||||
});
|
||||
}
|
||||
|
||||
/// Mark the canvas dirty so the rAF pump issues a repaint on the next
|
||||
/// animation frame.
|
||||
///
|
||||
/// After calling `load_str()` call `push_scene()` (which also sets the
|
||||
/// dirty flag) to update both the displayed scene and trigger a repaint.
|
||||
/// `mark_dirty()` alone repaints the currently stored scene without
|
||||
/// replacing it.
|
||||
pub fn mark_dirty(&self) {
|
||||
RENDER.with(|slot| {
|
||||
if let Some(inner_rc) = slot.borrow().as_ref() {
|
||||
if let Ok(inner) = inner_rc.try_borrow() {
|
||||
inner.dirty.set(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Push the viewer's current layout scene into the live render state and
|
||||
/// mark the canvas dirty.
|
||||
///
|
||||
/// Call this after `load_str()` to display the newly loaded document
|
||||
/// without re-attaching the canvas. If no canvas is attached yet, this
|
||||
/// is a no-op; the scene will be picked up automatically by the next
|
||||
/// `attach_canvas` call via the `initial_scene` snapshot.
|
||||
pub fn push_scene(&self) {
|
||||
let scene = self.scene().cloned();
|
||||
RENDER.with(|slot| {
|
||||
if let Some(inner_rc) = slot.borrow().as_ref() {
|
||||
if let Ok(mut inner) = inner_rc.try_borrow_mut() {
|
||||
inner.scene = scene;
|
||||
inner.dirty.set(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rAF pump helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Push an updated viewport into the live render state so the RAF pump reads
|
||||
/// it on the next animation frame. Called from `navigation::push_viewport`.
|
||||
/// No-op if no canvas is attached.
|
||||
pub(crate) fn push_viewport_to_render(vp: DocViewport) {
|
||||
RENDER.with(|slot| {
|
||||
if let Some(inner_rc) = slot.borrow().as_ref() {
|
||||
if let Ok(mut inner) = inner_rc.try_borrow_mut() {
|
||||
inner.viewport = vp;
|
||||
inner.dirty.set(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Schedule a self-rescheduling `requestAnimationFrame` pump.
|
||||
///
|
||||
/// The pump fires once per animation frame, checks the `dirty` flag, paints
|
||||
/// if needed, then reschedules as long as the RENDER thread-local is occupied.
|
||||
/// When `detach` clears the slot the next frame fires, finds the slot empty,
|
||||
/// and drops the closure.
|
||||
///
|
||||
/// The scene is read from `inner.scene` each frame so callers can update it
|
||||
/// via `push_scene` without restarting the pump.
|
||||
fn start_raf_pump(inner: Rc<RefCell<RenderInner>>) {
|
||||
// Wrap the closure in a shared slot so it can re-schedule itself.
|
||||
type FrameSlot = Rc<RefCell<Option<Closure<dyn FnMut()>>>>;
|
||||
let holder: FrameSlot = Rc::new(RefCell::new(None));
|
||||
let holder2 = holder.clone();
|
||||
|
||||
*holder.borrow_mut() = Some(Closure::wrap(Box::new(move || {
|
||||
// Check whether the render slot is still live. If `detach` was
|
||||
// called (or a new `attach_canvas` replaced the slot) the Rc inside
|
||||
// RENDER is a *different* allocation from the one captured in
|
||||
// `inner`, so the pump can no longer reach the active state — stop.
|
||||
let still_live = RENDER.with(|slot| {
|
||||
slot.borrow()
|
||||
.as_ref()
|
||||
.map(|rc| Rc::ptr_eq(rc, &inner))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if !still_live {
|
||||
// Drop the self-reference so the closure can be freed.
|
||||
let _ = holder2.borrow_mut().take();
|
||||
return;
|
||||
}
|
||||
|
||||
// Repaint if dirty.
|
||||
if let Ok(mut b) = inner.try_borrow_mut() {
|
||||
if b.dirty.get() {
|
||||
// Clone the scene out to avoid holding the borrow across the
|
||||
// mutable backend calls (which borrow `b` mutably).
|
||||
if let Some(scene_snap) = b.scene.clone() {
|
||||
// Read the live viewport — updated by push_viewport_to_render
|
||||
// on each set_viewport / forward_wheel call.
|
||||
let vp = b.viewport;
|
||||
let w = b.logical_w;
|
||||
let h = b.logical_h;
|
||||
b.backend.begin_frame();
|
||||
crate::viewer_host::paint_scene(
|
||||
&mut b.backend,
|
||||
&scene_snap,
|
||||
vp,
|
||||
Theme::dark(),
|
||||
w,
|
||||
h,
|
||||
);
|
||||
b.backend.end_frame();
|
||||
b.dirty.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reschedule for the next frame.
|
||||
if let Some(c) = holder2.borrow().as_ref() {
|
||||
request_frame(c);
|
||||
}
|
||||
}) as Box<dyn FnMut()>));
|
||||
|
||||
// Kick off the first frame.
|
||||
{
|
||||
let slot = holder.borrow();
|
||||
if let Some(c) = slot.as_ref() {
|
||||
request_frame(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule `c` to run on the next animation frame. Best-effort: if `window`
|
||||
/// is unavailable (e.g. a WebWorker) the pump simply does not run.
|
||||
fn request_frame(c: &Closure<dyn FnMut()>) {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.request_animation_frame(c.as_ref().unchecked_ref());
|
||||
}
|
||||
}
|
||||
30
crates/op-web-sdk/src/scene.rs
Normal file
30
crates/op-web-sdk/src/scene.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// Paint-only LayoutScene builder for Viewer.
|
||||
// Delegates to op_pen_loader::pen_document_to_layout_scene which runs
|
||||
// jian-core's taffy flex layout pass (using the estimate measure backend
|
||||
// when the skia-measure feature is disabled, which is the case here).
|
||||
use crate::Viewer;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
impl Viewer {
|
||||
/// Rebuild the paint-only scene for the active page from the loaded doc.
|
||||
/// Clears the scene if no document is loaded.
|
||||
pub fn rebuild_scene(&mut self) {
|
||||
let Some(doc) = self.doc.as_ref() else {
|
||||
self.scene = None;
|
||||
return;
|
||||
};
|
||||
// v1: pass an empty active-theme map (default theme axis).
|
||||
let active_theme: BTreeMap<String, String> = BTreeMap::new();
|
||||
self.scene = Some(op_pen_loader::pen_document_to_layout_scene(
|
||||
doc,
|
||||
&active_theme,
|
||||
self.active_page,
|
||||
));
|
||||
}
|
||||
|
||||
/// Return a reference to the cached layout scene, or `None` if no
|
||||
/// document has been loaded or `rebuild_scene` has not been called.
|
||||
pub fn scene(&self) -> Option<&op_editor_ui::layout_scene::LayoutScene> {
|
||||
self.scene.as_ref()
|
||||
}
|
||||
}
|
||||
11
crates/op-web-sdk/src/scene_tests.rs
Normal file
11
crates/op-web-sdk/src/scene_tests.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Tests for Viewer::rebuild_scene / scene().
|
||||
const DOC: &str = r#"{"version":"1.0","pages":[{"id":"p1","name":"P","children":[{"type":"rectangle","id":"r1","x":0,"y":0,"width":10,"height":10}]}]}"#;
|
||||
|
||||
#[test]
|
||||
fn rebuild_scene_yields_one_page() {
|
||||
let mut v = super::Viewer::placeholder();
|
||||
v.load(DOC).unwrap();
|
||||
v.rebuild_scene();
|
||||
let scene = v.scene().expect("scene built");
|
||||
assert_eq!(scene.pages.len(), 1);
|
||||
}
|
||||
125
crates/op-web-sdk/src/snapshot.rs
Normal file
125
crates/op-web-sdk/src/snapshot.rs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// Read-only JSON snapshot getters for the embedded Viewer.
|
||||
//
|
||||
// These methods serialize the current in-memory state to JSON strings so
|
||||
// JavaScript callers can read document structure without touching Rust internals.
|
||||
// All methods are available without the `canvaskit` feature (they use serde_json
|
||||
// directly, with no browser dependencies).
|
||||
//
|
||||
// Error handling: all three getters return `Result<String, JsValue>`. On the
|
||||
// JS side wasm-bindgen converts `Err(JsValue)` into a thrown exception, so
|
||||
// serialisation failures surface as real JS errors rather than fake-JSON
|
||||
// strings that `JSON.parse` would silently swallow.
|
||||
|
||||
use crate::Viewer;
|
||||
use jian_ops_schema::node::PenNode;
|
||||
use op_editor_core::Viewport as DocViewport;
|
||||
use serde::Serialize;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Viewport serialization helper — DocViewport does not derive Serialize so we
|
||||
// project its fields into a small local struct that does.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[allow(dead_code)]
|
||||
struct ViewportSnapshot {
|
||||
pan_x: f32,
|
||||
pan_y: f32,
|
||||
zoom: f32,
|
||||
}
|
||||
|
||||
impl From<DocViewport> for ViewportSnapshot {
|
||||
fn from(v: DocViewport) -> Self {
|
||||
ViewportSnapshot { pan_x: v.pan_x, pan_y: v.pan_y, zoom: v.zoom }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synthetic single-page wrapper for the single-page-fallback path.
|
||||
//
|
||||
// When `PenDocument.pages` is `None` but `children` is non-empty, the nodes
|
||||
// live in `doc.children` (the legacy/single-page-fallback layout). We wrap
|
||||
// them in a synthetic page so JS callers always receive the same
|
||||
// `[{id, name, children}]` shape regardless of which storage format was used.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SyntheticPage<'a> {
|
||||
id: &'a str,
|
||||
name: &'a str,
|
||||
children: &'a [PenNode],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wasm-exposed snapshot getters.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl Viewer {
|
||||
/// Serialize the full loaded `PenDocument` to a JSON string.
|
||||
///
|
||||
/// Returns `Ok("{}")` when no document has been loaded yet so callers always
|
||||
/// receive valid JSON they can safely parse without null-checking.
|
||||
///
|
||||
/// # Errors
|
||||
/// Throws a JS exception if `serde_json` fails to serialize the document.
|
||||
pub fn document_json(&self) -> Result<String, JsValue> {
|
||||
match &self.doc {
|
||||
Some(doc) => serde_json::to_string(doc)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string())),
|
||||
None => Ok("{}".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the pages array of the loaded document to a JSON string.
|
||||
///
|
||||
/// The returned JSON always has the shape `[{id, name, children}, ...]`.
|
||||
///
|
||||
/// - No document loaded → `Ok("[]")`.
|
||||
/// - Document has an explicit `pages` array → serialize that array.
|
||||
/// - Document has `pages: None` but non-empty `children` (single-page
|
||||
/// fallback) → return a synthetic single-element array wrapping those
|
||||
/// children under `{id: "default", name: "Page 1", children: [...]}`.
|
||||
/// - Document has `pages: None` and empty `children` → `Ok("[]")`.
|
||||
///
|
||||
/// # Errors
|
||||
/// Throws a JS exception if `serde_json` fails to serialize the data.
|
||||
pub fn pages_json(&self) -> Result<String, JsValue> {
|
||||
let doc = match &self.doc {
|
||||
Some(d) => d,
|
||||
None => return Ok("[]".to_string()),
|
||||
};
|
||||
|
||||
match &doc.pages {
|
||||
Some(pages) => serde_json::to_string(pages)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string())),
|
||||
None => {
|
||||
if doc.children.is_empty() {
|
||||
Ok("[]".to_string())
|
||||
} else {
|
||||
// Single-page fallback: wrap doc.children in a synthetic page.
|
||||
let synthetic = [SyntheticPage {
|
||||
id: "default",
|
||||
name: "Page 1",
|
||||
children: &doc.children,
|
||||
}];
|
||||
serde_json::to_string(&synthetic)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the current viewport (pan + zoom) to a JSON string.
|
||||
///
|
||||
/// The returned object always contains `pan_x`, `pan_y`, and `zoom` fields.
|
||||
///
|
||||
/// # Errors
|
||||
/// Throws a JS exception if `serde_json` fails to serialize the viewport.
|
||||
pub fn viewport_json(&self) -> Result<String, JsValue> {
|
||||
let snap = ViewportSnapshot::from(self.viewport);
|
||||
serde_json::to_string(&snap)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
}
|
||||
72
crates/op-web-sdk/src/snapshot_tests.rs
Normal file
72
crates/op-web-sdk/src/snapshot_tests.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// Tests for Viewer read-only JSON snapshot getters.
|
||||
|
||||
// A document with an explicit pages array (multi-page format).
|
||||
const DOC_WITH_PAGE: &str =
|
||||
r#"{"version":"1.0","pages":[{"id":"pageX","name":"P","children":[]}]}"#;
|
||||
|
||||
// A document that uses the single-page fallback: no `pages` key, nodes live in
|
||||
// top-level `children`. PenNode is a serde tagged enum so `type` is required.
|
||||
const DOC_SINGLE_PAGE_FALLBACK: &str =
|
||||
r#"{"version":"1.0","children":[{"type":"rectangle","id":"r1","name":"rect"}]}"#;
|
||||
|
||||
#[test]
|
||||
fn document_json_contains_page_id() {
|
||||
let mut v = super::Viewer::placeholder();
|
||||
v.load(DOC_WITH_PAGE).unwrap();
|
||||
assert!(v.document_json().unwrap().contains("pageX"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_json_contains_page_id() {
|
||||
let mut v = super::Viewer::placeholder();
|
||||
v.load(DOC_WITH_PAGE).unwrap();
|
||||
assert!(v.pages_json().unwrap().contains("pageX"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_json_round_trips() {
|
||||
let mut v = super::Viewer::placeholder();
|
||||
v.set_viewport(5.0, -10.0, 2.0);
|
||||
let j = v.viewport_json().unwrap();
|
||||
// Must contain the pan_x field and the exact values we set.
|
||||
assert!(j.contains("pan_x"), "expected pan_x field in: {j}");
|
||||
assert!(j.contains('5'), "expected pan_x value 5 in: {j}");
|
||||
assert!(j.contains('2'), "expected zoom value 2 in: {j}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_json_returns_empty_on_no_doc() {
|
||||
let v = super::Viewer::placeholder();
|
||||
// No document loaded — must return exactly "{}" (the documented default).
|
||||
let j = v.document_json().unwrap();
|
||||
assert_eq!(j, "{}", "document_json must return \"{{}}\" when no doc is loaded, got: {j}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_json_returns_empty_array_on_no_doc() {
|
||||
let v = super::Viewer::placeholder();
|
||||
assert_eq!(v.pages_json().unwrap(), "[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_json_single_page_fallback_exposes_children() {
|
||||
let mut v = super::Viewer::placeholder();
|
||||
v.load(DOC_SINGLE_PAGE_FALLBACK).unwrap();
|
||||
let j = v.pages_json().unwrap();
|
||||
// Must be a non-empty JSON array containing our node.
|
||||
assert!(j.starts_with('[') && !j.starts_with("[]"),
|
||||
"expected non-empty array for single-page fallback, got: {j}");
|
||||
assert!(j.contains("rectangle"), "expected node type value in pages_json result: {j}");
|
||||
// Synthetic page must carry the documented sentinel fields.
|
||||
assert!(j.contains("\"id\":\"default\""), "expected synthetic page id in: {j}");
|
||||
assert!(j.contains("\"name\":\"Page 1\""), "expected synthetic page name in: {j}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_json_no_pages_no_children_returns_empty_array() {
|
||||
// Document with neither pages nor children → "[]"
|
||||
let src = r#"{"version":"1.0","children":[]}"#;
|
||||
let mut v = super::Viewer::placeholder();
|
||||
v.load(src).unwrap();
|
||||
assert_eq!(v.pages_json().unwrap(), "[]");
|
||||
}
|
||||
29
crates/op-web-sdk/src/viewer_host.rs
Normal file
29
crates/op-web-sdk/src/viewer_host.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
//! The only module permitted to call `op_editor_ui::widgets::*` (widget-boundary rule).
|
||||
//!
|
||||
//! Provides the sole paint entry point for the read-only viewer. All widget
|
||||
//! logic is contained here; `render.rs` calls through this module without
|
||||
//! touching `op_editor_ui::widgets` directly.
|
||||
//!
|
||||
//! `paint_scene` is called only from the `canvaskit`-feature render path.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use op_editor_core::Viewport as DocViewport;
|
||||
use op_editor_ui::layout_scene::LayoutScene;
|
||||
use op_editor_ui::theme::Theme;
|
||||
use op_editor_ui::widgets::canvas_viewport::CanvasViewport;
|
||||
use op_editor_ui::widgets::{PaintCx, Widget};
|
||||
use op_editor_ui::{Rect, RenderBackend};
|
||||
|
||||
// glue: read-only viewer paint pass.
|
||||
pub fn paint_scene(
|
||||
backend: &mut dyn RenderBackend,
|
||||
scene: &LayoutScene,
|
||||
viewport: DocViewport,
|
||||
theme: Theme,
|
||||
w: f32,
|
||||
h: f32,
|
||||
) {
|
||||
let view = CanvasViewport::from_scene(scene, viewport, theme);
|
||||
let mut cx = PaintCx { backend };
|
||||
view.paint(&mut cx, Rect::xywh(0.0, 0.0, w, h));
|
||||
}
|
||||
115
crates/op-web-sdk/tools/build-wasm.sh
Normal file
115
crates/op-web-sdk/tools/build-wasm.sh
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env bash
|
||||
# crates/op-web-sdk/tools/build-wasm.sh — cargo + wasm-bindgen + wasm-opt
|
||||
# build + bundle gate for op-web-sdk.
|
||||
#
|
||||
# What this script does:
|
||||
# 1. Verify prerequisites: cargo, wasm-bindgen, wasm-opt, node, gzip.
|
||||
# 2. cargo build -p op-web-sdk --target wasm32-unknown-unknown \
|
||||
# --features canvaskit --release
|
||||
# Note: --no-default-features is NOT needed — op-web-sdk's [features]
|
||||
# default is empty (default = []), so there are no default features to
|
||||
# suppress.
|
||||
# 3. wasm-bindgen --target web → crates/op-web-sdk/pkg/
|
||||
# Output: pkg/op_web_sdk_bg.wasm + pkg/op_web_sdk.js + pkg/*.d.ts.
|
||||
# The artifact is the raw wasm-bindgen pkg/ directory — no separate
|
||||
# CanvasKit sub-asset assembly is needed because op-web-sdk is pure
|
||||
# document logic; CanvasKit itself is loaded externally by the host page.
|
||||
# 4. Assert 0 env.* imports in the raw bindgen wasm (LinkError guard).
|
||||
# 5. wasm-opt -Oz (with the rustc-emitted WebAssembly feature flags) then
|
||||
# gzip size <= OP_WEB_SDK_WASM_GZIP_LIMIT_BYTES (default 6 291 456 = 6 MiB).
|
||||
# The SDK is pure logic with no CanvasKit WASM included — the ceiling
|
||||
# mirrors op-host-web's 6 MiB guard and can be tightened later.
|
||||
#
|
||||
# This approach mirrors tools/check-wasm-bundle.sh (op-host-web gate) exactly:
|
||||
# cargo build → wasm-bindgen → wasm-opt, without wasm-pack. This removes the
|
||||
# wasm-pack version fragility (0.13.1 is broken on stable Rust) and keeps the
|
||||
# two gates consistent.
|
||||
#
|
||||
# This script is the local counterpart to
|
||||
# `.github/workflows/web-sdk-bundle.yml`; keep the two recipes aligned.
|
||||
#
|
||||
# Exit semantics:
|
||||
# 0 all checks PASS.
|
||||
# 1 any check FAILED — message names which one.
|
||||
# 2 prerequisite missing.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve workspace root regardless of where the script is invoked from.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORKSPACE_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
|
||||
|
||||
CRATE_DIR="${WORKSPACE_ROOT}/crates/op-web-sdk"
|
||||
PKG_DIR="${CRATE_DIR}/pkg"
|
||||
# wasm-bindgen names the .wasm after the lib.name in Cargo.toml ("op_web_sdk").
|
||||
WASM_RAW="${PKG_DIR}/op_web_sdk_bg.wasm"
|
||||
WASM_OPT="${PKG_DIR}/op_web_sdk_bg.opt.wasm"
|
||||
# cargo places the raw cdylib here before wasm-bindgen post-processes it.
|
||||
TARGET_WASM="${WORKSPACE_ROOT}/target/wasm32-unknown-unknown/release/op_web_sdk.wasm"
|
||||
|
||||
# rustc-emitted WebAssembly feature flags (matches op-host-web gate).
|
||||
WASM_OPT_FEATURES=(
|
||||
--enable-bulk-memory
|
||||
--enable-bulk-memory-opt
|
||||
--enable-nontrapping-float-to-int
|
||||
)
|
||||
|
||||
# Gzip ceiling — same 6 MiB as op-host-web; the SDK bundle is pure logic.
|
||||
# Override via env when intentionally re-baselining.
|
||||
LIMIT="${OP_WEB_SDK_WASM_GZIP_LIMIT_BYTES:-6291456}"
|
||||
|
||||
step() { printf '\n[step %d/%d] %s\n' "$1" "$2" "$3"; }
|
||||
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
|
||||
need() { command -v "$1" >/dev/null 2>&1 || { printf 'missing prerequisite: %s\n' "$1" >&2; exit 2; }; }
|
||||
|
||||
step 1 5 "Verify prerequisites"
|
||||
need cargo
|
||||
need wasm-bindgen
|
||||
need wasm-opt
|
||||
need node
|
||||
need gzip
|
||||
|
||||
step 2 5 "cargo build -p op-web-sdk --target wasm32-unknown-unknown --features canvaskit --release"
|
||||
# Run from the workspace root so cargo resolves the workspace Cargo.toml.
|
||||
# --no-default-features is omitted intentionally: op-web-sdk has no default
|
||||
# features (default = [] in Cargo.toml), so the flag is a no-op and omitting
|
||||
# it keeps the invocation consistent with what a consumer would normally use.
|
||||
cd "${WORKSPACE_ROOT}"
|
||||
cargo build -p op-web-sdk \
|
||||
--target wasm32-unknown-unknown \
|
||||
--features canvaskit \
|
||||
--release
|
||||
|
||||
step 3 5 "wasm-bindgen --target web → ${PKG_DIR}/"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
wasm-bindgen --target web --out-dir "${PKG_DIR}" "${TARGET_WASM}"
|
||||
|
||||
step 4 5 "Verify 0 env.* imports (LinkError guard)"
|
||||
env_count="$(node -e '
|
||||
const fs = require("fs");
|
||||
const buf = fs.readFileSync(process.argv[1]);
|
||||
WebAssembly.compile(buf).then(mod => {
|
||||
const imps = WebAssembly.Module.imports(mod);
|
||||
const env = imps.filter(i => i.module === "env");
|
||||
console.log(env.length);
|
||||
}).catch(e => { console.error("compile failed:", e); process.exit(1); });
|
||||
' "${WASM_RAW}")"
|
||||
if [ "${env_count}" != "0" ]; then
|
||||
fail "env.* import count = ${env_count} (must be 0); a dep pulled non-wasm-clean code — find and gate it out"
|
||||
fi
|
||||
printf ' ✓ 0 env.* imports\n'
|
||||
|
||||
step 5 5 "wasm-opt -Oz + gzip size <= ${LIMIT} bytes"
|
||||
wasm-opt "${WASM_OPT_FEATURES[@]}" -Oz "${WASM_RAW}" -o "${WASM_OPT}"
|
||||
# Replace the raw wasm with the optimised version in-place so pkg/ is
|
||||
# ready to deploy / upload as-is (mirrors check-wasm-bundle.sh behaviour).
|
||||
cp "${WASM_OPT}" "${WASM_RAW}"
|
||||
gz_bytes="$(gzip -c "${WASM_OPT}" | wc -c | tr -d ' ')"
|
||||
if [ "${gz_bytes}" -gt "${LIMIT}" ]; then
|
||||
fail "op-web-sdk wasm gzip size ${gz_bytes} bytes > ceiling ${LIMIT} bytes"
|
||||
fi
|
||||
pct=$(( (gz_bytes * 100) / LIMIT ))
|
||||
printf ' ✓ gzip size %s bytes (%d%% of %s ceiling)\n' "${gz_bytes}" "${pct}" "${LIMIT}"
|
||||
|
||||
printf '\nAll op-web-sdk bundle gates PASS.\n'
|
||||
printf 'Output: %s/\n' "${PKG_DIR}"
|
||||
45
crates/op-web-sdk/tools/gen-types.sh
Executable file
45
crates/op-web-sdk/tools/gen-types.sh
Executable file
|
|
@ -0,0 +1,45 @@
|
|||
#!/usr/bin/env bash
|
||||
# gen-types.sh — emit TypeScript type definitions for PenDocument and related
|
||||
# types by running jian-ops-schema's built-in export_ts binary.
|
||||
#
|
||||
# Output: crates/op-web-sdk/bindings/ops.ts (inside our crate, NOT the submodule)
|
||||
#
|
||||
# The jian-ops-schema export_ts binary writes into its own crate's bindings/
|
||||
# directory (vendor/jian/crates/jian-ops-schema/bindings/) via a compile-time
|
||||
# CARGO_MANIFEST_DIR path — it cannot be redirected by TS_RS_EXPORT_DIR at
|
||||
# runtime. To keep vendor/jian clean we:
|
||||
# 1. Run the binary (writes into vendor submodule).
|
||||
# 2. Copy the generated file into crates/op-web-sdk/bindings/.
|
||||
# 3. Restore vendor/jian to the pre-run state.
|
||||
# After this script exits, `git -C vendor/jian status --short` must show no
|
||||
# changes.
|
||||
set -euo pipefail
|
||||
|
||||
WORKSPACE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
JIAN_ROOT="${WORKSPACE_ROOT}/vendor/jian"
|
||||
JIAN_BINDINGS="${JIAN_ROOT}/crates/jian-ops-schema/bindings"
|
||||
SDK_BINDINGS="${WORKSPACE_ROOT}/crates/op-web-sdk/bindings"
|
||||
|
||||
echo "Generating TypeScript bindings from jian-ops-schema (workspace: ${JIAN_ROOT})..."
|
||||
# jian is excluded from the main workspace — run cargo inside its own workspace.
|
||||
cargo run --manifest-path "${JIAN_ROOT}/Cargo.toml" \
|
||||
-p jian-ops-schema \
|
||||
--features export-ts \
|
||||
--bin export_ts 2>&1
|
||||
|
||||
echo "Copying generated files to ${SDK_BINDINGS}..."
|
||||
mkdir -p "${SDK_BINDINGS}"
|
||||
# Copy all .ts files emitted by ts-rs into our crate's bindings directory.
|
||||
cp "${JIAN_BINDINGS}"/*.ts "${SDK_BINDINGS}/"
|
||||
|
||||
echo "Restoring vendor/jian to pre-run state to keep the submodule clean..."
|
||||
git -C "${JIAN_ROOT}" checkout -- .
|
||||
|
||||
echo "Done. TypeScript bindings available at ${SDK_BINDINGS}/"
|
||||
echo "Verifying vendor/jian is clean..."
|
||||
if git -C "${JIAN_ROOT}" status --short | grep -q .; then
|
||||
echo "ERROR: vendor/jian still has uncommitted changes after restore!" >&2
|
||||
git -C "${JIAN_ROOT}" status --short >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "vendor/jian is clean — OK."
|
||||
|
|
@ -179,6 +179,23 @@ if [ -n "${core_ref_hits}" ]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# SDK-F4: only viewer_host.rs may import `op_editor_ui::widgets` in
|
||||
# any form inside `crates/op-web-sdk/src/`. Mirrors the op-host-web
|
||||
# F4 rule with the allowed module changed to `viewer_host`.
|
||||
# ---------------------------------------------------------------------
|
||||
SDK_SRC="crates/op-web-sdk/src"
|
||||
sdk_ref_hits="$(grep -RIn 'op_editor_ui' "${SDK_SRC}" 2>/dev/null || true)"
|
||||
if [ -n "${sdk_ref_hits}" ]; then
|
||||
sdk_illegal_imports="$(printf '%s\n' "${sdk_ref_hits}" \
|
||||
| grep 'widgets' \
|
||||
| grep -vE '^crates/op-web-sdk/src/viewer_host(\.rs|/[^/]+\.rs):' \
|
||||
|| true)"
|
||||
if [ -n "${sdk_illegal_imports}" ]; then
|
||||
fail_lines+=("SDK-F4: op_editor_ui::widgets reference outside viewer_host.rs in op-web-sdk:" "${sdk_illegal_imports}")
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Reverse R1: each expected widget impl file exists AND
|
||||
# carries a real `impl Widget for X` line that is NOT a Rust
|
||||
|
|
|
|||
Loading…
Reference in a new issue