openpencil/src/components/properties/FillSection.vue

231 lines
7.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, ref } from 'vue'
import {
PopoverRoot,
PopoverTrigger,
PopoverPortal,
PopoverContent,
ComboboxRoot,
ComboboxInput,
ComboboxContent,
ComboboxItem,
ComboboxEmpty,
useFilter
} from 'reka-ui'
import FillPicker from '@/components/FillPicker.vue'
import ScrubInput from '@/components/ScrubInput.vue'
import { useNodeProps } from '@/composables/use-node-props'
import { useMultiProps } from '@/composables/use-multi-props'
import { DEFAULT_SHAPE_FILL } from '@/constants'
import { colorToHexRaw } from '@/engine/color'
import type { Fill, Variable, Color } from '@/engine/scene-graph'
const { store } = useNodeProps()
const { nodes, isMulti, active, activeNode } = useMultiProps()
const fillsAreMixed = computed(() => {
if (!isMulti.value) return false
const all = nodes.value
const first = JSON.stringify(all[0].fills)
return all.some((n) => JSON.stringify(n.fills) !== first)
})
const colorVariables = computed(() => store.graph.getVariablesByType('COLOR'))
function getBoundVariable(index: number): Variable | undefined {
const n = activeNode.value
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)
store.requestRender()
}
function unbindVariable(index: number) {
const n = activeNode.value
if (!n) return
store.graph.unbindVariable(n.id, `fills/${index}/color`)
store.requestRender()
}
function resolvedSwatchStyle(variable: Variable): string {
const color = store.graph.resolveColorVariable(variable.id)
if (!color) return 'background: #000'
return `background: rgb(${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)})`
}
function updateFill(index: number, fill: Fill) {
for (const n of isMulti.value ? nodes.value : [activeNode.value]) {
if (!n) continue
const fills = [...n.fills]
fills[index] = fill
store.updateNodeWithUndo(n.id, { fills }, 'Change fill')
}
}
function updateOpacity(index: number, opacity: number) {
for (const n of isMulti.value ? nodes.value : [activeNode.value]) {
if (!n) continue
const fills = [...n.fills]
fills[index] = { ...fills[index], opacity: Math.max(0, Math.min(1, opacity / 100)) }
store.updateNodeWithUndo(n.id, { fills }, 'Change fill')
}
}
function toggleVisibility(index: number) {
for (const n of isMulti.value ? nodes.value : [activeNode.value]) {
if (!n) continue
const fills = [...n.fills]
fills[index] = { ...fills[index], visible: !fills[index].visible }
store.updateNodeWithUndo(n.id, { fills }, 'Change fill')
}
}
function add() {
if (isMulti.value) {
for (const n of nodes.value) {
store.updateNodeWithUndo(n.id, { fills: [{ ...DEFAULT_SHAPE_FILL }] }, 'Set fill')
}
store.requestRender()
} else {
const n = activeNode.value
if (!n) return
store.updateNodeWithUndo(n.id, { fills: [...n.fills, { ...DEFAULT_SHAPE_FILL }] }, 'Add fill')
}
}
function remove(index: number) {
for (const n of isMulti.value ? nodes.value : [activeNode.value]) {
if (!n) continue
store.updateNodeWithUndo(n.id, { fills: n.fills.filter((_, i) => i !== index) }, 'Remove fill')
}
}
const searchTerm = ref('')
const { contains } = useFilter({ sensitivity: 'base' })
const filteredVariables = computed(() => {
if (!searchTerm.value) return colorVariables.value
return colorVariables.value.filter((v) => contains(v.name, searchTerm.value))
})
</script>
<template>
<div v-if="active" 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
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"
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>
<button
class="cursor-pointer border-none bg-transparent p-0 text-violet-400 hover:text-surface"
title="Detach variable"
@click="unbindVariable(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>
<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)"
>
<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="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>
<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>
</template>