Replace custom color picker with reka-ui Color components
Upgrade reka-ui 2.8.2 → 2.9.0 and replace the hand-rolled HSV color area, hue slider, alpha slider, and hex input with reka-ui's new ColorArea, ColorSlider, and ColorField primitives. - ColorAreaRoot + ColorAreaArea handle the 2D saturation/brightness picker with proper gradient backgrounds and keyboard navigation - ColorSliderRoot + ColorSliderTrack for hue and alpha channels with automatic gradient rendering - ColorFieldRoot + ColorFieldInput for hex color text input with arrow key increment/decrement support - Bridge between our Color type (0–1 range) and reka-ui's Color (0–255 RGB) via hex string serialization Removes ~110 lines of custom HSV↔RGB conversion math, pointer capture handling, and CSS gradient computation.
This commit is contained in:
parent
d55eb7ce9e
commit
b3e1bbdb09
10
bun.lock
10
bun.lock
|
|
@ -26,7 +26,7 @@
|
|||
"fzstd": "^0.1.1",
|
||||
"lib0": "^0.2.117",
|
||||
"prismjs": "^1.30.0",
|
||||
"reka-ui": "^2.8.2",
|
||||
"reka-ui": "^2.9.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"trystero": "^0.22.0",
|
||||
"valibot": "^1.2.0",
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
},
|
||||
"packages/cli": {
|
||||
"name": "@open-pencil/cli",
|
||||
"version": "0.4.2",
|
||||
"version": "0.6.0",
|
||||
"bin": {
|
||||
"openpencil": "./src/index.ts",
|
||||
},
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
},
|
||||
"packages/core": {
|
||||
"name": "@open-pencil/core",
|
||||
"version": "0.4.2",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
"culori": "^4.0.2",
|
||||
|
|
@ -99,7 +99,7 @@
|
|||
},
|
||||
"packages/mcp": {
|
||||
"name": "@open-pencil/mcp",
|
||||
"version": "0.4.2",
|
||||
"version": "0.6.0",
|
||||
"bin": {
|
||||
"openpencil-mcp": "./dist/index.js",
|
||||
"openpencil-mcp-http": "./dist/http.js",
|
||||
|
|
@ -1590,7 +1590,7 @@
|
|||
|
||||
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
|
||||
|
||||
"reka-ui": ["reka-ui@2.8.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-8lTKcJhmG+D3UyJxhBnNnW/720sLzm0pbA9AC1MWazmJ5YchJAyTSl+O00xP/kxBmEN0fw5JqWVHguiFmsGjzA=="],
|
||||
"reka-ui": ["reka-ui@2.9.0", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.4.0" } }, "sha512-5dpp80u109iLTbRBu+jhAk8R/877/JN20gYGjb3GsuAgS7E/5QTX5ZxuzWtZAVbChBDYDpXc8pkaQAFpa6s+4w=="],
|
||||
|
||||
"repeat-string": ["repeat-string@1.6.1", "", {}, "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
"fzstd": "^0.1.1",
|
||||
"lib0": "^0.2.117",
|
||||
"prismjs": "^1.30.0",
|
||||
"reka-ui": "^2.8.2",
|
||||
"reka-ui": "^2.9.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"trystero": "^0.22.0",
|
||||
"valibot": "^1.2.0",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
ColorAreaRoot,
|
||||
ColorAreaArea,
|
||||
ColorAreaThumb,
|
||||
ColorSliderRoot,
|
||||
ColorSliderTrack,
|
||||
ColorSliderThumb,
|
||||
ColorFieldRoot,
|
||||
ColorFieldInput,
|
||||
normalizeColor,
|
||||
convertToRgb,
|
||||
type Color as RekaColor
|
||||
} from 'reka-ui'
|
||||
|
||||
import { colorToHexRaw, parseColor } from '@/engine/color'
|
||||
import { colorToHex } from '@/engine/color'
|
||||
|
||||
import type { Color } from '@/types'
|
||||
|
||||
|
|
@ -13,208 +26,112 @@ const emit = defineEmits<{
|
|||
update: [color: 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
|
||||
const hexWithAlpha = computed(() => {
|
||||
const hex = colorToHex(props.color)
|
||||
if (props.color.a < 1) {
|
||||
const aa = Math.round(props.color.a * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return `${hex}${aa}`
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.color,
|
||||
(c) => {
|
||||
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
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function emitColor() {
|
||||
const rgb = hsvToRgb(hue.value, saturation.value, brightness.value)
|
||||
emit('update', { r: rgb.r, g: rgb.g, b: rgb.b, a: alpha.value })
|
||||
}
|
||||
|
||||
const hexValue = computed(() => colorToHexRaw(props.color))
|
||||
|
||||
function onHexInput(e: Event) {
|
||||
const input = (e.target as HTMLInputElement).value.replace('#', '')
|
||||
if (input.length !== 6) return
|
||||
const parsed = parseColor(`#${input}`)
|
||||
const color: Color = { ...parsed, a: alpha.value }
|
||||
emit('update', color)
|
||||
}
|
||||
|
||||
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)})`
|
||||
return hex
|
||||
})
|
||||
|
||||
const rekaColor = computed(() => normalizeColor(hexWithAlpha.value))
|
||||
|
||||
function rekaToColor(c: RekaColor): Color {
|
||||
const rgb = convertToRgb(c)
|
||||
return { r: rgb.r / 255, g: rgb.g / 255, b: rgb.b / 255, a: rgb.alpha }
|
||||
}
|
||||
|
||||
function onRekaColorUpdate(c: RekaColor) {
|
||||
emit('update', rekaToColor(c))
|
||||
}
|
||||
|
||||
function onHexUpdate(hex: string) {
|
||||
emit('update', rekaToColor(normalizeColor(hex)))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- SV area -->
|
||||
<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>
|
||||
<div class="flex flex-col gap-2">
|
||||
<ColorAreaRoot
|
||||
v-slot="{ style }"
|
||||
:model-value="rekaColor"
|
||||
color-space="hsb"
|
||||
x-channel="saturation"
|
||||
y-channel="brightness"
|
||||
@update:color="onRekaColorUpdate"
|
||||
>
|
||||
<ColorAreaArea
|
||||
class="relative h-[140px] w-full cursor-crosshair overflow-hidden rounded"
|
||||
:style="style"
|
||||
>
|
||||
<ColorAreaThumb
|
||||
class="pointer-events-none absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-sm"
|
||||
/>
|
||||
</ColorAreaArea>
|
||||
</ColorAreaRoot>
|
||||
|
||||
<!-- Hue slider -->
|
||||
<div class="mt-2">
|
||||
<input type="range" class="hue-slider" :value="hue" min="0" max="360" @input="onHueInput" />
|
||||
</div>
|
||||
<ColorSliderRoot
|
||||
:model-value="rekaColor"
|
||||
channel="hue"
|
||||
color-space="hsb"
|
||||
class="relative flex h-3 w-full items-center"
|
||||
@update:color="onRekaColorUpdate"
|
||||
>
|
||||
<ColorSliderTrack class="h-full w-full rounded-md" />
|
||||
<ColorSliderThumb
|
||||
class="absolute size-3.5 cursor-pointer rounded-full border-2 border-white shadow-sm"
|
||||
/>
|
||||
</ColorSliderRoot>
|
||||
|
||||
<!-- 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>
|
||||
<div class="checkerboard relative h-3 w-full rounded-md">
|
||||
<ColorSliderRoot
|
||||
:model-value="rekaColor"
|
||||
channel="alpha"
|
||||
color-space="hsb"
|
||||
class="absolute inset-0 flex items-center"
|
||||
@update:color="onRekaColorUpdate"
|
||||
>
|
||||
<ColorSliderTrack class="h-full w-full rounded-md" />
|
||||
<ColorSliderThumb
|
||||
class="absolute size-3.5 cursor-pointer rounded-full border-2 border-white shadow-sm"
|
||||
/>
|
||||
</ColorSliderRoot>
|
||||
</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"
|
||||
:value="Math.round(alpha * 100)"
|
||||
min="0"
|
||||
max="100"
|
||||
@change="onAlphaNumberInput"
|
||||
/>
|
||||
<span class="text-[11px] text-muted">%</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-[11px] text-muted">#</span>
|
||||
<ColorFieldRoot
|
||||
:model-value="hexWithAlpha"
|
||||
class="min-w-0 flex-1"
|
||||
@update:model-value="onHexUpdate"
|
||||
>
|
||||
<ColorFieldInput
|
||||
class="w-full rounded border border-border bg-input px-1.5 py-0.5 font-mono text-xs text-surface"
|
||||
/>
|
||||
</ColorFieldRoot>
|
||||
<input
|
||||
type="number"
|
||||
class="w-10 rounded border border-border bg-input px-1 py-0.5 text-right text-xs text-surface"
|
||||
:value="Math.round(color.a * 100)"
|
||||
min="0"
|
||||
max="100"
|
||||
@change="
|
||||
emit('update', {
|
||||
...color,
|
||||
a: Math.max(0, Math.min(1, +($event.target as HTMLInputElement).value / 100))
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span class="text-[11px] text-muted">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</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;
|
||||
.checkerboard {
|
||||
background-image:
|
||||
linear-gradient(45deg, #444 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #444 25%, transparent 25%),
|
||||
|
|
@ -228,28 +145,4 @@ const hueColor = computed(() => {
|
|||
-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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue