From 8cdb4db7eeb4395b87553561bfcefd3bc8beffb9 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 16 Mar 2026 17:24:45 +0300 Subject: [PATCH] Add @open-pencil/vue headless SDK package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renderless components (Reka UI-style) exposing editor logic via slots: - OpenPencilProvider — injects editor via Vue provide/inject - OpenPencilCanvas — CanvasKit/Skia surface with render loop - PageList, LayerTree, ToolSelector, NodeProperties — slot-based Composables: - useEditor() — access editor from any descendant - useCanvas() — low-level surface lifecycle (moved from app) The app's use-canvas.ts is now a 2-line re-export from the SDK. Includes example app in packages/vue/example/. --- bun.lock | 15 ++ package.json | 1 + packages/vue/README.md | 130 +++++++++ packages/vue/example/index.html | 12 + packages/vue/example/src/App.vue | 144 ++++++++++ packages/vue/example/src/main.ts | 5 + packages/vue/example/vite.config.ts | 26 ++ packages/vue/package.json | 46 ++++ packages/vue/src/components/LayerTree.vue | 22 ++ .../vue/src/components/NodeProperties.vue | 21 ++ .../vue/src/components/OpenPencilCanvas.vue | 22 ++ .../vue/src/components/OpenPencilProvider.vue | 13 + packages/vue/src/components/PageList.vue | 21 ++ packages/vue/src/components/ToolSelector.vue | 13 + packages/vue/src/composables/use-canvas.ts | 255 ++++++++++++++++++ packages/vue/src/context.ts | 21 ++ packages/vue/src/index.ts | 14 + packages/vue/tsconfig.json | 17 ++ src/composables/use-canvas.ts | 251 +---------------- 19 files changed, 800 insertions(+), 249 deletions(-) create mode 100644 packages/vue/README.md create mode 100644 packages/vue/example/index.html create mode 100644 packages/vue/example/src/App.vue create mode 100644 packages/vue/example/src/main.ts create mode 100644 packages/vue/example/vite.config.ts create mode 100644 packages/vue/package.json create mode 100644 packages/vue/src/components/LayerTree.vue create mode 100644 packages/vue/src/components/NodeProperties.vue create mode 100644 packages/vue/src/components/OpenPencilCanvas.vue create mode 100644 packages/vue/src/components/OpenPencilProvider.vue create mode 100644 packages/vue/src/components/PageList.vue create mode 100644 packages/vue/src/components/ToolSelector.vue create mode 100644 packages/vue/src/composables/use-canvas.ts create mode 100644 packages/vue/src/context.ts create mode 100644 packages/vue/src/index.ts create mode 100644 packages/vue/tsconfig.json diff --git a/bun.lock b/bun.lock index bcce6c7b4..b42b053a7 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@ai-sdk/vue": "^3.0.105", "@open-pencil/cli": "workspace:*", "@open-pencil/core": "workspace:*", + "@open-pencil/vue": "workspace:*", "@openrouter/ai-sdk-provider": "^2.2.3", "@tailwindcss/vite": "^4.2.1", "@tanstack/vue-table": "^8.21.3", @@ -142,6 +143,18 @@ "@types/ws": "^8.18.1", }, }, + "packages/vue": { + "name": "@open-pencil/vue", + "version": "0.10.0", + "dependencies": { + "@vueuse/core": "^14.2.1", + }, + "peerDependencies": { + "@open-pencil/core": "workspace:*", + "canvaskit-wasm": ">=0.39.0", + "vue": "^3.4.0", + }, + }, }, "packages": { "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="], @@ -674,6 +687,8 @@ "@open-pencil/mcp": ["@open-pencil/mcp@workspace:packages/mcp"], + "@open-pencil/vue": ["@open-pencil/vue@workspace:packages/vue"], + "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.2.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-NovC+BaCfEeJwhToDrs8JeDYXXlJdEyz7lcxkjtyePSE4eoAKik872SyDK0MzXKcz8MRkv7XlNhPI6zz4TQp0g=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], diff --git a/package.json b/package.json index 42a6d6564..e98bf5044 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@ai-sdk/vue": "^3.0.105", "@open-pencil/cli": "workspace:*", "@open-pencil/core": "workspace:*", + "@open-pencil/vue": "workspace:*", "@openrouter/ai-sdk-provider": "^2.2.3", "@tailwindcss/vite": "^4.2.1", "@tanstack/vue-table": "^8.21.3", diff --git a/packages/vue/README.md b/packages/vue/README.md new file mode 100644 index 000000000..6ae86215f --- /dev/null +++ b/packages/vue/README.md @@ -0,0 +1,130 @@ +# @open-pencil/vue + +Headless Vue 3 SDK for embedding an OpenPencil design editor. Renderless components expose logic via scoped slots — bring your own UI. + +## Install + +```bash +npm install @open-pencil/vue @open-pencil/core canvaskit-wasm +``` + +## Quick start + +```vue + + + +``` + +## Components + +All components are **renderless** — they provide data and actions via scoped slots. You control the markup and styling. + +### `` + +Provides the editor instance to all child components via Vue injection. + +```vue + + + +``` + +### `` + +Renders the CanvasKit/Skia drawing surface. Handles WebGL/WebGPU setup, resize, and the render loop. + +| Prop | Type | Description | +|------|------|-------------| +| `showRulers` | `boolean?` | Override ruler visibility | +| `preserveDrawingBuffer` | `boolean?` | Keep buffer for screenshots | +| `onReady` | `() => void` | Called when surface is ready | + +### `` + +Exposes page data and navigation. + +```vue + +
+ {{ page.name }} +
+
+``` + +### `` + +Exposes the flattened layer tree with selection state. + +```vue + +
+ {{ layer.node.name }} +
+
+``` + +### `` + +Exposes the active tool and setter. + +```vue + + + + +``` + +### `` + +Exposes the selected node and an update function. + +```vue + +
+ +
+
+``` + +## Composables + +### `useEditor()` + +Access the editor instance from any descendant of ``. + +```ts +import { useEditor } from '@open-pencil/vue' + +const editor = useEditor() +editor.createShape('ELLIPSE', 0, 0, 100, 100) +``` + +### `useCanvas(canvasRef, editor, options?)` + +Low-level composable for CanvasKit surface lifecycle. Used internally by `` — use directly only if you need custom canvas setup. + +## Example + +Run the included example: + +```bash +cd packages/vue/example +bun install +bun run dev +``` diff --git a/packages/vue/example/index.html b/packages/vue/example/index.html new file mode 100644 index 000000000..da5d50c93 --- /dev/null +++ b/packages/vue/example/index.html @@ -0,0 +1,12 @@ + + + + + + OpenPencil Vue SDK Example + + +
+ + + diff --git a/packages/vue/example/src/App.vue b/packages/vue/example/src/App.vue new file mode 100644 index 000000000..07cd63264 --- /dev/null +++ b/packages/vue/example/src/App.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/packages/vue/example/src/main.ts b/packages/vue/example/src/main.ts new file mode 100644 index 000000000..912d54f8d --- /dev/null +++ b/packages/vue/example/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' + +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/packages/vue/example/vite.config.ts b/packages/vue/example/vite.config.ts new file mode 100644 index 000000000..5e26b6a7e --- /dev/null +++ b/packages/vue/example/vite.config.ts @@ -0,0 +1,26 @@ +import { resolve } from 'path' +import { copyFileSync, existsSync, mkdirSync } from 'fs' + +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [ + { + name: 'copy-canvaskit-wasm', + buildStart() { + const src = resolve(__dirname, '../../../node_modules/canvaskit-wasm/bin/canvaskit.wasm') + const destDir = resolve(__dirname, 'public') + const dest = resolve(destDir, 'canvaskit.wasm') + if (existsSync(src) && !existsSync(dest)) { + mkdirSync(destDir, { recursive: true }) + copyFileSync(src, dest) + } + } + }, + vue() + ], + server: { + port: 3333 + } +}) diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 000000000..139248ed0 --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,46 @@ +{ + "name": "@open-pencil/vue", + "version": "0.10.0", + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "bun": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "files": ["src", "dist"], + "scripts": { + "build": "vue-tsc --noEmit", + "prepublishOnly": "bun run build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/open-pencil/open-pencil.git", + "directory": "packages/vue" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "node": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "peerDependencies": { + "vue": "^3.4.0", + "@open-pencil/core": "workspace:*", + "canvaskit-wasm": ">=0.39.0" + }, + "dependencies": { + "@vueuse/core": "^14.2.1" + } +} diff --git a/packages/vue/src/components/LayerTree.vue b/packages/vue/src/components/LayerTree.vue new file mode 100644 index 000000000..e48ad0da0 --- /dev/null +++ b/packages/vue/src/components/LayerTree.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/vue/src/components/NodeProperties.vue b/packages/vue/src/components/NodeProperties.vue new file mode 100644 index 000000000..46d78f9a3 --- /dev/null +++ b/packages/vue/src/components/NodeProperties.vue @@ -0,0 +1,21 @@ + + + diff --git a/packages/vue/src/components/OpenPencilCanvas.vue b/packages/vue/src/components/OpenPencilCanvas.vue new file mode 100644 index 000000000..bd1f1c04e --- /dev/null +++ b/packages/vue/src/components/OpenPencilCanvas.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/vue/src/components/OpenPencilProvider.vue b/packages/vue/src/components/OpenPencilProvider.vue new file mode 100644 index 000000000..63255f972 --- /dev/null +++ b/packages/vue/src/components/OpenPencilProvider.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/vue/src/components/PageList.vue b/packages/vue/src/components/PageList.vue new file mode 100644 index 000000000..5cf4593d7 --- /dev/null +++ b/packages/vue/src/components/PageList.vue @@ -0,0 +1,21 @@ + + + diff --git a/packages/vue/src/components/ToolSelector.vue b/packages/vue/src/components/ToolSelector.vue new file mode 100644 index 000000000..5537941a0 --- /dev/null +++ b/packages/vue/src/components/ToolSelector.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/vue/src/composables/use-canvas.ts b/packages/vue/src/composables/use-canvas.ts new file mode 100644 index 000000000..d7ad387ed --- /dev/null +++ b/packages/vue/src/composables/use-canvas.ts @@ -0,0 +1,255 @@ +import { useBreakpoints, useRafFn, useResizeObserver } from '@vueuse/core' +import { onMounted, onUnmounted, type Ref } from 'vue' + +import { getCanvasKit, getGpuBackend, SkiaRenderer } from '@open-pencil/core' + +import type { Editor } from '@open-pencil/core/editor' +import type { CanvasKit } from 'canvaskit-wasm' + +interface WebGPUContext { + device: GPUDevice + deviceContext: unknown +} + +interface CanvasKitWebGPU { + MakeGPUDeviceContext(device: GPUDevice): unknown + MakeGPUCanvasContext(ctx: unknown, canvas: HTMLCanvasElement, opts?: unknown): unknown + MakeGPUCanvasSurface( + ctx: unknown, + colorSpace?: unknown, + width?: number, + height?: number + ): ReturnType +} + +function asWebGPU(ck: CanvasKit): CanvasKitWebGPU { + return ck as unknown as CanvasKitWebGPU +} + +async function initWebGPU(ck: CanvasKit): Promise { + if (!('gpu' in navigator)) return null + const adapter = await navigator.gpu.requestAdapter() + if (!adapter) return null + const device = await adapter.requestDevice() + // oxlint-disable-next-line typescript/no-unnecessary-condition -- WebGPU CanvasKit API may not exist at runtime + const deviceContext = asWebGPU(ck).MakeGPUDeviceContext?.(device) + if (!deviceContext) return null + return { device, deviceContext } +} + +export interface UseCanvasOptions { + showRulers?: boolean + preserveDrawingBuffer?: boolean + onReady?: () => void +} + +export function useCanvas( + canvasRef: Ref, + editor: Editor, + options?: UseCanvasOptions +) { + let renderer: SkiaRenderer | null = null + let ck: CanvasKit | null = null + let gpuCtx: WebGPUContext | null = null + let glContext: ReturnType | null = null + let destroyed = false + let dirty = true + let lastRenderVersion = -1 + let lastSelectedIds: Set | null = null + + async function init() { + const canvas = canvasRef.value + if (!canvas || destroyed) return + + ck = await getCanvasKit() + // oxlint-disable-next-line typescript/no-unnecessary-condition -- async race: destroyed may change during await + if (destroyed) return + + if (getGpuBackend() === 'webgpu') { + gpuCtx = await initWebGPU(ck) + if (!gpuCtx) { + console.warn('WebGPU init failed, reload without ?gpu=webgpu to use WebGL') + return + } + } + + await new Promise((r) => requestAnimationFrame(r)) + createSurface(canvas) + } + + function sizeCanvas(canvas: HTMLCanvasElement) { + const dpr = window.devicePixelRatio || 1 + canvas.width = canvas.clientWidth * dpr + canvas.height = canvas.clientHeight * dpr + } + + function makeGLSurface(canvas: HTMLCanvasElement) { + if (!ck) return null + if (!glContext) { + const glAttrs = options?.preserveDrawingBuffer ? { preserveDrawingBuffer: 1 } : undefined + const handle = ck.GetWebGLContext(canvas, glAttrs) + if (!handle) return null + glContext = ck.MakeGrContext(handle) + } + if (!glContext) return null + return ck.MakeOnScreenGLSurface(glContext, canvas.width, canvas.height, ck.ColorSpace.SRGB) + } + + function createSurface(canvas: HTMLCanvasElement) { + if (!ck) return + + renderer?.destroy() + renderer = null + glContext?.delete() + glContext = null + + sizeCanvas(canvas) + + let surface + if (getGpuBackend() === 'webgpu' && gpuCtx) { + const gpu = asWebGPU(ck) + const canvasCtx = gpu.MakeGPUCanvasContext(gpuCtx.deviceContext, canvas) + surface = gpu.MakeGPUCanvasSurface(canvasCtx, ck.ColorSpace.SRGB, canvas.width, canvas.height) + if (!surface) { + console.error('Failed to create WebGPU surface') + return + } + } else { + surface = makeGLSurface(canvas) + if (!surface) { + console.error('Failed to create WebGL surface') + return + } + } + + const glCtx = canvas.getContext('webgl2') ?? null + renderer = new SkiaRenderer(ck, surface, glCtx) + editor.setCanvasKit(ck, renderer) + void renderer.loadFonts().then(() => renderNow()) + renderNow() + canvas.dataset.ready = '1' + options?.onReady?.() + } + + const params = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : new URLSearchParams() + const noRulersParam = params.has('no-rulers') + const breakpoints = useBreakpoints({ mobile: 768 }) + const isMobile = breakpoints.smaller('mobile') + + function shouldShowRulers() { + if (options?.showRulers === false) return false + return !noRulersParam && !isMobile.value + } + + function renderNow() { + if (!renderer || destroyed) return + renderer.dpr = window.devicePixelRatio || 1 + renderer.panX = editor.state.panX + renderer.panY = editor.state.panY + renderer.zoom = editor.state.zoom + renderer.viewportWidth = canvasRef.value?.clientWidth ?? 0 + renderer.viewportHeight = canvasRef.value?.clientHeight ?? 0 + renderer.showRulers = shouldShowRulers() + renderer.pageColor = editor.state.pageColor + renderer.pageId = editor.state.currentPageId + renderer.render( + editor.graph, + editor.state.selectedIds, + { + hoveredNodeId: editor.state.hoveredNodeId, + enteredContainerId: editor.state.enteredContainerId, + editingTextId: editor.state.editingTextId, + textEditor: editor.textEditor, + marquee: editor.state.marquee, + snapGuides: editor.state.snapGuides, + rotationPreview: editor.state.rotationPreview, + dropTargetId: editor.state.dropTargetId, + layoutInsertIndicator: editor.state.layoutInsertIndicator, + penState: editor.state.penState + ? { + ...editor.state.penState, + cursorX: editor.state.penCursorX ?? undefined, + cursorY: editor.state.penCursorY ?? undefined + } + : null, + remoteCursors: editor.state.remoteCursors.length > 0 ? editor.state.remoteCursors : undefined + }, + editor.state.sceneVersion + ) + lastRenderVersion = editor.state.renderVersion + lastSelectedIds = editor.state.selectedIds + } + + const { pause } = useRafFn(() => { + if (editor.state.loading) return + const versionChanged = editor.state.renderVersion !== lastRenderVersion + const selectionChanged = editor.state.selectedIds !== lastSelectedIds + if (dirty || versionChanged || selectionChanged) { + dirty = false + renderNow() + } + }) + + onMounted(() => { + void init() + }) + + onUnmounted(() => { + destroyed = true + pause() + cancelAnimationFrame(resizeRaf) + renderer?.destroy() + glContext?.delete() + }) + + let resizeRaf = 0 + useResizeObserver(canvasRef, () => { + const canvas = canvasRef.value + if (!canvas || !ck || resizeRaf) return + resizeRaf = requestAnimationFrame(() => { + resizeRaf = 0 + resizeCanvas(canvas) + }) + }) + + function resizeCanvas(canvas: HTMLCanvasElement) { + if (!ck || !renderer) { + createSurface(canvas) + return + } + + sizeCanvas(canvas) + + const surface = makeGLSurface(canvas) + if (!surface) { + createSurface(canvas) + return + } + renderer.replaceSurface(surface) + renderNow() + } + + function hitTestSectionTitle(canvasX: number, canvasY: number) { + return renderer?.hitTestSectionTitle(editor.graph, canvasX, canvasY) ?? null + } + + function hitTestComponentLabel(canvasX: number, canvasY: number) { + return renderer?.hitTestComponentLabel(editor.graph, canvasX, canvasY) ?? null + } + + function hitTestFrameTitle(canvasX: number, canvasY: number) { + return ( + renderer?.hitTestFrameTitle(editor.graph, canvasX, canvasY, editor.state.selectedIds) ?? null + ) + } + + return { + render: () => { + dirty = true + }, + renderNow, + hitTestSectionTitle, + hitTestComponentLabel, + hitTestFrameTitle + } +} diff --git a/packages/vue/src/context.ts b/packages/vue/src/context.ts new file mode 100644 index 000000000..2a8198587 --- /dev/null +++ b/packages/vue/src/context.ts @@ -0,0 +1,21 @@ +import { inject, provide } from 'vue' + +import type { Editor } from '@open-pencil/core/editor' +import type { InjectionKey } from 'vue' + +export const EDITOR_KEY: InjectionKey = Symbol('open-pencil-editor') + +export function provideEditor(editor: Editor) { + provide(EDITOR_KEY, editor) +} + +export function useEditor(): Editor { + const editor = inject(EDITOR_KEY) + if (!editor) { + throw new Error( + '[open-pencil] useEditor() called outside . ' + + 'Wrap your component tree with .' + ) + } + return editor +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 000000000..277d00652 --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,14 @@ +export type { Editor, EditorState, EditorOptions, Tool, EditorToolDef } from '@open-pencil/core/editor' +export { createEditor, EDITOR_TOOLS, TOOL_SHORTCUTS } from '@open-pencil/core/editor' + +export { EDITOR_KEY, provideEditor, useEditor } from './context' + +export { useCanvas } from './composables/use-canvas' +export type { UseCanvasOptions } from './composables/use-canvas' + +export { default as OpenPencilProvider } from './components/OpenPencilProvider.vue' +export { default as OpenPencilCanvas } from './components/OpenPencilCanvas.vue' +export { default as PageList } from './components/PageList.vue' +export { default as LayerTree } from './components/LayerTree.vue' +export { default as ToolSelector } from './components/ToolSelector.vue' +export { default as NodeProperties } from './components/NodeProperties.vue' diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 000000000..7e8f5cfe4 --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "declaration": true, + "declarationDir": "./dist", + "outDir": "./dist", + "rootDir": "./src", + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src/**/*.ts", "src/**/*.vue"], + "exclude": ["node_modules", "dist", "example"] +} diff --git a/src/composables/use-canvas.ts b/src/composables/use-canvas.ts index c537968d3..daab74761 100644 --- a/src/composables/use-canvas.ts +++ b/src/composables/use-canvas.ts @@ -1,249 +1,2 @@ -import { useBreakpoints, useRafFn, useResizeObserver } from '@vueuse/core' -import { onMounted, onUnmounted, type Ref } from 'vue' - -import { getCanvasKit, getGpuBackend, SkiaRenderer } from '@open-pencil/core' - -import type { EditorStore } from '@/stores/editor' -import type { CanvasKit } from 'canvaskit-wasm' - -interface WebGPUContext { - device: GPUDevice - deviceContext: unknown -} - -interface CanvasKitWebGPU { - MakeGPUDeviceContext(device: GPUDevice): unknown - MakeGPUCanvasContext(ctx: unknown, canvas: HTMLCanvasElement, opts?: unknown): unknown - MakeGPUCanvasSurface( - ctx: unknown, - colorSpace?: unknown, - width?: number, - height?: number - ): ReturnType -} - -function asWebGPU(ck: CanvasKit): CanvasKitWebGPU { - return ck as unknown as CanvasKitWebGPU -} - -async function initWebGPU(ck: CanvasKit): Promise { - if (!('gpu' in navigator)) return null - const adapter = await navigator.gpu.requestAdapter() - if (!adapter) return null - const device = await adapter.requestDevice() - // oxlint-disable-next-line typescript/no-unnecessary-condition -- WebGPU CanvasKit API may not exist at runtime - const deviceContext = asWebGPU(ck).MakeGPUDeviceContext?.(device) - if (!deviceContext) return null - return { device, deviceContext } -} - -export function useCanvas(canvasRef: Ref, store: EditorStore) { - let renderer: SkiaRenderer | null = null - let ck: CanvasKit | null = null - let gpuCtx: WebGPUContext | null = null - let glContext: ReturnType | null = null - let destroyed = false - let dirty = true - let lastRenderVersion = -1 - let lastSelectedIds: Set | null = null - - async function init() { - const canvas = canvasRef.value - if (!canvas || destroyed) return - - ck = await getCanvasKit() - // oxlint-disable-next-line typescript/no-unnecessary-condition -- async race: destroyed may change during await - if (destroyed) return - - if (getGpuBackend() === 'webgpu') { - gpuCtx = await initWebGPU(ck) - if (!gpuCtx) { - console.warn('WebGPU init failed, reload without ?gpu=webgpu to use WebGL') - return - } - } - - await new Promise((r) => requestAnimationFrame(r)) - createSurface(canvas) - - const loader = document.getElementById('loader') - if (loader) { - loader.classList.add('fade-out') - setTimeout(() => loader.remove(), 300) - } - } - - function sizeCanvas(canvas: HTMLCanvasElement) { - const dpr = window.devicePixelRatio || 1 - canvas.width = canvas.clientWidth * dpr - canvas.height = canvas.clientHeight * dpr - } - - function makeGLSurface(canvas: HTMLCanvasElement) { - if (!ck) return null - if (!glContext) { - const isTest = new URLSearchParams(window.location.search).has('test') - const glAttrs = isTest ? { preserveDrawingBuffer: 1 } : undefined - const handle = ck.GetWebGLContext(canvas, glAttrs) - if (!handle) return null - glContext = ck.MakeGrContext(handle) - } - if (!glContext) return null - return ck.MakeOnScreenGLSurface(glContext, canvas.width, canvas.height, ck.ColorSpace.SRGB) - } - - function createSurface(canvas: HTMLCanvasElement) { - if (!ck) return - - renderer?.destroy() - renderer = null - glContext?.delete() - glContext = null - - sizeCanvas(canvas) - - let surface - if (getGpuBackend() === 'webgpu' && gpuCtx) { - const gpu = asWebGPU(ck) - const canvasCtx = gpu.MakeGPUCanvasContext(gpuCtx.deviceContext, canvas) - surface = gpu.MakeGPUCanvasSurface(canvasCtx, ck.ColorSpace.SRGB, canvas.width, canvas.height) - if (!surface) { - console.error('Failed to create WebGPU surface') - return - } - } else { - surface = makeGLSurface(canvas) - if (!surface) { - console.error('Failed to create WebGL surface') - return - } - } - - const glCtx = canvas.getContext('webgl2') ?? null - renderer = new SkiaRenderer(ck, surface, glCtx) - store.setCanvasKit(ck, renderer) - void renderer.loadFonts().then(() => renderNow()) - renderNow() - canvas.dataset.ready = '1' - } - - const params = new URLSearchParams(window.location.search) - const noRulersParam = params.has('no-rulers') - const breakpoints = useBreakpoints({ mobile: 768 }) - const isMobile = breakpoints.smaller('mobile') - - function showRulers() { - return !noRulersParam && !isMobile.value - } - - function renderNow() { - if (!renderer || destroyed) return - renderer.dpr = window.devicePixelRatio || 1 - renderer.panX = store.state.panX - renderer.panY = store.state.panY - renderer.zoom = store.state.zoom - renderer.viewportWidth = canvasRef.value?.clientWidth ?? 0 - renderer.viewportHeight = canvasRef.value?.clientHeight ?? 0 - renderer.showRulers = showRulers() - renderer.pageColor = store.state.pageColor - renderer.pageId = store.state.currentPageId - renderer.render( - store.graph, - store.state.selectedIds, - { - hoveredNodeId: store.state.hoveredNodeId, - enteredContainerId: store.state.enteredContainerId, - editingTextId: store.state.editingTextId, - textEditor: store.textEditor, - marquee: store.state.marquee, - snapGuides: store.state.snapGuides, - rotationPreview: store.state.rotationPreview, - dropTargetId: store.state.dropTargetId, - layoutInsertIndicator: store.state.layoutInsertIndicator, - penState: store.state.penState - ? { - ...store.state.penState, - cursorX: store.state.penCursorX ?? undefined, - cursorY: store.state.penCursorY ?? undefined - } - : null, - remoteCursors: store.state.remoteCursors.length > 0 ? store.state.remoteCursors : undefined - }, - store.state.sceneVersion - ) - lastRenderVersion = store.state.renderVersion - lastSelectedIds = store.state.selectedIds - } - - const { pause } = useRafFn(() => { - if (store.state.loading) return - const versionChanged = store.state.renderVersion !== lastRenderVersion - const selectionChanged = store.state.selectedIds !== lastSelectedIds - if (dirty || versionChanged || selectionChanged) { - dirty = false - renderNow() - } - }) - - onMounted(() => { - void init() - }) - - onUnmounted(() => { - destroyed = true - pause() - cancelAnimationFrame(resizeRaf) - renderer?.destroy() - glContext?.delete() - }) - - let resizeRaf = 0 - useResizeObserver(canvasRef, () => { - const canvas = canvasRef.value - if (!canvas || !ck || resizeRaf) return - resizeRaf = requestAnimationFrame(() => { - resizeRaf = 0 - resizeCanvas(canvas) - }) - }) - - function resizeCanvas(canvas: HTMLCanvasElement) { - if (!ck || !renderer) { - createSurface(canvas) - return - } - - sizeCanvas(canvas) - - const surface = makeGLSurface(canvas) - if (!surface) { - createSurface(canvas) - return - } - renderer.replaceSurface(surface) - renderNow() - } - - function hitTestSectionTitle(canvasX: number, canvasY: number) { - return renderer?.hitTestSectionTitle(store.graph, canvasX, canvasY) ?? null - } - - function hitTestComponentLabel(canvasX: number, canvasY: number) { - return renderer?.hitTestComponentLabel(store.graph, canvasX, canvasY) ?? null - } - - function hitTestFrameTitle(canvasX: number, canvasY: number) { - return ( - renderer?.hitTestFrameTitle(store.graph, canvasX, canvasY, store.state.selectedIds) ?? null - ) - } - - return { - render: () => { - dirty = true - }, - hitTestSectionTitle, - hitTestComponentLabel, - hitTestFrameTitle - } -} +export { useCanvas } from '@open-pencil/vue' +export type { UseCanvasOptions } from '@open-pencil/vue'