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:
parent
6379217692
commit
8cdb4db7ee
15
bun.lock
15
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=="],
|
||||
|
|
|
|||
|
|
@ -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
130
packages/vue/README.md
Normal 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
|
||||
```
|
||||
12
packages/vue/example/index.html
Normal file
12
packages/vue/example/index.html
Normal 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>
|
||||
144
packages/vue/example/src/App.vue
Normal file
144
packages/vue/example/src/App.vue
Normal 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>
|
||||
5
packages/vue/example/src/main.ts
Normal file
5
packages/vue/example/src/main.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
26
packages/vue/example/vite.config.ts
Normal file
26
packages/vue/example/vite.config.ts
Normal 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
46
packages/vue/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
22
packages/vue/src/components/LayerTree.vue
Normal file
22
packages/vue/src/components/LayerTree.vue
Normal 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>
|
||||
21
packages/vue/src/components/NodeProperties.vue
Normal file
21
packages/vue/src/components/NodeProperties.vue
Normal 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>
|
||||
22
packages/vue/src/components/OpenPencilCanvas.vue
Normal file
22
packages/vue/src/components/OpenPencilCanvas.vue
Normal 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>
|
||||
13
packages/vue/src/components/OpenPencilProvider.vue
Normal file
13
packages/vue/src/components/OpenPencilProvider.vue
Normal 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>
|
||||
21
packages/vue/src/components/PageList.vue
Normal file
21
packages/vue/src/components/PageList.vue
Normal 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>
|
||||
13
packages/vue/src/components/ToolSelector.vue
Normal file
13
packages/vue/src/components/ToolSelector.vue
Normal 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>
|
||||
255
packages/vue/src/composables/use-canvas.ts
Normal file
255
packages/vue/src/composables/use-canvas.ts
Normal 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
|
||||
}
|
||||
}
|
||||
21
packages/vue/src/context.ts
Normal file
21
packages/vue/src/context.ts
Normal 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
14
packages/vue/src/index.ts
Normal 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'
|
||||
17
packages/vue/tsconfig.json
Normal file
17
packages/vue/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue