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:
parent
46e93a1b70
commit
638db38589
64
packages/vue/src/PositionControls/PositionControlsRoot.vue
Normal file
64
packages/vue/src/PositionControls/PositionControlsRoot.vue
Normal 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>
|
||||
1
packages/vue/src/PositionControls/index.ts
Normal file
1
packages/vue/src/PositionControls/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as PositionControlsRoot } from './PositionControlsRoot.vue'
|
||||
18
packages/vue/src/PropertyList/PropertyListItem.vue
Normal file
18
packages/vue/src/PropertyList/PropertyListItem.vue
Normal 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>
|
||||
111
packages/vue/src/PropertyList/PropertyListRoot.vue
Normal file
111
packages/vue/src/PropertyList/PropertyListRoot.vue
Normal 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>
|
||||
32
packages/vue/src/PropertyList/context.ts
Normal file
32
packages/vue/src/PropertyList/context.ts
Normal 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
|
||||
}
|
||||
4
packages/vue/src/PropertyList/index.ts
Normal file
4
packages/vue/src/PropertyList/index.ts
Normal 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'
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue