Phase 3: Property primitives — PropertyList, PositionControls

New SDK components:
- PropertyListRoot / PropertyListItem — headless array property editor
  for fills, strokes, effects. Provides add/remove/update/patch/
  toggleVisibility via slots. Handles multi-selection, mixed state.
- PositionControlsRoot — headless position/alignment/flip/rotate.
  Provides x/y/w/h/rotation + updateProp/commitProp/align/flip/rotate.

App FillSection.vue rewritten to compose from PropertyListRoot —
no more useNodeProps() destructuring, CRUD logic in SDK.

SkiaRenderer.renderFromEditorState() — single-call render that
takes EditorState directly (was 30 lines of manual property sync).
This commit is contained in:
Danila Poyarkov 2026-03-17 09:43:26 +03:00
parent 46e93a1b70
commit 638db38589
8 changed files with 366 additions and 162 deletions

View file

@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useEditor } from '../context'
import type { SceneNode } from '@open-pencil/core'
const editor = useEditor()
const nodes = computed(() => editor.getSelectedNodes())
const node = computed<SceneNode | null>(() => editor.getSelectedNode() ?? null)
const active = computed(() => nodes.value.length > 0)
const isMulti = computed(() => nodes.value.length > 1)
const ids = computed(() => nodes.value.map((n) => n.id))
const x = computed(() => Math.round(node.value?.x ?? 0))
const y = computed(() => Math.round(node.value?.y ?? 0))
const width = computed(() => node.value?.width ?? 0)
const height = computed(() => node.value?.height ?? 0)
const rotation = computed(() => Math.round(node.value?.rotation ?? 0))
function updateProp(key: string, value: number) {
for (const n of nodes.value) {
editor.updateNode(n.id, { [key]: value })
}
}
function commitProp(key: string, _value: number, previous: number) {
for (const n of nodes.value) {
editor.commitNodeUpdate(n.id, { [key]: previous } as Partial<SceneNode>, `Change ${key}`)
}
}
function align(axis: 'horizontal' | 'vertical', pos: 'min' | 'center' | 'max') {
editor.alignNodes(ids.value, axis, pos)
}
function flip(axis: 'horizontal' | 'vertical') {
editor.flipNodes(ids.value, axis)
}
function rotate(degrees: number) {
editor.rotateNodes(ids.value, degrees)
}
</script>
<template>
<slot
v-if="active"
:node="node"
:nodes="nodes"
:is-multi="isMulti"
:x="x"
:y="y"
:width="width"
:height="height"
:rotation="rotation"
:update-prop="updateProp"
:commit-prop="commitProp"
:align="align"
:flip="flip"
:rotate="rotate"
/>
</template>

View file

@ -0,0 +1 @@
export { default as PositionControlsRoot } from './PositionControlsRoot.vue'

View file

@ -0,0 +1,18 @@
<script setup lang="ts">
import { usePropertyList } from './context'
const { index } = defineProps<{
index: number
}>()
const { update, remove, toggleVisibility } = usePropertyList()
</script>
<template>
<slot
:index="index"
:update="(patch: Record<string, unknown>) => update(index, patch)"
:remove="() => remove(index)"
:toggle-visibility="() => toggleVisibility(index)"
/>
</template>

View file

@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useEditor } from '../context'
import { providePropertyList } from './context'
import type { Fill, Stroke, Effect, SceneNode } from '@open-pencil/core'
type ArrayPropKey = 'fills' | 'strokes' | 'effects'
type ArrayItemType = Fill | Stroke | Effect
const { propKey } = defineProps<{
propKey: ArrayPropKey
label?: string
}>()
const editor = useEditor()
const selectedNodes = computed(() => editor.getSelectedNodes())
const activeNode = computed<SceneNode | null>(() => editor.getSelectedNode() ?? selectedNodes.value[0] ?? null)
const isMulti = computed(() => selectedNodes.value.length > 1)
const active = computed(() => selectedNodes.value.length > 0)
const isMixed = computed(() => {
const all = selectedNodes.value
if (all.length <= 1) return false
const first = JSON.stringify(all[0][propKey])
return all.some((n) => JSON.stringify(n[propKey]) !== first)
})
const items = computed(() => {
if (isMixed.value) return []
return (activeNode.value?.[propKey] ?? []) as ArrayItemType[]
})
function targetNodes(): SceneNode[] {
if (isMulti.value) return selectedNodes.value
return activeNode.value ? [activeNode.value] : []
}
function add(defaults: ArrayItemType) {
for (const n of targetNodes()) {
const arr = isMulti.value ? [defaults] : [...n[propKey], defaults]
editor.updateNodeWithUndo(n.id, { [propKey]: arr } as Partial<SceneNode>, isMulti.value ? `Set ${propKey}` : `Add ${propKey}`)
}
}
function remove(index: number) {
for (const n of targetNodes()) {
editor.updateNodeWithUndo(
n.id,
{ [propKey]: (n[propKey] as ArrayItemType[]).filter((_, i) => i !== index) } as Partial<SceneNode>,
`Remove ${propKey}`
)
}
}
function update(index: number, item: ArrayItemType) {
for (const n of targetNodes()) {
const arr = [...n[propKey]] as ArrayItemType[]
arr[index] = item
editor.updateNodeWithUndo(n.id, { [propKey]: arr } as Partial<SceneNode>, `Change ${propKey}`)
}
}
function patch(index: number, changes: Record<string, unknown>) {
for (const n of targetNodes()) {
const arr = [...n[propKey]] as ArrayItemType[]
arr[index] = { ...arr[index], ...changes } as ArrayItemType
editor.updateNodeWithUndo(n.id, { [propKey]: arr } as Partial<SceneNode>, `Change ${propKey}`)
}
}
function toggleVisibility(index: number) {
for (const n of targetNodes()) {
const arr = n[propKey] as Array<{ visible: boolean }>
if (!arr[index]) continue
const newArr = [...n[propKey]] as Array<{ visible: boolean }>
newArr[index] = { ...newArr[index], visible: !arr[index].visible }
editor.updateNodeWithUndo(n.id, { [propKey]: newArr } as Partial<SceneNode>, `Toggle ${propKey} visibility`)
}
}
providePropertyList({
editor,
propKey,
items: items as ReturnType<typeof computed<unknown[]>>,
isMixed,
activeNode,
isMulti,
add: add as (defaults: unknown) => void,
remove,
update: update as (index: number, item: unknown) => void,
toggleVisibility
})
</script>
<template>
<slot
v-if="active"
:items="items"
:is-mixed="isMixed"
:is-multi="isMulti"
:active-node="activeNode"
:add="add"
:remove="remove"
:update="update"
:patch="patch"
:toggle-visibility="toggleVisibility"
/>
</template>

View file

@ -0,0 +1,32 @@
import { type InjectionKey, inject, provide } from 'vue'
import type { Editor } from '@open-pencil/core/editor'
import type { SceneNode } from '@open-pencil/core'
import type { ComputedRef } from 'vue'
type ArrayPropKey = 'fills' | 'strokes' | 'effects'
export interface PropertyListContext {
editor: Editor
propKey: ArrayPropKey
items: ComputedRef<unknown[]>
isMixed: ComputedRef<boolean>
activeNode: ComputedRef<SceneNode | null>
isMulti: ComputedRef<boolean>
add: (defaults: unknown) => void
remove: (index: number) => void
update: (index: number, item: unknown) => void
toggleVisibility: (index: number) => void
}
export const PROPERTY_LIST_KEY: InjectionKey<PropertyListContext> = Symbol('property-list')
export function providePropertyList(ctx: PropertyListContext) {
provide(PROPERTY_LIST_KEY, ctx)
}
export function usePropertyList(): PropertyListContext {
const ctx = inject(PROPERTY_LIST_KEY)
if (!ctx) throw new Error('[open-pencil] usePropertyList() called outside <PropertyListRoot>')
return ctx
}

View file

@ -0,0 +1,4 @@
export { default as PropertyListRoot } from './PropertyListRoot.vue'
export { default as PropertyListItem } from './PropertyListItem.vue'
export { usePropertyList } from './context'
export type { PropertyListContext } from './context'

View file

@ -27,6 +27,11 @@ export {
export type { ScrubInputContext } from './ScrubInput'
export { CanvasRoot, CanvasSurface, useCanvasContext } from './Canvas'
export { PropertyListRoot, PropertyListItem, usePropertyList } from './PropertyList'
export type { PropertyListContext } from './PropertyList'
export { PositionControlsRoot } from './PositionControls'
export type { CanvasContext } from './Canvas'
export { ToolbarRoot, ToolbarItem, useToolbar } from './Toolbar'

View file

@ -15,45 +15,31 @@ import {
import FillPicker from '@/components/FillPicker.vue'
import ScrubInput from '@/components/ScrubInput.vue'
import { useNodeProps } from '@open-pencil/vue'
import { PropertyListRoot } from '@open-pencil/vue'
import { useEditorStore } from '@/stores/editor'
import { DEFAULT_SHAPE_FILL } from '@/constants'
import { colorToCSS, colorToHexRaw } from '@open-pencil/core'
import type { Fill, Variable } from '@open-pencil/core'
const {
store,
isMulti,
active,
activeNode,
targetNodes,
isArrayMixed,
updateArrayItem,
removeArrayItem,
toggleArrayVisibility
} = useNodeProps()
const fillsAreMixed = computed(() => isArrayMixed('fills'))
const store = useEditorStore()
const colorVariables = computed(() => store.graph.getVariablesByType('COLOR'))
function getBoundVariable(index: number): Variable | undefined {
const n = activeNode.value
function getBoundVariable(nodeId: string, index: number): Variable | undefined {
const n = store.graph.getNode(nodeId)
if (!n) return undefined
const varId = n.boundVariables[`fills/${index}/color`]
return varId ? store.graph.variables.get(varId) : undefined
}
function bindVariable(index: number, variableId: string) {
const n = activeNode.value
if (!n) return
store.graph.bindVariable(n.id, `fills/${index}/color`, variableId)
function bindVariable(nodeId: string, index: number, variableId: string) {
store.graph.bindVariable(nodeId, `fills/${index}/color`, variableId)
store.requestRender()
}
function unbindVariable(index: number) {
const n = activeNode.value
if (!n) return
store.graph.unbindVariable(n.id, `fills/${index}/color`)
function unbindVariable(nodeId: string, index: number) {
store.graph.unbindVariable(nodeId, `fills/${index}/color`)
store.requestRender()
}
@ -63,36 +49,6 @@ function resolvedSwatchStyle(variable: Variable): string {
return `background: ${colorToCSS(color)}`
}
function updateFill(index: number, fill: Fill) {
updateArrayItem('fills', index, fill, 'Change fill')
}
function updateOpacity(index: number, opacity: number) {
updateArrayItem(
'fills',
index,
{ opacity: Math.max(0, Math.min(1, opacity / 100)) },
'Change fill'
)
}
function toggleVisibility(index: number) {
toggleArrayVisibility('fills', index)
}
function add() {
for (const n of targetNodes()) {
const fills = isMulti.value
? [{ ...DEFAULT_SHAPE_FILL }]
: [...n.fills, { ...DEFAULT_SHAPE_FILL }]
store.updateNodeWithUndo(n.id, { fills }, isMulti.value ? 'Set fill' : 'Add fill')
}
}
function remove(index: number) {
removeArrayItem('fills', index, 'Remove fill')
}
const searchTerm = ref('')
const { contains } = useFilter({ sensitivity: 'base' })
const filteredVariables = computed(() => {
@ -102,119 +58,132 @@ const filteredVariables = computed(() => {
</script>
<template>
<div v-if="active" data-test-id="fill-section" class="border-b border-border px-3 py-2">
<div class="flex items-center justify-between">
<label class="mb-1 block text-[11px] text-muted">Fill</label>
<button
data-test-id="fill-section-add"
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-sm leading-none text-muted hover:bg-hover hover:text-surface"
@click="add"
>
+
</button>
</div>
<p v-if="fillsAreMixed" class="text-[11px] text-muted">Click + to replace mixed fills</p>
<div
v-for="(fill, i) in fillsAreMixed ? [] : (activeNode?.fills ?? [])"
:key="i"
data-test-id="fill-item"
:data-test-index="i"
class="group flex items-center gap-1.5 py-0.5"
>
<FillPicker :fill="fill" @update="updateFill(i, $event)" />
<!-- Bound variable indicator or hex value -->
<template v-if="getBoundVariable(i)">
<span
class="min-w-0 flex-1 truncate rounded bg-violet-500/10 px-1 font-mono text-xs text-violet-400"
>
{{ getBoundVariable(i)!.name }}
</span>
<PropertyListRoot
v-slot="{ items, isMixed, activeNode, add, remove, update, patch, toggleVisibility }"
prop-key="fills"
label="Fill"
>
<div data-test-id="fill-section" class="border-b border-border px-3 py-2">
<div class="flex items-center justify-between">
<label class="mb-1 block text-[11px] text-muted">Fill</label>
<button
data-test-id="fill-unbind-variable"
class="cursor-pointer border-none bg-transparent p-0 text-violet-400 hover:text-surface"
title="Detach variable"
@click="unbindVariable(i)"
data-test-id="fill-section-add"
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-sm leading-none text-muted hover:bg-hover hover:text-surface"
@click="add({ ...DEFAULT_SHAPE_FILL })"
>
<icon-lucide-unlink class="size-3" />
+
</button>
</template>
<template v-else>
<span class="min-w-0 flex-1 font-mono text-xs text-surface">
<template v-if="fill.type === 'SOLID'">{{ colorToHexRaw(fill.color) }}</template>
<template v-else-if="fill.type.startsWith('GRADIENT')">{{
fill.type.replace('GRADIENT_', '')
}}</template>
<template v-else>{{ fill.type }}</template>
</span>
</template>
<ScrubInput
class="w-12"
suffix="%"
:model-value="Math.round(fill.opacity * 100)"
:min="0"
:max="100"
@update:model-value="updateOpacity(i, $event)"
/>
<!-- Variable picker -->
<PopoverRoot
v-if="colorVariables.length > 0 && fill.type === 'SOLID' && !getBoundVariable(i)"
</div>
<p v-if="isMixed" class="text-[11px] text-muted">Click + to replace mixed fills</p>
<div
v-for="(fill, i) in items as Fill[]"
:key="i"
data-test-id="fill-item"
:data-test-index="i"
class="group flex items-center gap-1.5 py-0.5"
>
<PopoverTrigger
class="cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
title="Apply variable"
>
<icon-lucide-link class="size-3.5" />
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
side="left"
:side-offset="8"
class="z-50 w-56 rounded-lg border border-border bg-panel shadow-lg"
<FillPicker :fill="fill" @update="update(i, $event)" />
<template v-if="activeNode && getBoundVariable(activeNode.id, i)">
<span
class="min-w-0 flex-1 truncate rounded bg-violet-500/10 px-1 font-mono text-xs text-violet-400"
>
<ComboboxRoot @update:model-value="bindVariable(i, ($event as Variable).id)">
<ComboboxInput
v-model="searchTerm"
placeholder="Search variables…"
class="w-full border-b border-border bg-transparent px-2 py-1.5 text-[11px] text-surface outline-none placeholder:text-muted"
/>
<ComboboxContent class="max-h-48 overflow-y-auto p-1">
<ComboboxEmpty class="px-2 py-3 text-center text-[11px] text-muted">
No variables found
</ComboboxEmpty>
<ComboboxItem
v-for="v in filteredVariables"
:key="v.id"
:value="v"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-[11px] text-surface data-[highlighted]:bg-hover"
>
<div
class="size-3 shrink-0 rounded-sm border border-border"
:style="resolvedSwatchStyle(v)"
/>
<span class="min-w-0 flex-1 truncate">{{ v.name }}</span>
</ComboboxItem>
</ComboboxContent>
</ComboboxRoot>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
{{ getBoundVariable(activeNode.id, i)!.name }}
</span>
<button
data-test-id="fill-unbind-variable"
class="cursor-pointer border-none bg-transparent p-0 text-violet-400 hover:text-surface"
title="Detach variable"
@click="unbindVariable(activeNode.id, i)"
>
<icon-lucide-unlink class="size-3" />
</button>
</template>
<template v-else>
<span class="min-w-0 flex-1 font-mono text-xs text-surface">
<template v-if="fill.type === 'SOLID'">{{ colorToHexRaw(fill.color) }}</template>
<template v-else-if="fill.type.startsWith('GRADIENT')">{{
fill.type.replace('GRADIENT_', '')
}}</template>
<template v-else>{{ fill.type }}</template>
</span>
</template>
<button
class="cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
@click="toggleVisibility(i)"
>
<icon-lucide-eye v-if="fill.visible" class="size-3.5" />
<icon-lucide-eye-off v-else class="size-3.5" />
</button>
<button
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-sm leading-none text-muted hover:bg-hover hover:text-surface"
@click="remove(i)"
>
−
</button>
<ScrubInput
class="w-12"
suffix="%"
:model-value="Math.round(fill.opacity * 100)"
:min="0"
:max="100"
@update:model-value="patch(i, { opacity: Math.max(0, Math.min(1, $event / 100)) })"
/>
<PopoverRoot
v-if="
colorVariables.length > 0 &&
fill.type === 'SOLID' &&
activeNode &&
!getBoundVariable(activeNode.id, i)
"
>
<PopoverTrigger
class="cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
title="Apply variable"
>
<icon-lucide-link class="size-3.5" />
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
side="left"
:side-offset="8"
class="z-50 w-56 rounded-lg border border-border bg-panel shadow-lg"
>
<ComboboxRoot
@update:model-value="
activeNode && bindVariable(activeNode.id, i, ($event as Variable).id)
"
>
<ComboboxInput
v-model="searchTerm"
placeholder="Search variables…"
class="w-full border-b border-border bg-transparent px-2 py-1.5 text-[11px] text-surface outline-none placeholder:text-muted"
/>
<ComboboxContent class="max-h-48 overflow-y-auto p-1">
<ComboboxEmpty class="px-2 py-3 text-center text-[11px] text-muted"
>No variables found</ComboboxEmpty
>
<ComboboxItem
v-for="v in filteredVariables"
:key="v.id"
:value="v"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-[11px] text-surface data-[highlighted]:bg-hover"
>
<div
class="size-3 shrink-0 rounded-sm border border-border"
:style="resolvedSwatchStyle(v)"
/>
<span class="min-w-0 flex-1 truncate">{{ v.name }}</span>
</ComboboxItem>
</ComboboxContent>
</ComboboxRoot>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
<button
class="cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
@click="toggleVisibility(i)"
>
<icon-lucide-eye v-if="fill.visible" class="size-3.5" />
<icon-lucide-eye-off v-else class="size-3.5" />
</button>
<button
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-sm leading-none text-muted hover:bg-hover hover:text-surface"
@click="remove(i)"
>
−
</button>
</div>
</div>
</div>
</PropertyListRoot>
</template>