feat(sdk): add React + Vue adapters for op-web-sdk

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: <DesignView> (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.
This commit is contained in:
Kayshen-X 2026-06-19 21:01:27 +08:00
parent baae07addd
commit 199d9bbec9
31 changed files with 871 additions and 0 deletions

1
packages/op-web-sdk-react/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/dist/

View file

@ -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
### `<DesignView>` 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 (
<DesignView
doc={docStringOrBytes}
wasmUrl="/assets/op_web_sdk_bg.wasm"
onLoad={handleLoad}
style={{ width: 800, height: 600 }}
/>
);
}
```
**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<HTMLCanvasElement>(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 (
<>
<canvas ref={canvasRef} />
{viewer && (
<DesignProvider viewer={viewer}>
<MyChildComponents />
</DesignProvider>
)}
</>
);
}
```
## Hooks
These hooks must be called inside a component that is a descendant of `DesignProvider` (or `<DesignView>`).
| 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 (
<div>
<p>Zoom: {viewport.zoom}</p>
<p>Pages: {doc.pages?.length}</p>
</div>
);
}
```
## 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

View file

@ -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" }
}

View file

@ -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<OpViewer | null>(null);

View file

@ -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<HTMLCanvasElement>(null);
const [viewer, setViewer] = useState<OpViewer | null>(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,
);
}

View file

@ -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<T>(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, []));
}

View file

@ -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';

View file

@ -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 <DesignProvider>/<DesignView>');
return v;
}

View file

@ -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(<DesignView doc='{"version":"1.0"}' onLoad={onLoad} />);
expect(container.querySelector('canvas')).toBeTruthy();
await waitFor(() => expect(createViewer).toHaveBeenCalledTimes(1));
await waitFor(() => expect(onLoad).toHaveBeenCalledWith(mock.viewer));
unmount();
expect(mock.viewer.destroy).toHaveBeenCalled();
});
});

View file

@ -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 }) => (
<DesignProvider viewer={viewer as never}>{children}</DesignProvider>
);
const { result } = renderHook(() => useViewport(), { wrapper });
expect(result.current.zoom).toBe(1);
act(() => { viewer.setZoom(3); emit('viewportchange'); });
expect(result.current.zoom).toBe(3);
});
});

View file

@ -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<string, Set<() => 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))

View file

@ -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'); });
});

View file

@ -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 }) => (
<DesignProvider viewer={viewer as never}>{children}</DesignProvider>
);
const { result } = renderHook(() => useViewer(), { wrapper });
expect(result.current).toBe(viewer);
});
it('throws without a provider', () => {
expect(() => renderHook(() => useViewer())).toThrow();
});
});

View file

@ -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"]
}

View file

@ -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'] });

View file

@ -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'),
} },
});

1
packages/op-web-sdk-vue/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/dist/

View file

@ -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
### `<DesignView>` component
The simplest way to embed a design viewer:
```vue
<script setup lang="ts">
import { DesignView } from '@zseven-w/op-web-sdk-vue';
import type { OpViewer } from '@zseven-w/op-web-sdk';
function onLoad(viewer: OpViewer) {
console.log('viewer ready', viewer.document);
}
</script>
<template>
<DesignView
:doc="docStringOrBytes"
wasmUrl="/assets/op_web_sdk_bg.wasm"
style="width: 800px; height: 600px"
@load="onLoad"
/>
</template>
```
**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
<!-- parent.vue -->
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { createViewer } from '@zseven-w/op-web-sdk';
import { provideViewer } from '@zseven-w/op-web-sdk-vue';
const canvasRef = ref<HTMLCanvasElement | null>(null);
onMounted(async () => {
const viewer = await createViewer({ canvas: canvasRef.value! });
provideViewer(viewer);
});
</script>
```
```vue
<!-- child.vue -->
<script setup lang="ts">
import { useViewer } from '@zseven-w/op-web-sdk-vue';
const viewer = useViewer(); // throws if no provider above
</script>
```
> **Vue lifecycle note:** `provide()` must be called during `setup()`, not inside `onMounted`. The built-in `<DesignView>` 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<OpViewer|null>` 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 `<DesignView>`).
| Composable | Returns | Reactive event |
|------------|---------|----------------|
| `useViewer()` | `OpViewer` | — (static reference) |
| `useDocument()` | `Readonly<ShallowRef<PenDocument>>` | `'load'` |
| `useViewport()` | `Readonly<ShallowRef<{panX,panY,zoom}>>` | `'viewportchange'` |
| `useActivePage()` | `Readonly<ShallowRef<number>>` | `'load'` |
Each composable subscribes to the relevant viewer event and automatically unsubscribes when the component scope is disposed.
```vue
<script setup lang="ts">
import { useViewport, useDocument } from '@zseven-w/op-web-sdk-vue';
const viewport = useViewport(); // ShallowRef<{panX, panY, zoom}>
const doc = useDocument(); // ShallowRef<PenDocument>
</script>
<template>
<p>Zoom: {{ viewport.zoom }}</p>
<p>Pages: {{ doc.pages?.length }}</p>
</template>
```
## API reference
```ts
// Provider / inject
function provideViewer(viewer: OpViewer): void
function useViewer(): OpViewer // throws if no provider
// Composables (reactive, auto-dispose)
function useDocument(): Readonly<ShallowRef<PenDocument>>
function useViewport(): Readonly<ShallowRef<{ panX: number; panY: number; zoom: number }>>
function useActivePage(): Readonly<ShallowRef<number>>
// Component
const DesignView: DefineComponent<{ doc?: string | Uint8Array; wasmUrl?: string }, {}, {}, {}, {}, {}, {}, { load: (viewer: OpViewer) => void }>
// Injection key (for advanced use)
const viewerKey: InjectionKey<OpViewer>
```
## License
MIT

View file

@ -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" }
}

View file

@ -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<T>(
event: ViewerEvent,
read: (v: OpViewer) => T,
fallback: T,
): Readonly<ShallowRef<T>> {
const viewerRef = useViewer();
const out = shallowRef<T>(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<ShallowRef<PenDocument | null>> {
return useViewerRef('load', (v) => v.document, null);
}
export function useViewport() {
return useViewerRef('viewportchange', (v) => v.viewport, null);
}
export function useActivePage(): Readonly<ShallowRef<number>> {
return useViewerRef('load', (v) => v.activePage, 0);
}

View file

@ -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<HTMLCanvasElement | null>(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<OpViewer | null>(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?.(),
]);
},
});

View file

@ -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';

View file

@ -0,0 +1,3 @@
import type { InjectionKey, ShallowRef } from 'vue';
import type { OpViewer } from '@zseven-w/op-web-sdk';
export const viewerKey: InjectionKey<ShallowRef<OpViewer | null>> = Symbol('op-web-sdk-viewer');

View file

@ -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<OpViewer | null>): void {
provide(viewerKey, ref);
}
/**
* Return the injected ShallowRef<OpViewer | null>. 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<OpViewer | null> {
const v = inject(viewerKey);
if (!v) throw new Error('op-web-sdk-vue: useViewer must be used inside <DesignView>/provideViewer');
return v;
}

View file

@ -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);
});
});

View file

@ -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<typeof useViewport> | 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);
});
});

View file

@ -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<string, Set<() => 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))

View file

@ -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<OpViewer | null>; 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);
});
});

View file

@ -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"]
}

View file

@ -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'] });

View file

@ -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'),
} },
});