Add @open-pencil/vue headless SDK package

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/.
This commit is contained in:
Danila Poyarkov 2026-03-16 17:24:45 +03:00
parent 6379217692
commit 8cdb4db7ee
19 changed files with 800 additions and 249 deletions

View file

@ -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=="],

View file

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

130
packages/vue/README.md Normal file
View file

@ -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
<script setup>
import { createEditor } from '@open-pencil/core/editor'
import { OpenPencilProvider, OpenPencilCanvas } from '@open-pencil/vue'
const editor = createEditor()
editor.createShape('RECTANGLE', 100, 100, 200, 150)
editor.zoomToFit()
</script>
<template>
<OpenPencilProvider :editor="editor">
<OpenPencilCanvas style="width: 100%; height: 100vh" />
</OpenPencilProvider>
</template>
```
## Components
All components are **renderless** — they provide data and actions via scoped slots. You control the markup and styling.
### `<OpenPencilProvider>`
Provides the editor instance to all child components via Vue injection.
```vue
<OpenPencilProvider :editor="editor">
<slot />
</OpenPencilProvider>
```
### `<OpenPencilCanvas>`
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 |
### `<PageList>`
Exposes page data and navigation.
```vue
<PageList v-slot="{ pages, currentPageId, switchPage, addPage, deletePage, renamePage }">
<div v-for="page in pages" :key="page.id" @click="switchPage(page.id)">
{{ page.name }}
</div>
</PageList>
```
### `<LayerTree>`
Exposes the flattened layer tree with selection state.
```vue
<LayerTree v-slot="{ layers, selectedIds, select, toggleVisibility, toggleLock }">
<div
v-for="layer in layers" :key="layer.node.id"
:style="{ paddingLeft: layer.depth * 16 + 'px' }"
@click="select([layer.node.id])"
>
{{ layer.node.name }}
</div>
</LayerTree>
```
### `<ToolSelector>`
Exposes the active tool and setter.
```vue
<ToolSelector v-slot="{ activeTool, setTool }">
<button :class="{ active: activeTool === 'SELECT' }" @click="setTool('SELECT')">Select</button>
<button :class="{ active: activeTool === 'RECTANGLE' }" @click="setTool('RECTANGLE')">Rectangle</button>
</ToolSelector>
```
### `<NodeProperties>`
Exposes the selected node and an update function.
```vue
<NodeProperties v-slot="{ node, update }">
<div v-if="node">
<input :value="node.x" @change="update({ x: +$event.target.value })" />
</div>
</NodeProperties>
```
## Composables
### `useEditor()`
Access the editor instance from any descendant of `<OpenPencilProvider>`.
```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 `<OpenPencilCanvas>` — use directly only if you need custom canvas setup.
## Example
Run the included example:
```bash
cd packages/vue/example
bun install
bun run dev
```

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenPencil Vue SDK Example</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -0,0 +1,144 @@
<script setup lang="ts">
import { createEditor, type Tool } from '@open-pencil/core/editor'
import {
OpenPencilProvider,
OpenPencilCanvas,
PageList,
LayerTree,
ToolSelector,
NodeProperties
} from '@open-pencil/vue'
const editor = createEditor()
editor.createShape('FRAME', 100, 100, 400, 300)
editor.createShape('RECTANGLE', 150, 150, 120, 80)
editor.createShape('ELLIPSE', 350, 200, 100, 100)
editor.zoomToFit()
const TOOL_LIST: Tool[] = [
'SELECT', 'FRAME', 'RECTANGLE', 'ELLIPSE',
'LINE', 'POLYGON', 'STAR', 'TEXT', 'PEN', 'HAND'
]
</script>
<template>
<OpenPencilProvider :editor="editor">
<div class="layout">
<ToolSelector v-slot="{ activeTool, setTool }">
<div class="toolbar">
<button
v-for="tool in TOOL_LIST"
:key="tool"
:class="{ active: activeTool === tool }"
@click="setTool(tool)"
>
{{ tool }}
</button>
</div>
</ToolSelector>
<div class="main">
<div class="panel left">
<PageList v-slot="{ pages, currentPageId, switchPage }">
<div class="section">
<h3>Pages</h3>
<div
v-for="page in pages"
:key="page.id"
:class="{ active: page.id === currentPageId }"
class="list-item"
@click="switchPage(page.id)"
>
{{ page.name }}
</div>
</div>
</PageList>
<LayerTree v-slot="{ layers, selectedIds, select }">
<div class="section">
<h3>Layers</h3>
<div
v-for="layer in layers"
:key="layer.node.id"
:class="{ active: selectedIds.has(layer.node.id) }"
:style="{ paddingLeft: layer.depth * 12 + 8 + 'px' }"
class="list-item"
@click="select([layer.node.id])"
>
{{ layer.node.name }}
</div>
</div>
</LayerTree>
</div>
<OpenPencilCanvas class="canvas-area" />
<div class="panel right">
<NodeProperties v-slot="{ node, update }">
<div v-if="node" class="section">
<h3>{{ node.type }}</h3>
<label>
X <input type="number" :value="node.x" @change="update({ x: +($event.target as HTMLInputElement).value })">
</label>
<label>
Y <input type="number" :value="node.y" @change="update({ y: +($event.target as HTMLInputElement).value })">
</label>
<label>
W <input type="number" :value="node.width" @change="update({ width: +($event.target as HTMLInputElement).value })">
</label>
<label>
H <input type="number" :value="node.height" @change="update({ height: +($event.target as HTMLInputElement).value })">
</label>
</div>
<div v-else class="section">
<p class="muted">No selection</p>
</div>
</NodeProperties>
</div>
</div>
</div>
</OpenPencilProvider>
</template>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: #1e1e1e; color: #e0e0e0; }
.layout { display: flex; flex-direction: column; height: 100vh; }
.toolbar {
display: flex; gap: 2px; padding: 4px 8px;
background: #2a2a2a; border-bottom: 1px solid #3a3a3a;
}
.toolbar button {
padding: 4px 10px; font-size: 11px; background: transparent;
color: #ccc; border: 1px solid transparent; border-radius: 4px; cursor: pointer;
}
.toolbar button:hover { background: #3a3a3a; }
.toolbar button.active { background: #4a90d9; color: white; border-color: #5a9fe9; }
.main { display: flex; flex: 1; overflow: hidden; }
.panel { width: 220px; background: #252525; overflow-y: auto; flex-shrink: 0; }
.panel.left { border-right: 1px solid #3a3a3a; }
.panel.right { border-left: 1px solid #3a3a3a; }
.section { padding: 8px; }
.section h3 { font-size: 11px; text-transform: uppercase; color: #888; margin-bottom: 6px; letter-spacing: 0.5px; }
.list-item { padding: 4px 8px; font-size: 13px; border-radius: 4px; cursor: pointer; }
.list-item:hover { background: #333; }
.list-item.active { background: #4a90d9; color: white; }
.canvas-area { flex: 1; }
label { display: flex; align-items: center; gap: 8px; font-size: 12px; margin-bottom: 4px; }
label input {
flex: 1; background: #1e1e1e; border: 1px solid #3a3a3a;
color: #e0e0e0; padding: 3px 6px; border-radius: 3px; font-size: 12px;
}
.muted { color: #666; font-size: 13px; }
</style>

View file

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

View file

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

46
packages/vue/package.json Normal file
View file

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

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useEditor } from '../context'
const editor = useEditor()
const layers = computed(() => editor.getLayerTree())
const selectedIds = computed(() => editor.state.selectedIds)
</script>
<template>
<slot
:layers="layers"
:selected-ids="selectedIds"
:select="editor.select"
:toggle-visibility="editor.toggleVisibility"
:toggle-lock="editor.toggleLock"
:rename-node="editor.renameNode"
:delete-selected="editor.deleteSelected"
/>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { SceneNode } from '@open-pencil/core/scene-graph'
import { useEditor } from '../context'
const editor = useEditor()
const node = computed(() => editor.getSelectedNode())
function update(props: Partial<SceneNode>) {
const n = node.value
if (!n) return
editor.updateNodeWithUndo(n.id, props)
}
</script>
<template>
<slot :node="node" :update="update" :editor="editor" />
</template>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useEditor } from '../context'
import { useCanvas } from '../composables/use-canvas'
import type { UseCanvasOptions } from '../composables/use-canvas'
const props = withDefaults(defineProps<UseCanvasOptions>(), {
showRulers: undefined
})
const editor = useEditor()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const canvas = useCanvas(canvasRef, editor, props)
defineExpose({ canvasRef, editor, ...canvas })
</script>
<template>
<canvas ref="canvasRef" style="width: 100%; height: 100%; display: block" />
</template>

View file

@ -0,0 +1,13 @@
<script setup lang="ts">
import type { Editor } from '@open-pencil/core/editor'
import { provideEditor } from '../context'
const props = defineProps<{ editor: Editor }>()
provideEditor(props.editor)
</script>
<template>
<slot />
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useEditor } from '../context'
const editor = useEditor()
const pages = computed(() => editor.graph.getPages())
const currentPageId = computed(() => editor.state.currentPageId)
</script>
<template>
<slot
:pages="pages"
:current-page-id="currentPageId"
:switch-page="editor.switchPage"
:add-page="editor.addPage"
:delete-page="editor.deletePage"
:rename-page="editor.renamePage"
/>
</template>

View file

@ -0,0 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useEditor } from '../context'
const editor = useEditor()
const activeTool = computed(() => editor.state.activeTool)
</script>
<template>
<slot :active-tool="activeTool" :set-tool="editor.setTool" />
</template>

View file

@ -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<CanvasKit['MakeSurface']>
}
function asWebGPU(ck: CanvasKit): CanvasKitWebGPU {
return ck as unknown as CanvasKitWebGPU
}
async function initWebGPU(ck: CanvasKit): Promise<WebGPUContext | null> {
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<HTMLCanvasElement | null>,
editor: Editor,
options?: UseCanvasOptions
) {
let renderer: SkiaRenderer | null = null
let ck: CanvasKit | null = null
let gpuCtx: WebGPUContext | null = null
let glContext: ReturnType<CanvasKit['MakeGrContext']> | null = null
let destroyed = false
let dirty = true
let lastRenderVersion = -1
let lastSelectedIds: Set<string> | 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
}
}

View file

@ -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<Editor> = 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 <OpenPencilProvider>. ' +
'Wrap your component tree with <OpenPencilProvider :editor="editor">.'
)
}
return editor
}

14
packages/vue/src/index.ts Normal file
View file

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

View file

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

View file

@ -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<CanvasKit['MakeSurface']>
}
function asWebGPU(ck: CanvasKit): CanvasKitWebGPU {
return ck as unknown as CanvasKitWebGPU
}
async function initWebGPU(ck: CanvasKit): Promise<WebGPUContext | null> {
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<HTMLCanvasElement | null>, store: EditorStore) {
let renderer: SkiaRenderer | null = null
let ck: CanvasKit | null = null
let gpuCtx: WebGPUContext | null = null
let glContext: ReturnType<CanvasKit['MakeGrContext']> | null = null
let destroyed = false
let dirty = true
let lastRenderVersion = -1
let lastSelectedIds: Set<string> | 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'