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/.
145 lines
4.6 KiB
Vue
145 lines
4.6 KiB
Vue
<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>
|