diff --git a/.github/workflows/web-sdk-bundle.yml b/.github/workflows/web-sdk-bundle.yml new file mode 100644 index 000000000..3badc0c06 --- /dev/null +++ b/.github/workflows/web-sdk-bundle.yml @@ -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 +# 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 diff --git a/Cargo.lock b/Cargo.lock index fbac6b4c1..bd8c567ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport.rs b/crates/op-editor-ui/src/widgets/canvas_viewport.rs index 3cce71124..11739cf19 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport.rs @@ -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; diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_from_scene_tests.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_from_scene_tests.rs new file mode 100644 index 000000000..09bcf9e51 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_from_scene_tests.rs @@ -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() + ); +} diff --git a/crates/op-host-web/src/canvaskit.rs b/crates/op-host-web/src/canvaskit.rs index cef3b4c5e..b660ed5ec 100644 --- a/crates/op-host-web/src/canvaskit.rs +++ b/crates/op-host-web/src/canvaskit.rs @@ -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, diff --git a/crates/op-host-web/src/lib.rs b/crates/op-host-web/src/lib.rs index 7c0519872..b0b893a23 100644 --- a/crates/op-host-web/src/lib.rs +++ b/crates/op-host-web/src/lib.rs @@ -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) diff --git a/crates/op-web-sdk/.gitignore b/crates/op-web-sdk/.gitignore new file mode 100644 index 000000000..86effa9b1 --- /dev/null +++ b/crates/op-web-sdk/.gitignore @@ -0,0 +1,2 @@ +/pkg/ +/bindings/ diff --git a/crates/op-web-sdk/Cargo.toml b/crates/op-web-sdk/Cargo.toml new file mode 100644 index 000000000..6420a5515 --- /dev/null +++ b/crates/op-web-sdk/Cargo.toml @@ -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", +] diff --git a/crates/op-web-sdk/smoke/index.html b/crates/op-web-sdk/smoke/index.html new file mode 100644 index 000000000..537d37a7c --- /dev/null +++ b/crates/op-web-sdk/smoke/index.html @@ -0,0 +1,77 @@ + + + + + op-web-sdk smoke test + + + +

OpenPencil Web SDK — smoke

+ + +
Loading WASM…
+ + + + + + + diff --git a/crates/op-web-sdk/src/document.rs b/crates/op-web-sdk/src/document.rs new file mode 100644 index 000000000..ab2e42284 --- /dev/null +++ b/crates/op-web-sdk/src/document.rs @@ -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 + } +} diff --git a/crates/op-web-sdk/src/document_tests.rs b/crates/op-web-sdk/src/document_tests.rs new file mode 100644 index 000000000..bc50487e0 --- /dev/null +++ b/crates/op-web-sdk/src/document_tests.rs @@ -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); +} diff --git a/crates/op-web-sdk/src/export.rs b/crates/op-web-sdk/src/export.rs new file mode 100644 index 000000000..83153de69 --- /dev/null +++ b/crates/op-web-sdk/src/export.rs @@ -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 { + 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, 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", + )), + } +} diff --git a/crates/op-web-sdk/src/export_tests.rs b/crates/op-web-sdk/src/export_tests.rs new file mode 100644 index 000000000..287204921 --- /dev/null +++ b/crates/op-web-sdk/src/export_tests.rs @@ -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("` 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, + /// 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, + /// 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; diff --git a/crates/op-web-sdk/src/navigation.rs b/crates/op-web-sdk/src/navigation.rs new file mode 100644 index 000000000..7d0dde9e1 --- /dev/null +++ b/crates/op-web-sdk/src/navigation.rs @@ -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); + } +} diff --git a/crates/op-web-sdk/src/navigation_tests.rs b/crates/op-web-sdk/src/navigation_tests.rs new file mode 100644 index 000000000..340e8108f --- /dev/null +++ b/crates/op-web-sdk/src/navigation_tests.rs @@ -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" + ); +} diff --git a/crates/op-web-sdk/src/render.rs b/crates/op-web-sdk/src/render.rs new file mode 100644 index 000000000..fe1fc89d0 --- /dev/null +++ b/crates/op-web-sdk/src/render.rs @@ -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, + /// 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, + /// 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>>> = const { RefCell::new(None) }; +} + +// --------------------------------------------------------------------------- +// wasm-bindgen impl on Viewer +// --------------------------------------------------------------------------- + +#[wasm_bindgen] +impl Viewer { + /// Attach a `` 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 "))?; + + // 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>) { + // Wrap the closure in a shared slot so it can re-schedule itself. + type FrameSlot = Rc>>>; + 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)); + + // 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) { + if let Some(window) = web_sys::window() { + let _ = window.request_animation_frame(c.as_ref().unchecked_ref()); + } +} diff --git a/crates/op-web-sdk/src/scene.rs b/crates/op-web-sdk/src/scene.rs new file mode 100644 index 000000000..33a77a7e2 --- /dev/null +++ b/crates/op-web-sdk/src/scene.rs @@ -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 = 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() + } +} diff --git a/crates/op-web-sdk/src/scene_tests.rs b/crates/op-web-sdk/src/scene_tests.rs new file mode 100644 index 000000000..629ea90ac --- /dev/null +++ b/crates/op-web-sdk/src/scene_tests.rs @@ -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); +} diff --git a/crates/op-web-sdk/src/snapshot.rs b/crates/op-web-sdk/src/snapshot.rs new file mode 100644 index 000000000..f81960e0c --- /dev/null +++ b/crates/op-web-sdk/src/snapshot.rs @@ -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`. 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 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 { + 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 { + 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 { + let snap = ViewportSnapshot::from(self.viewport); + serde_json::to_string(&snap) + .map_err(|e| JsValue::from_str(&e.to_string())) + } +} diff --git a/crates/op-web-sdk/src/snapshot_tests.rs b/crates/op-web-sdk/src/snapshot_tests.rs new file mode 100644 index 000000000..4352e8f34 --- /dev/null +++ b/crates/op-web-sdk/src/snapshot_tests.rs @@ -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(), "[]"); +} diff --git a/crates/op-web-sdk/src/viewer_host.rs b/crates/op-web-sdk/src/viewer_host.rs new file mode 100644 index 000000000..aee308d45 --- /dev/null +++ b/crates/op-web-sdk/src/viewer_host.rs @@ -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)); +} diff --git a/crates/op-web-sdk/tools/build-wasm.sh b/crates/op-web-sdk/tools/build-wasm.sh new file mode 100644 index 000000000..7c5d22a47 --- /dev/null +++ b/crates/op-web-sdk/tools/build-wasm.sh @@ -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}" diff --git a/crates/op-web-sdk/tools/gen-types.sh b/crates/op-web-sdk/tools/gen-types.sh new file mode 100755 index 000000000..b887ab3bd --- /dev/null +++ b/crates/op-web-sdk/tools/gen-types.sh @@ -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." diff --git a/tools/check-widget-boundary.sh b/tools/check-widget-boundary.sh index aceb5e7cd..dba272518 100755 --- a/tools/check-widget-boundary.sh +++ b/tools/check-widget-boundary.sh @@ -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