From 199d9bbec9b549e61eee7eb556929d1233228887 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Fri, 19 Jun 2026 21:01:27 +0800 Subject: [PATCH] feat(sdk): add React + Vue adapters for op-web-sdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 3 of the web embedding SDK: @zseven-w/op-web-sdk-react and -vue — thin read-only framework adapters over the TS core. Each: (canvas + viewer lifecycle), provider/inject of OpViewer, and read-only useDocument/useViewport/useActivePage. React via useSyncExternalStore (cache-invalidate-on-event); Vue via an async shallowRef provider so children inject the viewer after load. Self-contained (no pen-* deps; framework peer); React 5/5 + Vue 4/4 tests; both tsc clean. --- packages/op-web-sdk-react/.gitignore | 1 + packages/op-web-sdk-react/README.md | 153 ++++++++++++++++++ packages/op-web-sdk-react/package.json | 16 ++ packages/op-web-sdk-react/src/context.ts | 5 + packages/op-web-sdk-react/src/design-view.tsx | 62 +++++++ packages/op-web-sdk-react/src/hooks.ts | 58 +++++++ packages/op-web-sdk-react/src/index.ts | 4 + packages/op-web-sdk-react/src/use-viewer.ts | 16 ++ .../test/design-view.test.tsx | 21 +++ packages/op-web-sdk-react/test/hooks.test.tsx | 19 +++ packages/op-web-sdk-react/test/mock-core.ts | 24 +++ .../op-web-sdk-react/test/scaffold.test.tsx | 5 + .../op-web-sdk-react/test/use-viewer.test.tsx | 19 +++ packages/op-web-sdk-react/tsconfig.json | 12 ++ packages/op-web-sdk-react/tsup.config.ts | 2 + packages/op-web-sdk-react/vitest.config.ts | 11 ++ packages/op-web-sdk-vue/.gitignore | 1 + packages/op-web-sdk-vue/README.md | 139 ++++++++++++++++ packages/op-web-sdk-vue/package.json | 16 ++ packages/op-web-sdk-vue/src/composables.ts | 62 +++++++ packages/op-web-sdk-vue/src/design-view.ts | 54 +++++++ packages/op-web-sdk-vue/src/index.ts | 5 + packages/op-web-sdk-vue/src/injection.ts | 3 + packages/op-web-sdk-vue/src/use-viewer.ts | 27 ++++ .../op-web-sdk-vue/test/composables.test.ts | 19 +++ .../op-web-sdk-vue/test/design-view.test.ts | 49 ++++++ packages/op-web-sdk-vue/test/mock-core.ts | 24 +++ .../op-web-sdk-vue/test/use-viewer.test.ts | 17 ++ packages/op-web-sdk-vue/tsconfig.json | 12 ++ packages/op-web-sdk-vue/tsup.config.ts | 2 + packages/op-web-sdk-vue/vitest.config.ts | 13 ++ 31 files changed, 871 insertions(+) create mode 100644 packages/op-web-sdk-react/.gitignore create mode 100644 packages/op-web-sdk-react/README.md create mode 100644 packages/op-web-sdk-react/package.json create mode 100644 packages/op-web-sdk-react/src/context.ts create mode 100644 packages/op-web-sdk-react/src/design-view.tsx create mode 100644 packages/op-web-sdk-react/src/hooks.ts create mode 100644 packages/op-web-sdk-react/src/index.ts create mode 100644 packages/op-web-sdk-react/src/use-viewer.ts create mode 100644 packages/op-web-sdk-react/test/design-view.test.tsx create mode 100644 packages/op-web-sdk-react/test/hooks.test.tsx create mode 100644 packages/op-web-sdk-react/test/mock-core.ts create mode 100644 packages/op-web-sdk-react/test/scaffold.test.tsx create mode 100644 packages/op-web-sdk-react/test/use-viewer.test.tsx create mode 100644 packages/op-web-sdk-react/tsconfig.json create mode 100644 packages/op-web-sdk-react/tsup.config.ts create mode 100644 packages/op-web-sdk-react/vitest.config.ts create mode 100644 packages/op-web-sdk-vue/.gitignore create mode 100644 packages/op-web-sdk-vue/README.md create mode 100644 packages/op-web-sdk-vue/package.json create mode 100644 packages/op-web-sdk-vue/src/composables.ts create mode 100644 packages/op-web-sdk-vue/src/design-view.ts create mode 100644 packages/op-web-sdk-vue/src/index.ts create mode 100644 packages/op-web-sdk-vue/src/injection.ts create mode 100644 packages/op-web-sdk-vue/src/use-viewer.ts create mode 100644 packages/op-web-sdk-vue/test/composables.test.ts create mode 100644 packages/op-web-sdk-vue/test/design-view.test.ts create mode 100644 packages/op-web-sdk-vue/test/mock-core.ts create mode 100644 packages/op-web-sdk-vue/test/use-viewer.test.ts create mode 100644 packages/op-web-sdk-vue/tsconfig.json create mode 100644 packages/op-web-sdk-vue/tsup.config.ts create mode 100644 packages/op-web-sdk-vue/vitest.config.ts diff --git a/packages/op-web-sdk-react/.gitignore b/packages/op-web-sdk-react/.gitignore new file mode 100644 index 000000000..178135c2b --- /dev/null +++ b/packages/op-web-sdk-react/.gitignore @@ -0,0 +1 @@ +/dist/ diff --git a/packages/op-web-sdk-react/README.md b/packages/op-web-sdk-react/README.md new file mode 100644 index 000000000..71a537935 --- /dev/null +++ b/packages/op-web-sdk-react/README.md @@ -0,0 +1,153 @@ +# @zseven-w/op-web-sdk-react + +React adapter for the OpenPencil read-only web viewer SDK. Embed a live OpenPencil design canvas in any React 19 application. + +> **Read-only boundary:** This package provides a viewer only — no editing. For the full editor experience, use the OpenPencil desktop or web app directly. + +## Install + +```bash +npm install @zseven-w/op-web-sdk @zseven-w/op-web-sdk-react +# or +bun add @zseven-w/op-web-sdk @zseven-w/op-web-sdk-react +``` + +## Peer dependencies + +| Package | Required version | +|--------------|-----------------| +| `react` | `^19.0.0` | +| `react-dom` | `^19.0.0` | + +## WASM note + +The core SDK (`@zseven-w/op-web-sdk`) loads a WebAssembly binary at runtime. You must either: +- Pass the wasm URL explicitly via the `wasmUrl` prop, or +- Serve the wasm file from your own static assets and ensure it is reachable. + +The wasm file ships alongside the core package at `@zseven-w/op-web-sdk/dist/op_web_sdk_bg.wasm`. + +## Usage + +### `` component + +The simplest way to embed a design viewer: + +```tsx +import { DesignView } from '@zseven-w/op-web-sdk-react'; +import type { OpViewer } from '@zseven-w/op-web-sdk'; + +export function MyPage() { + function handleLoad(viewer: OpViewer) { + console.log('viewer ready', viewer.document); + } + + return ( + + ); +} +``` + +**Props (`DesignViewProps`):** +- `doc?: string | Uint8Array` — serialized OpenPencil document (JSON string or binary bytes). Omit to start with an empty document. +- `wasmUrl?: string` — URL to the core WASM binary. +- `onLoad?: (viewer: OpViewer) => void` — called once the viewer is ready. +- `className?: string` — CSS class applied to the wrapper div. +- `children?: ReactNode` — child components that can call viewer hooks (rendered only after the viewer is ready, inside `DesignProvider`). + +### Manual provider + +For advanced layouts, wrap your subtree with `DesignProvider` and use `useViewer` in descendants: + +```tsx +import { useState, useEffect, useRef } from 'react'; +import { createViewer } from '@zseven-w/op-web-sdk'; +import { DesignProvider } from '@zseven-w/op-web-sdk-react'; + +export function MyLayout() { + const canvasRef = useRef(null); + const [viewer, setViewer] = useState(null); + + useEffect(() => { + let cancelled = false; + createViewer({ canvas: canvasRef.current! }).then((v) => { + if (cancelled) { v.destroy(); return; } + setViewer(v); + }); + return () => { cancelled = true; viewer?.destroy(); }; + }, []); + + return ( + <> + + {viewer && ( + + + + )} + + ); +} +``` + +## Hooks + +These hooks must be called inside a component that is a descendant of `DesignProvider` (or ``). + +| Hook | Returns | Reactive event | +|------|---------|----------------| +| `useViewer()` | `OpViewer` | — (stable reference) | +| `useDocument()` | `PenDocument` | `'load'` | +| `useViewport()` | `{ panX: number; panY: number; zoom: number }` | `'viewportchange'` | +| `useActivePage()` | `number` | `'load'` | + +Each hook uses `useSyncExternalStore` internally and re-renders only when the relevant viewer event fires. + +```tsx +import { useViewport, useDocument } from '@zseven-w/op-web-sdk-react'; + +export function ZoomDisplay() { + const viewport = useViewport(); // { panX, panY, zoom } + const doc = useDocument(); // PenDocument + + return ( +
+

Zoom: {viewport.zoom}

+

Pages: {doc.pages?.length}

+
+ ); +} +``` + +## API reference + +```ts +// Provider / context +function DesignProvider(props: { viewer: OpViewer; children?: ReactNode }): JSX.Element +function useViewer(): OpViewer // throws if no provider above + +// Hooks (re-render on event) +function useDocument(): PenDocument +function useViewport(): { panX: number; panY: number; zoom: number } +function useActivePage(): number + +// Component +function DesignView(props: DesignViewProps): JSX.Element + +interface DesignViewProps { + doc?: string | Uint8Array; + wasmUrl?: string; + onLoad?: (viewer: OpViewer) => void; + className?: string; + children?: ReactNode; +} +``` + +## License + +MIT diff --git a/packages/op-web-sdk-react/package.json b/packages/op-web-sdk-react/package.json new file mode 100644 index 000000000..33f4a38c2 --- /dev/null +++ b/packages/op-web-sdk-react/package.json @@ -0,0 +1,16 @@ +{ + "name": "@zseven-w/op-web-sdk-react", + "version": "0.8.0", + "description": "React adapter for the OpenPencil read-only web viewer SDK", + "license": "MIT", + "type": "module", + "files": ["dist"], + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" } }, + "scripts": { "build": "tsup", "typecheck": "tsc --noEmit", "test": "vitest run" }, + "dependencies": { "@zseven-w/op-web-sdk": "workspace:*" }, + "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, + "devDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", "@testing-library/react": "^16.0.0", "tsup": "^8.0.0", "typescript": "^5.7.2", "vitest": "^3.0.0", "jsdom": "^25.0.0" } +} diff --git a/packages/op-web-sdk-react/src/context.ts b/packages/op-web-sdk-react/src/context.ts new file mode 100644 index 000000000..765c18d03 --- /dev/null +++ b/packages/op-web-sdk-react/src/context.ts @@ -0,0 +1,5 @@ +import { createContext } from 'react'; +import type { OpViewer } from '@zseven-w/op-web-sdk'; + +// React context holding the current OpViewer instance; null when no provider is mounted. +export const ViewerContext = createContext(null); diff --git a/packages/op-web-sdk-react/src/design-view.tsx b/packages/op-web-sdk-react/src/design-view.tsx new file mode 100644 index 000000000..d74a5b03c --- /dev/null +++ b/packages/op-web-sdk-react/src/design-view.tsx @@ -0,0 +1,62 @@ +import { createElement, useEffect, useRef, useState, type ReactNode } from 'react'; +import { createViewer, type OpViewer } from '@zseven-w/op-web-sdk'; +import { DesignProvider } from './use-viewer.js'; + +export interface DesignViewProps { + /** Serialized document JSON string or raw bytes to load on mount. */ + doc?: string | Uint8Array; + /** URL to the WASM bundle; omit to use the bundled default. */ + wasmUrl?: string; + /** Called once after the viewer is created and ready. */ + onLoad?: (viewer: OpViewer) => void; + /** Optional CSS class applied to the wrapper div. */ + className?: string; + children?: ReactNode; +} + +/** + * Renders a canvas, creates an OpViewer on mount, and exposes it to + * descendant hooks via DesignProvider. Destroys the viewer on unmount. + * Handles the async race: if unmount occurs before createViewer resolves, + * the late-arriving viewer is destroyed immediately. + */ +export function DesignView(props: DesignViewProps) { + const canvasRef = useRef(null); + const [viewer, setViewer] = useState(null); + + useEffect(() => { + let cancelled = false; + // Track the viewer that was synchronously assigned so the cleanup + // path can destroy it even if setViewer batching hasn't flushed. + let made: OpViewer | null = null; + const canvas = canvasRef.current; + if (!canvas) return; + + createViewer({ canvas, doc: props.doc, wasmUrl: props.wasmUrl }).then((v) => { + if (cancelled) { + // Component unmounted before the promise resolved — destroy immediately. + v.destroy(); + return; + } + made = v; + setViewer(v); + props.onLoad?.(v); + }); + + return () => { + cancelled = true; + // Destroy synchronously if the viewer was already assigned. + made?.destroy(); + }; + // Re-create when doc identity or wasmUrl changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.doc, props.wasmUrl]); + + return createElement( + 'div', + { className: props.className }, + createElement('canvas', { ref: canvasRef, style: { width: '100%', height: '100%', display: 'block' } }), + // Only render children once the viewer is ready. + viewer ? createElement(DesignProvider, { viewer, children: props.children }) : null, + ); +} diff --git a/packages/op-web-sdk-react/src/hooks.ts b/packages/op-web-sdk-react/src/hooks.ts new file mode 100644 index 000000000..afacfa574 --- /dev/null +++ b/packages/op-web-sdk-react/src/hooks.ts @@ -0,0 +1,58 @@ +import { useCallback, useRef, useSyncExternalStore } from 'react'; +import type { OpViewer } from '@zseven-w/op-web-sdk'; +import { useViewer } from './use-viewer.js'; + +type ViewerEvent = 'load' | 'viewportchange'; + +// Subscribe to `event` and return a referentially stable snapshot between events. +// +// CRITICAL — render-loop prevention: the core's getters (e.g. `viewport`) return +// a fresh object on every call. If we passed them directly to useSyncExternalStore, +// React would see a new reference on every render, trigger a re-render, call the +// getter again, get yet another new reference, and loop infinitely. +// +// Solution: cache the last snapshot in a ref. The subscribe callback clears the +// cache when the event fires so the *next* getSnapshot call fetches a fresh value. +// Between events the same cached object is returned, satisfying reference stability. +function useViewerSnapshot(event: ViewerEvent, read: (v: OpViewer) => T): T { + const viewer = useViewer(); + // Null signals "cache is dirty, read fresh on next getSnapshot call". + const cache = useRef<{ value: T } | null>(null); + + const subscribe = useCallback( + (cb: () => void) => + viewer.on(event, () => { + // Invalidate the cache so getSnapshot returns the new value after this event. + cache.current = null; + cb(); + }), + [viewer, event], + ); + + const getSnapshot = useCallback(() => { + if (cache.current === null) { + cache.current = { value: read(viewer) }; + } + return cache.current.value; + }, [viewer, read]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +// Returns the current PenDocument; re-renders when 'load' fires. +export function useDocument() { + // eslint-disable-next-line react-hooks/exhaustive-deps + return useViewerSnapshot('load', useCallback((v: OpViewer) => v.document, [])); +} + +// Returns the current viewport {panX, panY, zoom}; re-renders when 'viewportchange' fires. +export function useViewport() { + // eslint-disable-next-line react-hooks/exhaustive-deps + return useViewerSnapshot('viewportchange', useCallback((v: OpViewer) => v.viewport, [])); +} + +// Returns the active page index; re-renders when 'load' fires. +export function useActivePage(): number { + // eslint-disable-next-line react-hooks/exhaustive-deps + return useViewerSnapshot('load', useCallback((v: OpViewer) => v.activePage, [])); +} diff --git a/packages/op-web-sdk-react/src/index.ts b/packages/op-web-sdk-react/src/index.ts new file mode 100644 index 000000000..40fe92500 --- /dev/null +++ b/packages/op-web-sdk-react/src/index.ts @@ -0,0 +1,4 @@ +export const VERSION = '0.8.0'; +export { DesignProvider, useViewer } from './use-viewer.js'; +export { useDocument, useViewport, useActivePage } from './hooks.js'; +export { DesignView, type DesignViewProps } from './design-view.js'; diff --git a/packages/op-web-sdk-react/src/use-viewer.ts b/packages/op-web-sdk-react/src/use-viewer.ts new file mode 100644 index 000000000..094751e2a --- /dev/null +++ b/packages/op-web-sdk-react/src/use-viewer.ts @@ -0,0 +1,16 @@ +import { createElement, useContext, type ReactNode } from 'react'; +import type { OpViewer } from '@zseven-w/op-web-sdk'; +import { ViewerContext } from './context.js'; + +// Provider component that injects an OpViewer into React context. +export function DesignProvider(props: { viewer: OpViewer; children: ReactNode }) { + return createElement(ViewerContext.Provider, { value: props.viewer }, props.children); +} + +// Returns the OpViewer from the nearest DesignProvider/DesignView ancestor. +// Throws if called outside a provider tree. +export function useViewer(): OpViewer { + const v = useContext(ViewerContext); + if (!v) throw new Error('op-web-sdk-react: useViewer must be used inside /'); + return v; +} diff --git a/packages/op-web-sdk-react/test/design-view.test.tsx b/packages/op-web-sdk-react/test/design-view.test.tsx new file mode 100644 index 000000000..46d8a698a --- /dev/null +++ b/packages/op-web-sdk-react/test/design-view.test.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { makeMockViewer } from './mock-core.js'; + +const mock = makeMockViewer(); +vi.mock('@zseven-w/op-web-sdk', () => ({ createViewer: vi.fn(async () => mock.viewer) })); +import { DesignView } from '../src/index.js'; +import { createViewer } from '@zseven-w/op-web-sdk'; + +describe('DesignView', () => { + beforeEach(() => vi.clearAllMocks()); + it('creates a viewer on mount and destroys on unmount', async () => { + const onLoad = vi.fn(); + const { unmount, container } = render(); + expect(container.querySelector('canvas')).toBeTruthy(); + await waitFor(() => expect(createViewer).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(onLoad).toHaveBeenCalledWith(mock.viewer)); + unmount(); + expect(mock.viewer.destroy).toHaveBeenCalled(); + }); +}); diff --git a/packages/op-web-sdk-react/test/hooks.test.tsx b/packages/op-web-sdk-react/test/hooks.test.tsx new file mode 100644 index 000000000..02bd2c002 --- /dev/null +++ b/packages/op-web-sdk-react/test/hooks.test.tsx @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { act } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; +import { makeMockViewer } from './mock-core.js'; +import { DesignProvider, useViewport } from '../src/index.js'; +import type { ReactNode } from 'react'; + +describe('useViewport', () => { + it('re-renders on viewportchange', () => { + const { viewer, emit } = makeMockViewer(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useViewport(), { wrapper }); + expect(result.current.zoom).toBe(1); + act(() => { viewer.setZoom(3); emit('viewportchange'); }); + expect(result.current.zoom).toBe(3); + }); +}); diff --git a/packages/op-web-sdk-react/test/mock-core.ts b/packages/op-web-sdk-react/test/mock-core.ts new file mode 100644 index 000000000..7464b37ef --- /dev/null +++ b/packages/op-web-sdk-react/test/mock-core.ts @@ -0,0 +1,24 @@ +import { vi } from 'vitest'; +// A controllable fake OpViewer for adapter tests. `emit(event)` fires listeners. +export function makeMockViewer() { + const listeners: Record void>> = { load: new Set(), viewportchange: new Set() }; + let vp = { panX: 0, panY: 0, zoom: 1 }; + const viewer = { + load: vi.fn(), + get document() { return { id: 'doc', pages: [] }; }, + get pages() { return [{ id: 'p', name: 'P', children: [] }]; }, + get pageCount() { return 1; }, + get activePage() { return 0; }, + get viewport() { return vp; }, + setViewport: vi.fn((v: { panX: number; panY: number; zoom: number }) => { vp = v; }), + setZoom: vi.fn((z: number) => { vp = { ...vp, zoom: z }; }), + panTo: vi.fn(), zoomToFit: vi.fn(), + export: vi.fn(() => new Uint8Array([60, 115, 118, 103, 47, 62])), + on: vi.fn((e: string, cb: () => void) => { listeners[e].add(cb); return () => listeners[e].delete(cb); }), + off: vi.fn((e: string, cb: () => void) => { listeners[e].delete(cb); }), + destroy: vi.fn(), + }; + const emit = (e: 'load' | 'viewportchange') => listeners[e].forEach((cb) => cb()); + return { viewer, emit }; +} +// Shared vi.mock factory: tests call vi.mock('@zseven-w/op-web-sdk', () => coreMock(current)) diff --git a/packages/op-web-sdk-react/test/scaffold.test.tsx b/packages/op-web-sdk-react/test/scaffold.test.tsx new file mode 100644 index 000000000..862496ff4 --- /dev/null +++ b/packages/op-web-sdk-react/test/scaffold.test.tsx @@ -0,0 +1,5 @@ +import { describe, it, expect } from 'vitest'; +import { VERSION } from '../src/index.js'; +describe('react adapter scaffold', () => { + it('exposes VERSION', () => { expect(typeof VERSION).toBe('string'); }); +}); diff --git a/packages/op-web-sdk-react/test/use-viewer.test.tsx b/packages/op-web-sdk-react/test/use-viewer.test.tsx new file mode 100644 index 000000000..cd862216b --- /dev/null +++ b/packages/op-web-sdk-react/test/use-viewer.test.tsx @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { makeMockViewer } from './mock-core.js'; +import { DesignProvider, useViewer } from '../src/index.js'; +import type { ReactNode } from 'react'; + +describe('useViewer', () => { + it('returns the provided viewer', () => { + const { viewer } = makeMockViewer(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useViewer(), { wrapper }); + expect(result.current).toBe(viewer); + }); + it('throws without a provider', () => { + expect(() => renderHook(() => useViewer())).toThrow(); + }); +}); diff --git a/packages/op-web-sdk-react/tsconfig.json b/packages/op-web-sdk-react/tsconfig.json new file mode 100644 index 000000000..005a5250f --- /dev/null +++ b/packages/op-web-sdk-react/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", + "strict": true, "jsx": "react-jsx", "skipLibCheck": true, "noEmit": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vitest/globals"], + "paths": { + "@zseven-w/op-web-sdk": ["../op-web-sdk/src/index.ts"], + "virtual:op_web_sdk_wasm": ["../op-web-sdk/test/wasm-stub.ts"] + } + }, + "include": ["src", "test"] +} diff --git a/packages/op-web-sdk-react/tsup.config.ts b/packages/op-web-sdk-react/tsup.config.ts new file mode 100644 index 000000000..a29808529 --- /dev/null +++ b/packages/op-web-sdk-react/tsup.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'tsup'; +export default defineConfig({ entry: ['src/index.ts'], format: ['esm', 'cjs'], dts: true, clean: true, sourcemap: true, external: ['react', 'react-dom', '@zseven-w/op-web-sdk'] }); diff --git a/packages/op-web-sdk-react/vitest.config.ts b/packages/op-web-sdk-react/vitest.config.ts new file mode 100644 index 000000000..b7ad534fe --- /dev/null +++ b/packages/op-web-sdk-react/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +const here = fileURLToPath(new URL('.', import.meta.url)); +export default defineConfig({ + test: { environment: 'jsdom', globals: true }, + resolve: { alias: { + '@zseven-w/op-web-sdk': resolve(here, '../op-web-sdk/src/index.ts'), + 'virtual:op_web_sdk_wasm': resolve(here, '../op-web-sdk/test/wasm-stub.ts'), + } }, +}); diff --git a/packages/op-web-sdk-vue/.gitignore b/packages/op-web-sdk-vue/.gitignore new file mode 100644 index 000000000..178135c2b --- /dev/null +++ b/packages/op-web-sdk-vue/.gitignore @@ -0,0 +1 @@ +/dist/ diff --git a/packages/op-web-sdk-vue/README.md b/packages/op-web-sdk-vue/README.md new file mode 100644 index 000000000..7fda50219 --- /dev/null +++ b/packages/op-web-sdk-vue/README.md @@ -0,0 +1,139 @@ +# @zseven-w/op-web-sdk-vue + +Vue 3 adapter for the OpenPencil read-only web viewer SDK. Embed a live OpenPencil design canvas in any Vue 3 application. + +> **Read-only boundary:** This package provides a viewer only — no editing. For the full editor experience, use the OpenPencil desktop or web app directly. + +## Install + +```bash +npm install @zseven-w/op-web-sdk @zseven-w/op-web-sdk-vue +# or +bun add @zseven-w/op-web-sdk @zseven-w/op-web-sdk-vue +``` + +## Peer dependencies + +| Package | Required version | +|---------|-----------------| +| `vue` | `^3.4.0` | + +## WASM note + +The core SDK (`@zseven-w/op-web-sdk`) loads a WebAssembly binary at runtime. You must either: +- Pass the wasm URL explicitly via the `wasmUrl` prop, or +- Serve the wasm file from your own static assets and ensure it is reachable. + +The wasm file ships alongside the core package at `@zseven-w/op-web-sdk/dist/op_web_sdk_bg.wasm`. + +## Usage + +### `` component + +The simplest way to embed a design viewer: + +```vue + + + +``` + +**Props:** +- `doc?: string | Uint8Array` — serialized OpenPencil document (JSON string or binary bytes). Omit to start with an empty document. +- `wasmUrl?: string` — URL to the core WASM binary. Required unless your bundler resolves the virtual `virtual:op_web_sdk_wasm` module. + +**Events:** +- `load(viewer: OpViewer)` — fired once the viewer is ready. + +### Manual provide / inject + +For advanced layouts where you need the viewer in child components, call `provideViewer` yourself and use `useViewer` in descendants: + +```vue + + +``` + +```vue + + +``` + +> **Vue lifecycle note:** `provide()` must be called during `setup()`, not inside `onMounted`. The built-in `` calls `provideViewer` in `onMounted` (after the async `createViewer` resolves), which means synchronous children cannot `inject` the viewer in their own `setup()`. This is acceptable for the common standalone use-case. If you need synchronous child injection, implement your own wrapper that `provide`s a `shallowRef` in `setup()` and fills it on mount. + +## Composables + +These composables must be called inside a component that is a descendant of a `provideViewer` call (or ``). + +| Composable | Returns | Reactive event | +|------------|---------|----------------| +| `useViewer()` | `OpViewer` | — (static reference) | +| `useDocument()` | `Readonly>` | `'load'` | +| `useViewport()` | `Readonly>` | `'viewportchange'` | +| `useActivePage()` | `Readonly>` | `'load'` | + +Each composable subscribes to the relevant viewer event and automatically unsubscribes when the component scope is disposed. + +```vue + + + +``` + +## API reference + +```ts +// Provider / inject +function provideViewer(viewer: OpViewer): void +function useViewer(): OpViewer // throws if no provider + +// Composables (reactive, auto-dispose) +function useDocument(): Readonly> +function useViewport(): Readonly> +function useActivePage(): Readonly> + +// Component +const DesignView: DefineComponent<{ doc?: string | Uint8Array; wasmUrl?: string }, {}, {}, {}, {}, {}, {}, { load: (viewer: OpViewer) => void }> + +// Injection key (for advanced use) +const viewerKey: InjectionKey +``` + +## License + +MIT diff --git a/packages/op-web-sdk-vue/package.json b/packages/op-web-sdk-vue/package.json new file mode 100644 index 000000000..f06bc999a --- /dev/null +++ b/packages/op-web-sdk-vue/package.json @@ -0,0 +1,16 @@ +{ + "name": "@zseven-w/op-web-sdk-vue", + "version": "0.8.0", + "description": "Vue 3 adapter for the OpenPencil read-only web viewer SDK", + "license": "MIT", + "type": "module", + "files": ["dist"], + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" } }, + "scripts": { "build": "tsup", "typecheck": "tsc --noEmit", "test": "vitest run" }, + "dependencies": { "@zseven-w/op-web-sdk": "workspace:*" }, + "peerDependencies": { "vue": "^3.4.0" }, + "devDependencies": { "vue": "^3.4.0", "@vue/test-utils": "^2.4.0", "@vitejs/plugin-vue": "^5.0.0", "tsup": "^8.0.0", "typescript": "^5.7.2", "vitest": "^3.0.0", "jsdom": "^25.0.0" } +} diff --git a/packages/op-web-sdk-vue/src/composables.ts b/packages/op-web-sdk-vue/src/composables.ts new file mode 100644 index 000000000..3afe65e3d --- /dev/null +++ b/packages/op-web-sdk-vue/src/composables.ts @@ -0,0 +1,62 @@ +import { onScopeDispose, shallowRef, triggerRef, watch, type ShallowRef } from 'vue'; +import type { OpViewer, PenDocument } from '@zseven-w/op-web-sdk'; +import { useViewer } from './use-viewer.js'; + +type ViewerEvent = 'load' | 'viewportchange'; + +/** + * Watch the viewer ref and keep an output ref in sync with a viewer property. + * When the viewer becomes available (or changes), subscribe to `event` and + * read the initial value immediately. The previous subscription is cleaned up + * via watch's onCleanup callback. + */ +function useViewerRef( + event: ViewerEvent, + read: (v: OpViewer) => T, + fallback: T, +): Readonly> { + const viewerRef = useViewer(); + const out = shallowRef(viewerRef.value != null ? read(viewerRef.value) : fallback); + + // Track the latest unsubscribe function for onScopeDispose safety. + let latestOff: (() => void) | null = null; + + watch( + viewerRef, + (v, _old, onCleanup) => { + if (!v) return; + // Read initial value as soon as the viewer is available. + out.value = read(v); + triggerRef(out); + // Subscribe to future events from this viewer. + const off = v.on(event, () => { + out.value = read(v); + triggerRef(out); + }); + latestOff = off; + onCleanup(off); + }, + { immediate: true }, + ); + + // Safety: also unsubscribe when the composable scope is disposed (e.g. component unmounted + // before the watcher's own cleanup runs). + onScopeDispose(() => { + latestOff?.(); + latestOff = null; + }); + + return out; +} + +export function useDocument(): Readonly> { + return useViewerRef('load', (v) => v.document, null); +} + +export function useViewport() { + return useViewerRef('viewportchange', (v) => v.viewport, null); +} + +export function useActivePage(): Readonly> { + return useViewerRef('load', (v) => v.activePage, 0); +} diff --git a/packages/op-web-sdk-vue/src/design-view.ts b/packages/op-web-sdk-vue/src/design-view.ts new file mode 100644 index 000000000..78550cefc --- /dev/null +++ b/packages/op-web-sdk-vue/src/design-view.ts @@ -0,0 +1,54 @@ +import { defineComponent, Fragment, h, onMounted, onUnmounted, ref, shallowRef } from 'vue'; +import { createViewer, type OpViewer } from '@zseven-w/op-web-sdk'; +import { provideViewerRef } from './use-viewer.js'; + +export const DesignView = defineComponent({ + name: 'DesignView', + props: { + doc: { + type: [String, Object] as unknown as () => string | Uint8Array, + default: undefined, + }, + wasmUrl: { type: String, default: undefined }, + }, + emits: { load: (_viewer: OpViewer) => true }, + setup(props, { emit, slots }) { + const canvasRef = ref(null); + + // Create the ref in setup() so provide() runs at the correct time. + // Child composables that call useViewer() will receive this ref and + // watch it; when viewerRef.value becomes non-null they subscribe. + const viewerRef = shallowRef(null); + provideViewerRef(viewerRef); + + let cancelled = false; + + onMounted(async () => { + const canvas = canvasRef.value; + if (!canvas) return; + const v = await createViewer({ canvas, doc: props.doc, wasmUrl: props.wasmUrl }); + if (cancelled) { + // Component was unmounted before the async viewer resolved — discard it. + v.destroy(); + return; + } + // Setting .value triggers watchers in child composables. + viewerRef.value = v; + emit('load', v); + }); + + onUnmounted(() => { + cancelled = true; + viewerRef.value?.destroy(); + viewerRef.value = null; + }); + + // Render the canvas plus any slotted children (e.g. child components that + // call useViewport/useDocument to subscribe to viewer events). + return () => + h(Fragment, [ + h('canvas', { ref: canvasRef, style: 'width:100%;height:100%;display:block' }), + slots.default?.(), + ]); + }, +}); diff --git a/packages/op-web-sdk-vue/src/index.ts b/packages/op-web-sdk-vue/src/index.ts new file mode 100644 index 000000000..2d3f29a71 --- /dev/null +++ b/packages/op-web-sdk-vue/src/index.ts @@ -0,0 +1,5 @@ +export const VERSION = '0.8.0'; +export { viewerKey } from './injection.js'; +export { provideViewer, provideViewerRef, useViewer } from './use-viewer.js'; +export { useDocument, useViewport, useActivePage } from './composables.js'; +export { DesignView } from './design-view.js'; diff --git a/packages/op-web-sdk-vue/src/injection.ts b/packages/op-web-sdk-vue/src/injection.ts new file mode 100644 index 000000000..bd885d2ba --- /dev/null +++ b/packages/op-web-sdk-vue/src/injection.ts @@ -0,0 +1,3 @@ +import type { InjectionKey, ShallowRef } from 'vue'; +import type { OpViewer } from '@zseven-w/op-web-sdk'; +export const viewerKey: InjectionKey> = Symbol('op-web-sdk-viewer'); diff --git a/packages/op-web-sdk-vue/src/use-viewer.ts b/packages/op-web-sdk-vue/src/use-viewer.ts new file mode 100644 index 000000000..3f3955d0e --- /dev/null +++ b/packages/op-web-sdk-vue/src/use-viewer.ts @@ -0,0 +1,27 @@ +import { inject, provide, shallowRef, type ShallowRef } from 'vue'; +import type { OpViewer } from '@zseven-w/op-web-sdk'; +import { viewerKey } from './injection.js'; + +/** Provide a viewer synchronously (manual / test use). */ +export function provideViewer(viewer: OpViewer): void { + provide(viewerKey, shallowRef(viewer)); +} + +/** + * Provide a pre-created ShallowRef so that DesignView can fill it + * asynchronously after mount while still calling provide() in setup(). + */ +export function provideViewerRef(ref: ShallowRef): void { + provide(viewerKey, ref); +} + +/** + * Return the injected ShallowRef. Consumers read `.value` + * to access the viewer; it becomes non-null once the async viewer is ready. + * Throws if called outside a DesignView / provideViewer context. + */ +export function useViewer(): ShallowRef { + const v = inject(viewerKey); + if (!v) throw new Error('op-web-sdk-vue: useViewer must be used inside /provideViewer'); + return v; +} diff --git a/packages/op-web-sdk-vue/test/composables.test.ts b/packages/op-web-sdk-vue/test/composables.test.ts new file mode 100644 index 000000000..3d9093547 --- /dev/null +++ b/packages/op-web-sdk-vue/test/composables.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { defineComponent, h, nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; +import { makeMockViewer } from './mock-core.js'; +import { provideViewer, useViewport } from '../src/index.js'; + +describe('useViewport (vue)', () => { + it('updates on viewportchange', async () => { + const { viewer, emit } = makeMockViewer(); + let vpRef: { value: { zoom: number } }; + const Child = defineComponent({ setup() { vpRef = useViewport() as never; return () => h('div'); } }); + const Parent = defineComponent({ setup() { provideViewer(viewer as never); return () => h(Child); } }); + mount(Parent); + expect(vpRef!.value.zoom).toBe(1); + viewer.setZoom(4); emit('viewportchange'); + await nextTick(); + expect(vpRef!.value.zoom).toBe(4); + }); +}); diff --git a/packages/op-web-sdk-vue/test/design-view.test.ts b/packages/op-web-sdk-vue/test/design-view.test.ts new file mode 100644 index 000000000..864267c08 --- /dev/null +++ b/packages/op-web-sdk-vue/test/design-view.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { defineComponent, h, nextTick } from 'vue'; +import { mount, flushPromises } from '@vue/test-utils'; +import { makeMockViewer } from './mock-core.js'; +const mock = makeMockViewer(); +vi.mock('@zseven-w/op-web-sdk', () => ({ createViewer: vi.fn(async () => mock.viewer) })); +import { DesignView } from '../src/index.js'; +import { useViewport } from '../src/index.js'; +import { createViewer } from '@zseven-w/op-web-sdk'; + +describe('DesignView (vue)', () => { + beforeEach(() => vi.clearAllMocks()); + + it('creates a viewer on mount, emits load, destroys on unmount', async () => { + const wrapper = mount(DesignView, { props: { doc: '{"version":"1.0"}' } }); + expect(wrapper.find('canvas').exists()).toBe(true); + await flushPromises(); + expect(createViewer).toHaveBeenCalledTimes(1); + expect(wrapper.emitted('load')?.[0]?.[0]).toBe(mock.viewer); + wrapper.unmount(); + expect(mock.viewer.destroy).toHaveBeenCalled(); + }); + + it('child composable inside DesignView receives viewport after async load', async () => { + // Proves that provide() in setup() + async fill lets children inject correctly. + let vpRef: ReturnType | undefined; + const Child = defineComponent({ + setup() { + vpRef = useViewport(); + return () => h('div'); + }, + }); + const Parent = defineComponent({ + setup() { + return () => h(DesignView, { doc: '{"version":"1.0"}' }, { default: () => h(Child) }); + }, + }); + + mount(Parent); + // Before load: viewport is null (viewer not yet created). + expect(vpRef!.value).toBeNull(); + + // After async createViewer resolves, viewerRef is set and watcher fires. + await flushPromises(); + await nextTick(); + expect(vpRef!.value).not.toBeNull(); + expect((vpRef!.value as { zoom: number }).zoom).toBe(1); + }); +}); diff --git a/packages/op-web-sdk-vue/test/mock-core.ts b/packages/op-web-sdk-vue/test/mock-core.ts new file mode 100644 index 000000000..7464b37ef --- /dev/null +++ b/packages/op-web-sdk-vue/test/mock-core.ts @@ -0,0 +1,24 @@ +import { vi } from 'vitest'; +// A controllable fake OpViewer for adapter tests. `emit(event)` fires listeners. +export function makeMockViewer() { + const listeners: Record void>> = { load: new Set(), viewportchange: new Set() }; + let vp = { panX: 0, panY: 0, zoom: 1 }; + const viewer = { + load: vi.fn(), + get document() { return { id: 'doc', pages: [] }; }, + get pages() { return [{ id: 'p', name: 'P', children: [] }]; }, + get pageCount() { return 1; }, + get activePage() { return 0; }, + get viewport() { return vp; }, + setViewport: vi.fn((v: { panX: number; panY: number; zoom: number }) => { vp = v; }), + setZoom: vi.fn((z: number) => { vp = { ...vp, zoom: z }; }), + panTo: vi.fn(), zoomToFit: vi.fn(), + export: vi.fn(() => new Uint8Array([60, 115, 118, 103, 47, 62])), + on: vi.fn((e: string, cb: () => void) => { listeners[e].add(cb); return () => listeners[e].delete(cb); }), + off: vi.fn((e: string, cb: () => void) => { listeners[e].delete(cb); }), + destroy: vi.fn(), + }; + const emit = (e: 'load' | 'viewportchange') => listeners[e].forEach((cb) => cb()); + return { viewer, emit }; +} +// Shared vi.mock factory: tests call vi.mock('@zseven-w/op-web-sdk', () => coreMock(current)) diff --git a/packages/op-web-sdk-vue/test/use-viewer.test.ts b/packages/op-web-sdk-vue/test/use-viewer.test.ts new file mode 100644 index 000000000..68843b764 --- /dev/null +++ b/packages/op-web-sdk-vue/test/use-viewer.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { defineComponent, h } from 'vue'; +import { mount } from '@vue/test-utils'; +import { makeMockViewer } from './mock-core.js'; +import { provideViewer, useViewer } from '../src/index.js'; + +describe('useViewer (vue)', () => { + it('returns a ref whose .value is the provided viewer', () => { + const { viewer } = makeMockViewer(); + let got: unknown; + // useViewer() now returns ShallowRef; read .value to get the viewer. + const Child = defineComponent({ setup() { got = useViewer().value; return () => h('div'); } }); + const Parent = defineComponent({ setup() { provideViewer(viewer as never); return () => h(Child); } }); + mount(Parent); + expect(got).toBe(viewer); + }); +}); diff --git a/packages/op-web-sdk-vue/tsconfig.json b/packages/op-web-sdk-vue/tsconfig.json new file mode 100644 index 000000000..502e7bd25 --- /dev/null +++ b/packages/op-web-sdk-vue/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", + "strict": true, "skipLibCheck": true, "noEmit": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vitest/globals"], + "paths": { + "@zseven-w/op-web-sdk": ["../op-web-sdk/src/index.ts"], + "virtual:op_web_sdk_wasm": ["../op-web-sdk/test/wasm-stub.ts"] + } + }, + "include": ["src", "test"] +} diff --git a/packages/op-web-sdk-vue/tsup.config.ts b/packages/op-web-sdk-vue/tsup.config.ts new file mode 100644 index 000000000..05ba26df6 --- /dev/null +++ b/packages/op-web-sdk-vue/tsup.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'tsup'; +export default defineConfig({ entry: ['src/index.ts'], format: ['esm', 'cjs'], dts: true, clean: true, sourcemap: true, external: ['vue', '@zseven-w/op-web-sdk'] }); diff --git a/packages/op-web-sdk-vue/vitest.config.ts b/packages/op-web-sdk-vue/vitest.config.ts new file mode 100644 index 000000000..24bf64ab7 --- /dev/null +++ b/packages/op-web-sdk-vue/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; +import vue from '@vitejs/plugin-vue'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +const here = fileURLToPath(new URL('.', import.meta.url)); +export default defineConfig({ + plugins: [vue()], + test: { environment: 'jsdom', globals: true }, + resolve: { alias: { + '@zseven-w/op-web-sdk': resolve(here, '../op-web-sdk/src/index.ts'), + 'virtual:op_web_sdk_wasm': resolve(here, '../op-web-sdk/test/wasm-stub.ts'), + } }, +});