openpencil/src/components/FillPicker.vue
Danila Poyarkov cf454a855e Fill type picker with solid/gradient/image tabs and gradient stop editing
- FillPicker component: 3 category tabs (Solid, Gradient, Image), gradient
  subtype dropdown (Linear/Radial/Angular/Diamond), draggable gradient stop
  bar, per-stop color/position/opacity editing with ScrubInput
- FillSection uses FillPicker instead of ColorPicker for full Fill editing
- Default gradient transforms for each subtype so renderer picks them up
- colorToRgba255/rgba255ToColor helpers in color.ts
- Custom SVG icons for fill type tabs (solid square, gradient fade)
2026-02-28 17:26:50 +03:00

602 lines
21 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 { ref, computed, watch } from 'vue'
import {
PopoverRoot, PopoverTrigger, PopoverPortal, PopoverContent,
SelectRoot, SelectTrigger, SelectValue, SelectPortal, SelectContent, SelectItem, SelectItemText, SelectItemIndicator, SelectViewport,
} from 'reka-ui'
import ScrubInput from './ScrubInput.vue'
import { colorToHexRaw, colorToRgba255, parseColor } from '../engine/color'
import type { Color } from '../types'
import type { Fill, FillType, GradientStop, GradientTransform } from '../engine/scene-graph'
type FillCategory = 'SOLID' | 'GRADIENT' | 'IMAGE'
type GradientSubtype = 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR' | 'GRADIENT_DIAMOND'
const GRADIENT_SUBTYPES: { type: GradientSubtype; label: string }[] = [
{ type: 'GRADIENT_LINEAR', label: 'Linear' },
{ type: 'GRADIENT_RADIAL', label: 'Radial' },
{ type: 'GRADIENT_ANGULAR', label: 'Angular' },
{ type: 'GRADIENT_DIAMOND', label: 'Diamond' },
]
const DEFAULT_GRADIENT_TRANSFORMS: Record<GradientSubtype, GradientTransform> = {
GRADIENT_LINEAR: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 0, m12: 0.5 },
GRADIENT_RADIAL: { m00: 0.5, m01: 0, m02: 0.5, m10: 0, m11: 0.5, m12: 0.5 },
GRADIENT_ANGULAR: { m00: 0.5, m01: 0, m02: 0.5, m10: 0, m11: 0.5, m12: 0.5 },
GRADIENT_DIAMOND: { m00: 0.5, m01: 0, m02: 0.5, m10: 0, m11: 0.5, m12: 0.5 },
}
const props = defineProps<{
fill: Fill
}>()
const emit = defineEmits<{
update: [fill: Fill]
}>()
const activeStopIndex = ref(0)
const fillCategory = computed<FillCategory>(() => {
if (props.fill.type.startsWith('GRADIENT')) return 'GRADIENT'
if (props.fill.type === 'IMAGE') return 'IMAGE'
return 'SOLID'
})
const isGradient = computed(() => fillCategory.value === 'GRADIENT')
const gradientSubtype = computed(() =>
isGradient.value ? props.fill.type as GradientSubtype : 'GRADIENT_LINEAR'
)
const activeColor = computed(() => {
if (isGradient.value && props.fill.gradientStops?.length) {
const idx = Math.min(activeStopIndex.value, props.fill.gradientStops.length - 1)
return props.fill.gradientStops[idx].color
}
return props.fill.color
})
const hue = ref(0)
const saturation = ref(100)
const brightness = ref(100)
const alpha = ref(1)
function rgbToHsv(r: number, g: number, b: number) {
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const d = max - min
let h = 0
const s = max === 0 ? 0 : d / max
const v = max
if (d !== 0) {
if (max === r) h = ((g - b) / d + 6) % 6
else if (max === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
h *= 60
}
return { h, s: s * 100, v: v * 100 }
}
function hsvToRgb(h: number, s: number, v: number) {
s /= 100
v /= 100
const c = v * s
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
const m = v - c
let r = 0
let g = 0
let b = 0
if (h < 60) [r, g, b] = [c, x, 0]
else if (h < 120) [r, g, b] = [x, c, 0]
else if (h < 180) [r, g, b] = [0, c, x]
else if (h < 240) [r, g, b] = [0, x, c]
else if (h < 300) [r, g, b] = [x, 0, c]
else [r, g, b] = [c, 0, x]
return { r: r + m, g: g + m, b: b + m }
}
function syncFromColor(c: Color) {
const hsv = rgbToHsv(c.r, c.g, c.b)
hue.value = hsv.h
saturation.value = hsv.s
brightness.value = hsv.v
alpha.value = c.a
}
watch(activeColor, syncFromColor, { immediate: true })
function currentRgb(): Color {
const rgb = hsvToRgb(hue.value, saturation.value, brightness.value)
return { r: rgb.r, g: rgb.g, b: rgb.b, a: alpha.value }
}
function emitColor() {
const color = currentRgb()
if (isGradient.value && props.fill.gradientStops?.length) {
const stops = [...props.fill.gradientStops]
const idx = Math.min(activeStopIndex.value, stops.length - 1)
stops[idx] = { ...stops[idx], color }
emit('update', { ...props.fill, gradientStops: stops })
} else {
emit('update', { ...props.fill, color })
}
}
function setCategory(cat: FillCategory) {
if (cat === fillCategory.value) return
if (cat === 'SOLID') {
const color = props.fill.gradientStops?.length
? { ...props.fill.gradientStops[0].color }
: props.fill.color
emit('update', { ...props.fill, type: 'SOLID', color })
} else if (cat === 'GRADIENT') {
const type: GradientSubtype = 'GRADIENT_LINEAR'
const stops = props.fill.gradientStops?.length
? props.fill.gradientStops
: [
{ color: { ...props.fill.color }, position: 0 },
{ color: { r: 1, g: 1, b: 1, a: 1 }, position: 1 },
]
emit('update', {
...props.fill,
type,
gradientStops: stops,
gradientTransform: DEFAULT_GRADIENT_TRANSFORMS[type],
})
activeStopIndex.value = 0
} else {
emit('update', { ...props.fill, type: 'IMAGE' })
}
}
function setGradientSubtype(type: string) {
const subtype = type as GradientSubtype
if (subtype === props.fill.type) return
emit('update', {
...props.fill,
type: subtype,
gradientTransform: DEFAULT_GRADIENT_TRANSFORMS[subtype],
})
}
function selectStop(index: number) {
activeStopIndex.value = index
if (props.fill.gradientStops?.[index]) {
syncFromColor(props.fill.gradientStops[index].color)
}
}
function addStop() {
if (!props.fill.gradientStops) return
const stops = [...props.fill.gradientStops]
const newPos = stops.length >= 2
? (stops[stops.length - 2].position + stops[stops.length - 1].position) / 2
: 0.5
const color = currentRgb()
stops.push({ color, position: newPos })
stops.sort((a, b) => a.position - b.position)
const newIndex = stops.findIndex(s => s.position === newPos)
activeStopIndex.value = newIndex
emit('update', { ...props.fill, gradientStops: stops })
}
function removeStop(index: number) {
if (!props.fill.gradientStops || props.fill.gradientStops.length <= 2) return
const stops = props.fill.gradientStops.filter((_, i) => i !== index)
activeStopIndex.value = Math.min(activeStopIndex.value, stops.length - 1)
emit('update', { ...props.fill, gradientStops: stops })
}
function updateStopPosition(index: number, value: string) {
if (!props.fill.gradientStops) return
const pos = Math.max(0, Math.min(100, Number(value))) / 100
const stops = [...props.fill.gradientStops]
stops[index] = { ...stops[index], position: pos }
emit('update', { ...props.fill, gradientStops: stops })
}
function updateStopColor(index: number, hex: string) {
if (!props.fill.gradientStops) return
const color = parseColor(hex.startsWith('#') ? hex : `#${hex}`)
if (!color) return
const stops = [...props.fill.gradientStops]
stops[index] = { ...stops[index], color: { ...color, a: stops[index].color.a } }
emit('update', { ...props.fill, gradientStops: stops })
}
function updateStopOpacity(index: number, value: string) {
if (!props.fill.gradientStops) return
const a = Math.max(0, Math.min(100, Number(value))) / 100
const stops = [...props.fill.gradientStops]
stops[index] = { ...stops[index], color: { ...stops[index].color, a } }
emit('update', { ...props.fill, gradientStops: stops })
}
const hexValue = computed(() => colorToHexRaw(activeColor.value))
function onHexInput(e: Event) {
const input = (e.target as HTMLInputElement).value.replace('#', '')
if (input.length !== 6) return
const parsed = parseColor(`#${input}`)
const rgb: Color = { ...parsed, a: alpha.value }
if (isGradient.value && props.fill.gradientStops?.length) {
const stops = [...props.fill.gradientStops]
const idx = Math.min(activeStopIndex.value, stops.length - 1)
stops[idx] = { ...stops[idx], color: rgb }
emit('update', { ...props.fill, gradientStops: stops })
} else {
emit('update', { ...props.fill, color: rgb })
}
syncFromColor(rgb)
}
const svAreaRef = ref<HTMLDivElement | null>(null)
function onSvPointerDown(e: PointerEvent) {
const el = svAreaRef.value
if (!el) return
el.setPointerCapture(e.pointerId)
updateSv(e)
}
function onSvPointerMove(e: PointerEvent) {
const el = svAreaRef.value
if (!el || !el.hasPointerCapture(e.pointerId)) return
updateSv(e)
}
function updateSv(e: PointerEvent) {
const el = svAreaRef.value
if (!el) return
const rect = el.getBoundingClientRect()
saturation.value = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100))
brightness.value = Math.max(0, Math.min(100, 100 - ((e.clientY - rect.top) / rect.height) * 100))
emitColor()
}
function onHueInput(e: Event) {
hue.value = +(e.target as HTMLInputElement).value
emitColor()
}
function onAlphaSliderInput(e: Event) {
alpha.value = +(e.target as HTMLInputElement).value / 100
emitColor()
}
function onAlphaNumberInput(e: Event) {
alpha.value = Math.max(0, Math.min(1, +(e.target as HTMLInputElement).value / 100))
emitColor()
}
const hueColor = computed(() => {
const rgb = hsvToRgb(hue.value, 100, 100)
return `rgb(${Math.round(rgb.r * 255)}, ${Math.round(rgb.g * 255)}, ${Math.round(rgb.b * 255)})`
})
const swatchBackground = computed(() => {
if (isGradient.value && props.fill.gradientStops?.length) {
const stops = props.fill.gradientStops
.map(s => {
const rgba = colorToRgba255(s.color)
return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${s.color.a}) ${s.position * 100}%`
})
.join(', ')
return `linear-gradient(to right, ${stops})`
}
const rgba = colorToRgba255(props.fill.color)
return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${props.fill.color.a})`
})
const gradientBarBackground = computed(() => {
if (!props.fill.gradientStops?.length) return ''
const stops = props.fill.gradientStops
.map(s => {
const rgba = colorToRgba255(s.color)
return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${s.color.a}) ${s.position * 100}%`
})
.join(', ')
return `linear-gradient(to right, ${stops})`
})
const gradientStopBarRef = ref<HTMLDivElement | null>(null)
const draggingStopIndex = ref<number | null>(null)
function onStopPointerDown(index: number, e: PointerEvent) {
selectStop(index)
draggingStopIndex.value = index
const el = gradientStopBarRef.value
if (el) el.setPointerCapture(e.pointerId)
}
function onStopBarPointerMove(e: PointerEvent) {
const el = gradientStopBarRef.value
if (!el || draggingStopIndex.value === null || !el.hasPointerCapture(e.pointerId)) return
const rect = el.getBoundingClientRect()
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const stops = [...(props.fill.gradientStops ?? [])]
stops[draggingStopIndex.value] = { ...stops[draggingStopIndex.value], position: pos }
emit('update', { ...props.fill, gradientStops: stops })
}
function onStopBarPointerUp() {
draggingStopIndex.value = null
}
function stopSwatchColor(stop: GradientStop) {
const rgba = colorToRgba255(stop.color)
return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${stop.color.a})`
}
</script>
<template>
<PopoverRoot>
<PopoverTrigger as-child>
<button
class="size-5 shrink-0 cursor-pointer rounded border border-border p-0"
:style="{ background: swatchBackground }"
/>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
class="z-[100] w-60 rounded-lg border border-border bg-panel p-2 shadow-xl"
:side-offset="4"
side="left"
>
<!-- Fill category tabs: Solid | Gradient | Image -->
<div class="mb-2 flex items-center gap-0.5">
<button
class="flex size-6 cursor-pointer items-center justify-center rounded border-none p-0 text-muted transition-colors hover:bg-hover hover:text-surface"
:class="{ 'bg-hover text-surface': fillCategory === 'SOLID' }"
title="Solid"
@click="setCategory('SOLID')"
>
<svg class="size-3.5" viewBox="0 0 16 16"><rect x="2" y="2" width="12" height="12" rx="2" fill="currentColor" /></svg>
</button>
<button
class="flex size-6 cursor-pointer items-center justify-center rounded border-none p-0 text-muted transition-colors hover:bg-hover hover:text-surface"
:class="{ 'bg-hover text-surface': fillCategory === 'GRADIENT' }"
title="Gradient"
@click="setCategory('GRADIENT')"
>
<svg class="size-3.5" viewBox="0 0 16 16"><defs><linearGradient id="gl"><stop offset="0" stop-color="currentColor" /><stop offset="1" stop-color="currentColor" stop-opacity="0" /></linearGradient></defs><rect x="2" y="2" width="12" height="12" rx="2" fill="url(#gl)" /></svg>
</button>
<button
class="flex size-6 cursor-pointer items-center justify-center rounded border-none p-0 text-muted transition-colors hover:bg-hover hover:text-surface"
:class="{ 'bg-hover text-surface': fillCategory === 'IMAGE' }"
title="Image"
@click="setCategory('IMAGE')"
>
<icon-lucide-image class="size-3.5" />
</button>
</div>
<!-- Gradient subtype dropdown -->
<div v-if="isGradient" class="mb-2">
<SelectRoot :model-value="gradientSubtype" @update:model-value="setGradientSubtype">
<SelectTrigger class="flex h-7 w-28 cursor-pointer items-center justify-between rounded border border-border bg-input px-2 text-xs text-surface">
<SelectValue />
<icon-lucide-chevron-down class="size-3 text-muted" />
</SelectTrigger>
<SelectPortal>
<SelectContent class="z-[200] min-w-[112px] rounded-md border border-border bg-panel py-1 shadow-xl" position="popper" side="bottom" :side-offset="4" :align="'start'">
<SelectViewport>
<SelectItem
v-for="sub in GRADIENT_SUBTYPES"
:key="sub.type"
:value="sub.type"
class="relative flex cursor-pointer items-center rounded py-1 pr-2 pl-6 text-xs text-surface outline-none data-[highlighted]:bg-accent data-[highlighted]:text-white"
>
<SelectItemIndicator class="absolute left-1.5">
<icon-lucide-check class="size-3" />
</SelectItemIndicator>
<SelectItemText>{{ sub.label }}</SelectItemText>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</div>
<!-- Gradient stop bar -->
<div
v-if="isGradient && fill.gradientStops?.length"
ref="gradientStopBarRef"
class="relative mb-2 h-6 rounded"
:style="{ background: gradientBarBackground }"
@pointermove="onStopBarPointerMove"
@pointerup="onStopBarPointerUp"
>
<div
v-for="(stop, idx) in fill.gradientStops"
:key="idx"
class="absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-sm border-2 shadow-sm"
:class="idx === activeStopIndex ? 'border-white' : 'border-white/60'"
:style="{
left: `${stop.position * 100}%`,
background: stopSwatchColor(stop),
}"
@pointerdown.stop="onStopPointerDown(idx, $event)"
/>
</div>
<!-- Gradient stops list -->
<div v-if="isGradient && fill.gradientStops?.length" class="mb-2">
<div class="mb-1 flex items-center justify-between">
<span class="text-[11px] text-muted">Stops</span>
<button
class="flex size-4 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted hover:text-surface"
title="Add stop"
@click="addStop"
>
<icon-lucide-plus class="size-3" />
</button>
</div>
<div
v-for="(stop, idx) in fill.gradientStops"
:key="idx"
class="flex items-center gap-1 py-0.5"
:class="{ 'rounded bg-hover/50': idx === activeStopIndex }"
@click="selectStop(idx)"
>
<ScrubInput
class="w-11"
suffix="%"
:model-value="Math.round(stop.position * 100)"
:min="0"
:max="100"
@update:model-value="updateStopPosition(idx, String($event))"
@click.stop
/>
<button
class="size-4 shrink-0 cursor-pointer rounded border border-border p-0"
:style="{ background: stopSwatchColor(stop) }"
@click.stop="selectStop(idx)"
/>
<input
class="min-w-0 flex-1 rounded border border-border bg-input px-1 py-0.5 font-mono text-[11px] text-surface"
:value="colorToHexRaw(stop.color)"
maxlength="6"
@change="updateStopColor(idx, ($event.target as HTMLInputElement).value)"
@click.stop
/>
<ScrubInput
class="w-9"
suffix="%"
:model-value="Math.round(stop.color.a * 100)"
:min="0"
:max="100"
@update:model-value="updateStopOpacity(idx, String($event))"
@click.stop
/>
<button
v-if="(fill.gradientStops?.length ?? 0) > 2"
class="flex size-4 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted hover:text-surface"
@click.stop="removeStop(idx)"
>−</button>
</div>
</div>
<!-- Image placeholder -->
<div
v-if="fill.type === 'IMAGE'"
class="flex h-24 items-center justify-center rounded border border-dashed border-border text-xs text-muted"
>
Image fill (coming soon)
</div>
<!-- HSV color area (solid mode, or editing active gradient stop) -->
<template v-if="fill.type !== 'IMAGE'">
<div
ref="svAreaRef"
class="relative h-[140px] w-full cursor-crosshair overflow-hidden rounded"
:style="{ background: hueColor }"
@pointerdown="onSvPointerDown"
@pointermove="onSvPointerMove"
>
<div class="absolute inset-0 bg-gradient-to-r from-white to-transparent" />
<div class="absolute inset-0 bg-gradient-to-t from-black to-transparent" />
<div
class="pointer-events-none absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-sm"
:style="{ left: `${saturation}%`, top: `${100 - brightness}%` }"
/>
</div>
<!-- Hue slider -->
<div class="mt-2">
<input type="range" class="hue-slider" :value="hue" min="0" max="360" @input="onHueInput" />
</div>
<!-- Alpha slider -->
<div class="alpha-wrap mt-2">
<div class="alpha-gradient" :style="{ background: `linear-gradient(to right, transparent, ${hueColor})` }" />
<input type="range" class="alpha-slider" :value="alpha * 100" min="0" max="100" @input="onAlphaSliderInput" />
</div>
<!-- Hex input -->
<div class="mt-2 flex items-center gap-1">
<span class="text-[11px] text-muted">#</span>
<input
type="text"
class="min-w-0 flex-1 rounded border border-border bg-input px-1.5 py-0.5 font-mono text-xs text-surface"
:value="hexValue"
maxlength="6"
@change="onHexInput"
/>
<input
type="number"
class="w-10 rounded border border-border bg-input px-1 py-0.5 text-right text-xs text-surface [&::-webkit-inner-spin-button]:hidden"
:value="Math.round(alpha * 100)"
min="0"
max="100"
@change="onAlphaNumberInput"
/>
<span class="text-[11px] text-muted">%</span>
</div>
</template>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
<style scoped>
.hue-slider,
.alpha-slider {
width: 100%;
height: 12px;
-webkit-appearance: none;
appearance: none;
border-radius: 6px;
outline: none;
}
.hue-slider {
background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);
}
.alpha-wrap {
position: relative;
height: 12px;
border-radius: 6px;
background-image:
linear-gradient(45deg, #444 25%, transparent 25%),
linear-gradient(-45deg, #444 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #444 75%),
linear-gradient(-45deg, transparent 75%, #444 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0;
background-color: #333;
}
.alpha-gradient {
position: absolute;
inset: 0;
border-radius: 6px;
}
.alpha-slider {
position: absolute;
inset: 0;
background: transparent;
}
.hue-slider::-webkit-slider-thumb,
.alpha-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: white;
border: 2px solid white;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
cursor: pointer;
}
</style>