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)
This commit is contained in:
Danila Poyarkov 2026-02-28 17:26:50 +03:00
parent 07f0f559da
commit cf454a855e
4 changed files with 631 additions and 26 deletions

6
components.d.ts vendored
View file

@ -14,6 +14,7 @@ declare module 'vue' {
AppearanceSection: typeof import('./src/components/properties/AppearanceSection.vue')['default']
ColorPicker: typeof import('./src/components/ColorPicker.vue')['default']
EditorCanvas: typeof import('./src/components/EditorCanvas.vue')['default']
FillPicker: typeof import('./src/components/FillPicker.vue')['default']
FillSection: typeof import('./src/components/properties/FillSection.vue')['default']
IconLucideALargeSmall: typeof import('~icons/lucide/a-large-small')['default']
IconLucideAlignCenter: typeof import('~icons/lucide/align-center')['default']
@ -25,21 +26,26 @@ declare module 'vue' {
IconLucideAlignVerticalJustifyCenter: typeof import('~icons/lucide/align-vertical-justify-center')['default']
IconLucideAlignVerticalJustifyEnd: typeof import('~icons/lucide/align-vertical-justify-end')['default']
IconLucideAlignVerticalJustifyStart: typeof import('~icons/lucide/align-vertical-justify-start')['default']
IconLucideArrowRight: typeof import('~icons/lucide/arrow-right')['default']
IconLucideBaseline: typeof import('~icons/lucide/baseline')['default']
IconLucideBlend: typeof import('~icons/lucide/blend')['default']
IconLucideCheck: typeof import('~icons/lucide/check')['default']
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideCircle: typeof import('~icons/lucide/circle')['default']
IconLucideDiamond: typeof import('~icons/lucide/diamond')['default']
IconLucideEye: typeof import('~icons/lucide/eye')['default']
IconLucideEyeOff: typeof import('~icons/lucide/eye-off')['default']
IconLucideFile: typeof import('~icons/lucide/file')['default']
IconLucideFlipHorizontal: typeof import('~icons/lucide/flip-horizontal')['default']
IconLucideFlipVertical: typeof import('~icons/lucide/flip-vertical')['default']
IconLucideImage: typeof import('~icons/lucide/image')['default']
IconLucidePlus: typeof import('~icons/lucide/plus')['default']
IconLucideRadius: typeof import('~icons/lucide/radius')['default']
IconLucideRotateCcw: typeof import('~icons/lucide/rotate-ccw')['default']
IconLucideRotateCw: typeof import('~icons/lucide/rotate-cw')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideSquare: typeof import('~icons/lucide/square')['default']
LayersPanel: typeof import('./src/components/LayersPanel.vue')['default']
LayoutSection: typeof import('./src/components/properties/LayoutSection.vue')['default']
PageSection: typeof import('./src/components/properties/PageSection.vue')['default']

View file

@ -0,0 +1,601 @@
<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>

View file

@ -1,26 +1,20 @@
<script setup lang="ts">
import ColorPicker from '../ColorPicker.vue'
import FillPicker from '../FillPicker.vue'
import ScrubInput from '../ScrubInput.vue'
import { useNodeProps } from '../../composables/use-node-props'
import { DEFAULT_SHAPE_FILL } from '../../constants'
import { colorToHexRaw, parseColor } from '../../engine/color'
import { colorToHexRaw } from '../../engine/color'
import type { Color } from '../../engine/scene-graph'
import type { Fill } from '../../engine/scene-graph'
const { store, node } = useNodeProps()
function updateColor(index: number, color: Color) {
function updateFill(index: number, fill: Fill) {
const fills = [...node.value.fills]
fills[index] = { ...fills[index], color }
fills[index] = fill
store.updateNodeWithUndo(node.value.id, { fills }, 'Change fill')
}
function updateHex(index: number, hex: string) {
const color = parseColor(hex.startsWith('#') ? hex : `#${hex}`)
if (!color) return
updateColor(index, color)
}
function updateOpacity(index: number, opacity: number) {
const fills = [...node.value.fills]
fills[index] = { ...fills[index], opacity: Math.max(0, Math.min(1, opacity / 100)) }
@ -49,21 +43,12 @@ function remove(index: number) {
<button class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface" @click="add">+</button>
</div>
<div v-for="(fill, i) in node.fills" :key="i" class="group flex items-center gap-1.5 py-0.5">
<template v-if="fill.type === 'SOLID'">
<ColorPicker :color="fill.color" @update="updateColor(i, $event)" />
<input
class="min-w-0 flex-1 border-none bg-transparent font-mono text-xs text-surface outline-none"
:value="colorToHexRaw(fill.color)"
@change="updateHex(i, ($event.target as HTMLInputElement).value)"
/>
</template>
<template v-else>
<div class="flex h-5 w-5 items-center justify-center rounded border border-border">
<icon-lucide-blend v-if="fill.type.startsWith('GRADIENT')" class="size-3 text-muted" />
<icon-lucide-image v-else-if="fill.type === 'IMAGE'" class="size-3 text-muted" />
</div>
<span class="min-w-0 flex-1 text-xs text-muted">{{ fill.type.replace('GRADIENT_', '').replace('_', ' ') }}</span>
</template>
<FillPicker :fill="fill" @update="updateFill(i, $event)" />
<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>
<ScrubInput
class="w-12"
suffix="%"

View file

@ -24,6 +24,19 @@ export function colorToHexRaw(color: Color): string {
return colorToHex(color).slice(1)
}
export function colorToRgba255(color: Color) {
return {
r: Math.round(color.r * 255),
g: Math.round(color.g * 255),
b: Math.round(color.b * 255),
a: color.a
}
}
export function rgba255ToColor(rgba: { r: number; g: number; b: number; a: number }): Color {
return { r: rgba.r / 255, g: rgba.g / 255, b: rgba.b / 255, a: rgba.a }
}
export function colorToFill(color: string | Color) {
const rgba = typeof color === 'string' ? parseColor(color) : color
return {