openpencil/src/components/properties/FillSection.vue
Danila Poyarkov 9784544f1f Clean up tools/schema, add geometry.ts, isMixed helper, fix imports, add tests
- Strip tools/schema.ts from 1831 to 65 lines (remove duplicate tool defs)
- Fix tools/index.ts to export ALL_TOOLS from registry instead of schema
- Fix renderJsx → renderJSX typo in tools/create.ts and structure.ts
- Add geometry.ts with shared rotation/coordinate math utilities
- Add isArrayMixed() helper to use-multi-props.ts, deduplicate FillSection/StrokeSection/EffectsSection
- Fix broken test imports: use @open-pencil/core instead of relative paths into packages/core/src/
- Merge duplicate import statements across all test files
- Export profiler internals and style-run helpers from core index.ts
- Add 6 new test files: color, undo, snap, vector, style-runs, text-editor (98 new tests)
- Update renderer-effects test for renderer/ directory split
2026-03-06 22:33:10 +03:00

230 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 { colorToCSS, colorToHexRaw } from '@open-pencil/core'
import type { Fill, Variable, Color } from '@open-pencil/core'
const { store } = useNodeProps()
const { nodes, isMulti, active, activeNode, isArrayMixed } = useMultiProps()
const fillsAreMixed = computed(() => isArrayMixed('fills'))
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: ${colorToCSS(color)}`
}
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" 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>
<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)"
>
<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>