Add OkHCL color picker support

This commit is contained in:
Danila Poyarkov 2026-03-28 20:33:35 +03:00
parent 049a13a081
commit 04c4c208e7
17 changed files with 640 additions and 114 deletions

View file

@ -97,10 +97,13 @@ export { queryByXPath, matchByXPath } from './xpath'
export type { XPathQueryOptions } from './xpath'
export {
okhclToRGBA,
rgbaToOkHCL,
serializeOkHCLPayload,
parseOkHCLPayload,
setNodeFillOkHCL,
setNodeStrokeOkHCL,
clearNodeFillOkHCL,
clearNodeStrokeOkHCL,
getNodeOkHCLPayloads,
getFillOkHCL,
getStrokeOkHCL,

View file

@ -1,4 +1,4 @@
import { converter } from 'culori'
import { converter, toGamut } from 'culori'
import { normalizeColor } from './color'
import { copyFill, copyStroke } from './copy'
@ -21,6 +21,8 @@ export interface OkHCLPayload {
}
const toRgb = converter('rgb')
const toOklch = converter('oklch')
const toDisplayableRgb = toGamut('rgb', 'oklch')
const OKHCL_PLUGIN_KEY = 'okhcl'
function clampUnit(value: number): number {
@ -34,19 +36,47 @@ function normalizeHue(value: number): number {
return hue < 0 ? hue + 360 : hue
}
export function okhclToRGBA(color: OkHCLColor): Color {
const rgb = toRgb({
mode: 'oklch',
l: clampUnit(color.l),
c: Math.max(0, color.c),
function normalizeOkHCLColor(color: OkHCLColor): OkHCLColor {
return {
h: normalizeHue(color.h),
alpha: color.a ?? 1
})
c: Math.max(0, color.c),
l: clampUnit(color.l),
a: clampUnit(color.a ?? 1)
}
}
export function okhclToRGBA(color: OkHCLColor): Color {
const normalized = normalizeOkHCLColor(color)
const rgb = toRgb(
toDisplayableRgb({
mode: 'oklch',
l: normalized.l,
c: normalized.c,
h: normalized.h,
alpha: normalized.a
})
)
return normalizeColor({
r: rgb.r,
g: rgb.g,
b: rgb.b,
a: rgb.alpha ?? color.a ?? 1
a: rgb.alpha ?? normalized.a
})
}
export function rgbaToOkHCL(color: Color): OkHCLColor {
const oklch = toOklch({
mode: 'rgb',
r: color.r,
g: color.g,
b: color.b,
alpha: color.a
})
return normalizeOkHCLColor({
h: oklch.h ?? 0,
c: oklch.c,
l: oklch.l,
a: oklch.alpha ?? color.a
})
}
@ -86,19 +116,16 @@ function createOkHCLPayload(kind: 'fill' | 'stroke', index: number, color: OkHCL
version: 1,
kind,
index,
color: {
h: normalizeHue(color.h),
c: Math.max(0, color.c),
l: clampUnit(color.l),
a: color.a ?? 1
}
color: normalizeOkHCLColor(color)
}
}
function filterOkHCLPayloads(entries: string[], kind: 'fill' | 'stroke', index: number): string[] {
function filterOkHCLPayloads(entries: string[], kind?: 'fill' | 'stroke', index?: number): string[] {
return entries.filter((entry) => {
const payload = parseOkHCLPayload(entry)
return !payload || payload.kind !== kind || payload.index !== index
if (!payload) return true
if (kind === undefined || index === undefined) return false
return payload.kind !== kind || payload.index !== index
})
}
@ -142,6 +169,20 @@ export function setNodeStrokeOkHCL(node: SceneNode, index: number, color: OkHCLC
}
}
export function clearNodeFillOkHCL(node: SceneNode, index: number): Partial<SceneNode> {
const okhclValues = filterOkHCLPayloads(node.pluginData.map((entry) => entry.value), 'fill', index)
return {
pluginData: okhclValues.map((value) => ({ pluginId: 'open-pencil', key: OKHCL_PLUGIN_KEY, value }))
}
}
export function clearNodeStrokeOkHCL(node: SceneNode, index: number): Partial<SceneNode> {
const okhclValues = filterOkHCLPayloads(node.pluginData.map((entry) => entry.value), 'stroke', index)
return {
pluginData: okhclValues.map((value) => ({ pluginId: 'open-pencil', key: OKHCL_PLUGIN_KEY, value }))
}
}
export function getNodeOkHCLPayloads(node: SceneNode): OkHCLPayload[] {
return node.pluginData
.filter((entry) => entry.pluginId === 'open-pencil' && entry.key === OKHCL_PLUGIN_KEY)

View file

@ -3,10 +3,12 @@ import { computed } from 'vue'
import { colorToHexRaw, parseColor } from '@open-pencil/core'
import type { Color } from '@open-pencil/core'
import type { OkHCLControls } from './types'
const props = defineProps<{
color: Color
editable?: boolean
okhcl?: OkHCLControls | null
}>()
const emit = defineEmits<{ update: [color: Color] }>()
@ -26,5 +28,6 @@ function updateFromHex(value: string) {
:hex="hex"
:update-from-hex="updateFromHex"
:update-color="(nextColor: Color) => emit('update', nextColor)"
:okhcl="props.okhcl ?? null"
/>
</template>

View file

@ -0,0 +1,11 @@
import type { OkHCLColor } from '@open-pencil/core'
export type ColorModel = 'rgba' | 'okhcl'
export interface OkHCLControls {
model: ColorModel
modelOptions: { value: ColorModel; label: string }[]
okhcl: OkHCLColor | null
setModel: (model: ColorModel) => void
updateOkHCL: (patch: Partial<OkHCLColor>) => void
}

View file

@ -0,0 +1,89 @@
import {
clearNodeFillOkHCL,
clearNodeStrokeOkHCL,
getFillOkHCL,
getStrokeOkHCL,
rgbaToOkHCL,
setNodeFillOkHCL,
setNodeStrokeOkHCL
} from '@open-pencil/core'
import { useEditor } from '@open-pencil/vue/context/editorContext'
import type { OkHCLColor, SceneNode } from '@open-pencil/core'
export type ColorModel = 'rgba' | 'okhcl'
export function useOkHCL() {
const editor = useEditor()
function getFillColorModel(node: SceneNode | null, index: number): ColorModel {
if (!node) return 'rgba'
return getFillOkHCL(node, index) ? 'okhcl' : 'rgba'
}
function getStrokeColorModel(node: SceneNode | null, index: number): ColorModel {
if (!node) return 'rgba'
return getStrokeOkHCL(node, index) ? 'okhcl' : 'rgba'
}
function getFillOkHCLColor(node: SceneNode | null, index: number): OkHCLColor | null {
return node ? getFillOkHCL(node, index)?.color ?? null : null
}
function getStrokeOkHCLColor(node: SceneNode | null, index: number): OkHCLColor | null {
return node ? getStrokeOkHCL(node, index)?.color ?? null : null
}
function enableFillOkHCL(node: SceneNode, index: number) {
const color = getFillOkHCLColor(node, index) ?? rgbaToOkHCL(node.fills[index]?.color ?? { r: 0, g: 0, b: 0, a: 1 })
editor.updateNodeWithUndo(node.id, setNodeFillOkHCL(node, index, color), 'Enable fill OkHCL')
}
function disableFillOkHCL(node: SceneNode, index: number) {
editor.updateNodeWithUndo(node.id, clearNodeFillOkHCL(node, index), 'Disable fill OkHCL')
}
function enableStrokeOkHCL(node: SceneNode, index: number) {
const color = getStrokeOkHCLColor(node, index) ?? rgbaToOkHCL(node.strokes[index]?.color ?? { r: 0, g: 0, b: 0, a: 1 })
editor.updateNodeWithUndo(node.id, setNodeStrokeOkHCL(node, index, color), 'Enable stroke OkHCL')
}
function disableStrokeOkHCL(node: SceneNode, index: number) {
editor.updateNodeWithUndo(node.id, clearNodeStrokeOkHCL(node, index), 'Disable stroke OkHCL')
}
function updateFillOkHCL(node: SceneNode, index: number, patch: Partial<OkHCLColor>) {
const current = getFillOkHCLColor(node, index) ?? { h: 0, c: 0.1, l: 0.5, a: 1 }
editor.updateNodeWithUndo(
node.id,
setNodeFillOkHCL(node, index, { ...current, ...patch }),
'Change fill OkHCL'
)
}
function updateStrokeOkHCL(node: SceneNode, index: number, patch: Partial<OkHCLColor>) {
const current = getStrokeOkHCLColor(node, index) ?? { h: 0, c: 0.1, l: 0.5, a: 1 }
editor.updateNodeWithUndo(
node.id,
setNodeStrokeOkHCL(node, index, { ...current, ...patch }),
'Change stroke OkHCL'
)
}
return {
getFillColorModel,
getStrokeColorModel,
getFillOkHCLColor,
getStrokeOkHCLColor,
enableFillOkHCL,
disableFillOkHCL,
enableStrokeOkHCL,
disableStrokeOkHCL,
updateFillOkHCL,
updateStrokeOkHCL,
modelOptions: [
{ value: 'rgba' as const, label: 'RGBA' },
{ value: 'okhcl' as const, label: 'OkHCL' }
]
}
}

View file

@ -55,6 +55,7 @@ export { useFillControls } from './controls/useFillControls'
export { useColorVariableBinding } from './controls/useColorVariableBinding'
export { useEffectsControls } from './controls/useEffectsControls'
export { useStrokeControls } from './controls/useStrokeControls'
export { useOkHCL } from './controls/useOkHCL'
/** Variables, page navigation, and picker helpers. */
export { useVariables } from './VariablesEditor/useVariables'

View file

@ -4,19 +4,21 @@ import { ColorInputRoot } from '@open-pencil/vue'
import ColorPicker from './ColorPicker.vue'
import type { Color } from '@open-pencil/core'
import type { OkHCLControls } from '@open-pencil/vue/ColorPicker/types'
const { editable = false, color } = defineProps<{
const { editable = false, color, okhcl = null } = defineProps<{
color: Color
editable?: boolean
okhcl?: OkHCLControls | null
}>()
const emit = defineEmits<{ update: [color: Color] }>()
</script>
<template>
<ColorInputRoot :color="color" :editable="editable" @update="emit('update', $event)">
<template #default="{ editable: isEditable, hex, updateFromHex, updateColor }">
<ColorInputRoot :color="color" :editable="editable" :okhcl="okhcl" @update="emit('update', $event)">
<template #default="{ editable: isEditable, hex, updateFromHex, updateColor, okhcl: okhclControls }">
<div class="flex items-center gap-1.5">
<ColorPicker :color="color" @update="updateColor($event)" />
<ColorPicker :color="color" :okhcl="okhclControls" @update="updateColor($event)" />
<input
v-if="isEditable"
data-test-id="color-hex-input"

View file

@ -5,8 +5,9 @@ import HsvColorArea from './HsvColorArea.vue'
import { usePopoverUI } from './ui/popover'
import type { Color } from '@open-pencil/core'
import type { OkHCLControls } from '@open-pencil/vue/ColorPicker/types'
const { color } = defineProps<{ color: Color }>()
const { color, okhcl = null } = defineProps<{ color: Color; okhcl?: OkHCLControls | null }>()
const emit = defineEmits<{ update: [color: Color] }>()
const cls = usePopoverUI({ content: 'w-56 p-2' })
</script>
@ -19,7 +20,7 @@ const cls = usePopoverUI({ content: 'w-56 p-2' })
@update="emit('update', $event)"
>
<template #default="{ color: currentColor, update }">
<HsvColorArea :color="currentColor" @update="update($event)" />
<HsvColorArea :color="currentColor" :okhcl="okhcl" @update="update($event)" />
</template>
</ColorPickerRoot>
</template>

View file

@ -10,6 +10,7 @@ import Tip from './ui/Tip.vue'
import { usePopoverUI } from './ui/popover'
import type { Fill } from '@open-pencil/core'
import type { OkHCLControls } from '@open-pencil/vue/ColorPicker/types'
const TAB_BASE =
'flex size-6 cursor-pointer items-center justify-center rounded border-none p-0 transition-colors'
@ -21,7 +22,7 @@ function tabClass(active: boolean) {
)
}
const { fill } = defineProps<{ fill: Fill }>()
const { fill, okhcl = null } = defineProps<{ fill: Fill; okhcl?: OkHCLControls | null }>()
const emit = defineEmits<{ update: [fill: Fill] }>()
const cls = usePopoverUI({ content: 'w-60 p-2' })
const { panels } = useI18n()
@ -75,6 +76,7 @@ const { panels } = useI18n()
<HsvColorArea
v-if="category === 'SOLID'"
:color="currentFill.color"
:okhcl="okhcl"
@update="update({ ...currentFill, color: $event })"
/>

View file

@ -4,21 +4,23 @@ import {
ColorAreaRoot,
ColorAreaArea,
ColorAreaThumb,
ColorSliderRoot,
ColorSliderTrack,
ColorSliderThumb,
ColorFieldRoot,
ColorFieldInput,
normalizeColor,
convertToHsl,
convertToRgb,
type Color as RekaColor
} from 'reka-ui'
import type { Color } from '@open-pencil/core'
import { colorToHex8, rgba255ToColor } from '@open-pencil/core'
import { colorToCSS, colorToHex8, rgba255ToColor } from '@open-pencil/core'
import type { OkHCLControls } from '@open-pencil/vue/ColorPicker/types'
const { color } = defineProps<{
import PickerSlider from './PickerSlider.vue'
const { color, okhcl = null } = defineProps<{
color: Color
okhcl?: OkHCLControls | null
}>()
const emit = defineEmits<{
@ -26,8 +28,11 @@ const emit = defineEmits<{
}>()
const hexWithAlpha = computed(() => colorToHex8(color))
const rekaColor = computed(() => normalizeColor(hexWithAlpha.value))
const isOkHCLMode = computed(() => okhcl?.model === 'okhcl' && okhcl.okhcl)
const previewStyle = computed(() => ({ background: colorToCSS(color) }))
const hslColor = computed(() => convertToHsl(rekaColor.value))
const rgbaHue = computed(() => hslColor.value.h ?? 0)
function rekaToColor(c: RekaColor): Color {
const rgb = convertToRgb(c)
@ -41,98 +46,192 @@ function onRekaColorUpdate(c: RekaColor) {
function onHexUpdate(hex: string) {
emit('update', rekaToColor(normalizeColor(hex)))
}
function updateAlphaPercent(value: number) {
emit('update', {
...color,
a: Math.max(0, Math.min(1, value / 100))
})
}
function updateRGBAHue(value: number) {
onRekaColorUpdate(
normalizeColor({
...hslColor.value,
h: value
})
)
}
function updateRGBAAlpha(value: number) {
emit('update', {
...color,
a: Math.max(0, Math.min(1, value))
})
}
function toPercent(value: number) {
return Math.round(value * 100)
}
function fromPercent(value: number) {
return Math.max(0, Math.min(1, value / 100))
}
function updateOkHCLChannel(channel: 'h' | 'c' | 'l' | 'a', value: number) {
okhcl?.updateOkHCL({ [channel]: value })
}
</script>
<template>
<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"
<div v-if="okhcl" class="flex items-center gap-1">
<button
v-for="option in okhcl.modelOptions"
:key="option.value"
type="button"
class="rounded border px-2 py-0.5 text-[11px]"
:class="
okhcl.model === option.value
? 'border-accent bg-accent/10 text-accent'
: 'border-border bg-input text-muted'
"
@click="okhcl.setModel(option.value)"
>
<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"
{{ option.label }}
</button>
</div>
<template v-if="isOkHCLMode">
<div class="h-10 rounded border border-border" :style="previewStyle" />
<div class="flex flex-col gap-2">
<PickerSlider
label="H"
:model-value="okhcl!.okhcl!.h"
:min="0"
:max="360"
:step="1"
gradient-style="background: linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000);"
@update:model-value="updateOkHCLChannel('h', $event)"
/>
</ColorAreaArea>
</ColorAreaRoot>
<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>
<PickerSlider
label="C"
:model-value="okhcl!.okhcl!.c"
:min="0"
:max="0.4"
:step="0.001"
:display-value="toPercent(okhcl!.okhcl!.c)"
:display-min="0"
:display-max="40"
:display-step="1"
:parse-display="(value) => value / 100"
gradient-style="background: linear-gradient(to right, #3a3a3a, var(--color-accent));"
@update:model-value="updateOkHCLChannel('c', $event)"
/>
<div class="checkerboard relative h-3 w-full rounded-md">
<ColorSliderRoot
<PickerSlider
label="L"
:model-value="okhcl!.okhcl!.l"
:min="0"
:max="1"
:step="0.001"
:display-value="toPercent(okhcl!.okhcl!.l)"
:display-min="0"
:display-max="100"
:display-step="1"
:parse-display="fromPercent"
gradient-style="background: linear-gradient(to right, #000000, #888888, #ffffff);"
@update:model-value="updateOkHCLChannel('l', $event)"
/>
<PickerSlider
label="A"
:model-value="okhcl!.okhcl!.a ?? 1"
:min="0"
:max="1"
:step="0.001"
:display-value="toPercent(okhcl!.okhcl!.a ?? 1)"
:display-min="0"
:display-max="100"
:display-step="1"
:parse-display="fromPercent"
checkerboard
:gradient-style="`background: linear-gradient(to right, transparent, ${colorToCSS(color)})`"
:thumb-fill="colorToCSS(color)"
@update:model-value="updateOkHCLChannel('a', $event)"
/>
</div>
<p class="text-[10px] text-muted">H = hue, C = chroma, L = lightness, A = alpha</p>
</template>
<template v-else>
<ColorAreaRoot
v-slot="{ style }"
:model-value="rekaColor"
channel="alpha"
color-space="hsb"
class="absolute inset-0 flex items-center"
x-channel="saturation"
y-channel="brightness"
@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>
<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>
<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))
})
"
<PickerSlider
label="H"
:model-value="rgbaHue"
:min="0"
:max="360"
:step="1"
gradient-style="background: linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000);"
:thumb-fill="colorToCSS(color)"
:ui="{ root: 'gap-0', label: 'hidden', input: 'hidden' }"
@update:model-value="updateRGBAHue"
/>
<span class="text-[11px] text-muted">%</span>
</div>
<PickerSlider
label="A"
:model-value="color.a"
:min="0"
:max="1"
:step="0.001"
checkerboard
:gradient-style="`background: linear-gradient(to right, transparent, ${colorToCSS({ ...color, a: 1 })})`"
:thumb-fill="colorToCSS(color)"
:ui="{ root: 'gap-0', label: 'hidden', input: 'hidden' }"
@update:model-value="updateRGBAAlpha"
/>
<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="updateAlphaPercent(+($event.target as HTMLInputElement).value)"
/>
<span class="text-[11px] text-muted">%</span>
</div>
</template>
</div>
</template>
<style scoped>
.checkerboard {
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;
}
</style>

View file

@ -0,0 +1,89 @@
<script setup lang="ts">
import { usePickerSliderUI } from './ui/picker-slider'
const {
label,
modelValue,
min,
max,
step = 1,
displayValue,
displayMin,
displayMax,
displayStep,
formatDisplay,
parseDisplay,
gradientStyle,
checkerboard = false,
thumbFill = '#fff',
ui
} = defineProps<{
label: string
modelValue: number
min: number
max: number
step?: number
displayValue?: number
displayMin?: number
displayMax?: number
displayStep?: number
formatDisplay?: (value: number) => string | number
parseDisplay?: (value: number) => number
gradientStyle?: string
checkerboard?: boolean
thumbFill?: string
ui?: Partial<Record<'root' | 'label' | 'track' | 'gradient' | 'range' | 'thumb' | 'input', string>>
}>()
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const cls = usePickerSliderUI({ checkerboard, ui })
function numberValue(): string | number {
const value = displayValue ?? modelValue
return formatDisplay ? formatDisplay(value) : value
}
function handleNumberChange(value: number) {
emit('update:modelValue', parseDisplay ? parseDisplay(value) : value)
}
function thumbLeft(): string {
const range = max - min
const ratio = range === 0 ? 0 : (modelValue - min) / range
return `calc(${Math.max(0, Math.min(1, ratio)) * 100}% - 7px)`
}
</script>
<template>
<div :class="cls.root">
<span :class="cls.label">{{ label }}</span>
<div :class="cls.track">
<div :class="cls.gradient" :style="gradientStyle" />
<input
type="range"
:class="cls.range"
:min="min"
:max="max"
:step="step"
:value="modelValue"
@input="emit('update:modelValue', +($event.target as HTMLInputElement).value)"
/>
<div
:class="cls.thumb"
:style="{ left: thumbLeft(), background: thumbFill }"
/>
</div>
<input
type="number"
:class="cls.input"
:min="displayMin ?? min"
:max="displayMax ?? max"
:step="displayStep ?? step"
:value="numberValue()"
@change="handleNumberChange(+($event.target as HTMLInputElement).value)"
/>
</div>
</template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { colorToHexRaw } from '@open-pencil/core'
import { PropertyListRoot, useFillControls, useI18n } from '@open-pencil/vue'
import { PropertyListRoot, useFillControls, useOkHCL, useI18n } from '@open-pencil/vue'
import FillPicker from '@/components/FillPicker.vue'
import ColorStyleRow from '@/components/properties/ColorStyleRow.vue'
@ -10,6 +10,7 @@ import { sectionLabel, sectionWrapper } from '@/components/ui/section'
import type { Fill } from '@open-pencil/core'
const fillCtx = useFillControls()
const okhcl = useOkHCL()
const { panels } = useI18n()
</script>
@ -46,7 +47,24 @@ const { panels } = useI18n()
@toggle-visibility="toggleVisibility(i)"
@remove="remove(i)"
>
<FillPicker :fill="fill" @update="update(i, $event)" />
<FillPicker
:fill="fill"
:okhcl="
activeNode
? {
model: okhcl.getFillColorModel(activeNode, i),
modelOptions: okhcl.modelOptions,
okhcl: okhcl.getFillOkHCLColor(activeNode, i),
setModel: ($event) =>
$event === 'okhcl'
? okhcl.enableFillOkHCL(activeNode, i)
: okhcl.disableFillOkHCL(activeNode, i),
updateOkHCL: ($event) => okhcl.updateFillOkHCL(activeNode, i, $event)
}
: null
"
@update="update(i, $event)"
/>
<template v-if="activeNode && fillCtx.getBoundVariable(activeNode.id, i)">
<span

View file

@ -1,7 +1,13 @@
<script setup lang="ts">
import { ref } from 'vue'
import { PropertyListRoot, useColorVariableBinding, useStrokeControls, useI18n } from '@open-pencil/vue'
import {
PropertyListRoot,
useColorVariableBinding,
useStrokeControls,
useOkHCL,
useI18n
} from '@open-pencil/vue'
import ColorStyleRow from '@/components/properties/ColorStyleRow.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
@ -15,6 +21,7 @@ import type { SceneNode, Stroke } from '@open-pencil/core'
const strokeCtx = useStrokeControls()
const strokeVarCtx = useColorVariableBinding('strokes')
const okhcl = useOkHCL()
const { panels } = useI18n()
const expandedSides = ref(false)
@ -24,7 +31,13 @@ function onToggleSides(activeNode: SceneNode) {
expandedSides.value = next
if (next && !activeNode.independentStrokeWeights) {
const weight = activeNode.strokes[0]?.weight ?? 1
strokeCtx.selectSide('CUSTOM', { ...activeNode, borderTopWeight: weight, borderRightWeight: weight, borderBottomWeight: weight, borderLeftWeight: weight } as SceneNode)
strokeCtx.selectSide('CUSTOM', {
...activeNode,
borderTopWeight: weight,
borderRightWeight: weight,
borderBottomWeight: weight,
borderLeftWeight: weight
} as SceneNode)
} else if (!next && activeNode.independentStrokeWeights) {
strokeCtx.selectSide('ALL', activeNode)
}
@ -66,7 +79,26 @@ function onToggleSides(activeNode: SceneNode) {
@toggle-visibility="toggleVisibility(i)"
@remove="remove(i)"
>
<ColorInput class="min-w-0 flex-1" :color="stroke.color" editable @update="patch(i, { color: $event })" />
<ColorInput
class="min-w-0 flex-1"
:color="stroke.color"
:okhcl="
activeNode
? {
model: okhcl.getStrokeColorModel(activeNode, i),
modelOptions: okhcl.modelOptions,
okhcl: okhcl.getStrokeOkHCLColor(activeNode, i),
setModel: ($event) =>
$event === 'okhcl'
? okhcl.enableStrokeOkHCL(activeNode, i)
: okhcl.disableStrokeOkHCL(activeNode, i),
updateOkHCL: ($event) => okhcl.updateStrokeOkHCL(activeNode, i, $event)
}
: null
"
editable
@update="patch(i, { color: $event })"
/>
</ColorStyleRow>
<div

View file

@ -0,0 +1,44 @@
import { twMerge } from 'tailwind-merge'
import { tv } from 'tailwind-variants'
const pickerSlider = tv({
slots: {
root: 'flex items-center gap-2',
label: 'w-4 shrink-0 text-[10px] font-medium text-muted',
track: 'relative flex h-3 flex-1 items-center rounded-md',
gradient: 'absolute inset-0 rounded-md',
range: 'absolute inset-0 h-full w-full cursor-pointer opacity-0',
thumb: 'pointer-events-none absolute top-1/2 size-3.5 -translate-y-1/2 rounded-full border-2 border-white shadow-sm',
input: 'w-14 rounded border border-border bg-input px-1 py-0.5 text-right text-xs text-surface'
},
variants: {
checkerboard: {
true: {
track:
'bg-[#333] bg-[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%)] bg-[size:8px_8px] bg-[position:0_0,0_4px,4px_-4px,-4px_0]'
},
false: {}
}
},
defaultVariants: {
checkerboard: false
}
})
export type PickerSliderUi = Partial<Record<'root' | 'label' | 'track' | 'gradient' | 'range' | 'thumb' | 'input', string>>
export function usePickerSliderUI(options?: {
checkerboard?: boolean
ui?: PickerSliderUi
}) {
const slots = pickerSlider({ checkerboard: options?.checkerboard })
return {
root: twMerge(slots.root(), options?.ui?.root),
label: twMerge(slots.label(), options?.ui?.label),
track: twMerge(slots.track(), options?.ui?.track),
gradient: twMerge(slots.gradient(), options?.ui?.gradient),
range: twMerge(slots.range(), options?.ui?.range),
thumb: twMerge(slots.thumb(), options?.ui?.thumb),
input: twMerge(slots.input(), options?.ui?.input)
}
}

View file

@ -0,0 +1,47 @@
import { describe, expect, test } from 'bun:test'
import {
clearNodeFillOkHCL,
colorDistance,
getFillOkHCL,
rgbaToOkHCL,
SceneGraph,
setNodeFillOkHCL
} from '@open-pencil/core'
describe('OkHCL toggle conversion', () => {
test('enabling OkHCL from an existing rgba fill preserves the visible color closely', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const originalColor = { r: 0.22, g: 0.64, b: 0.48, a: 0.83 }
const node = graph.createNode('FRAME', page.id, {
fills: [{ type: 'SOLID', visible: true, opacity: originalColor.a, color: originalColor }]
})
graph.updateNode(node.id, setNodeFillOkHCL(node, 0, rgbaToOkHCL(originalColor)))
const updated = graph.getNode(node.id)!
expect(colorDistance(originalColor, updated.fills[0]!.color)).toBeLessThan(1)
expect(Math.abs(updated.fills[0]!.color.a - originalColor.a)).toBeLessThan(0.001)
expect(getFillOkHCL(updated, 0)).not.toBeNull()
})
test('disabling OkHCL removes metadata without changing the rendered color', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const originalColor = { r: 0.71, g: 0.31, b: 0.19, a: 0.92 }
const node = graph.createNode('FRAME', page.id, {
fills: [{ type: 'SOLID', visible: true, opacity: originalColor.a, color: originalColor }]
})
graph.updateNode(node.id, setNodeFillOkHCL(node, 0, rgbaToOkHCL(originalColor)))
const withOkHCL = graph.getNode(node.id)!
const renderedBeforeDisable = withOkHCL.fills[0]!.color
graph.updateNode(node.id, clearNodeFillOkHCL(withOkHCL, 0))
const afterDisable = graph.getNode(node.id)!
expect(getFillOkHCL(afterDisable, 0)).toBeNull()
expect(colorDistance(renderedBeforeDisable, afterDisable.fills[0]!.color)).toBeLessThan(0.001)
})
})

View file

@ -0,0 +1,33 @@
import { describe, expect, test } from 'bun:test'
import { clearNodeFillOkHCL, clearNodeStrokeOkHCL, getFillOkHCL, getStrokeOkHCL, SceneGraph, setNodeFillOkHCL, setNodeStrokeOkHCL } from '@open-pencil/core'
describe('OkHCL metadata toggling', () => {
test('can disable fill OkHCL metadata', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const node = graph.createNode('FRAME', page.id, {
fills: [{ type: 'SOLID', visible: true, opacity: 1, color: { r: 1, g: 0, b: 0, a: 1 } }]
})
graph.updateNode(node.id, setNodeFillOkHCL(node, 0, { h: 10, c: 0.1, l: 0.5 }))
expect(getFillOkHCL(graph.getNode(node.id)!, 0)).not.toBeNull()
graph.updateNode(node.id, clearNodeFillOkHCL(graph.getNode(node.id)!, 0))
expect(getFillOkHCL(graph.getNode(node.id)!, 0)).toBeNull()
})
test('can disable stroke OkHCL metadata', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const node = graph.createNode('FRAME', page.id, {
strokes: [{ color: { r: 0, g: 0, b: 1, a: 1 }, weight: 1, opacity: 1, visible: true, align: 'INSIDE' }]
})
graph.updateNode(node.id, setNodeStrokeOkHCL(node, 0, { h: 200, c: 0.1, l: 0.5 }))
expect(getStrokeOkHCL(graph.getNode(node.id)!, 0)).not.toBeNull()
graph.updateNode(node.id, clearNodeStrokeOkHCL(graph.getNode(node.id)!, 0))
expect(getStrokeOkHCL(graph.getNode(node.id)!, 0)).toBeNull()
})
})

View file

@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test'
import {
colorDistance,
exportFigFile,
FigmaAPI,
getFillOkHCL,
@ -8,6 +9,7 @@ import {
initCodec,
okhclToRGBA,
parseFigFile,
rgbaToOkHCL,
SceneGraph
} from '@open-pencil/core'
@ -46,6 +48,15 @@ describe('OkHCL metadata', () => {
})
})
test('preserves visible color closely when converting rgba to okhcl', () => {
const rgba = { r: 0.31, g: 0.52, b: 0.83, a: 0.72 }
const okhcl = rgbaToOkHCL(rgba)
const roundtrip = okhclToRGBA(okhcl)
expect(colorDistance(rgba, roundtrip)).toBeLessThan(1)
expect(Math.abs((roundtrip.a ?? 1) - rgba.a)).toBeLessThan(0.001)
})
test('roundtrips okhcl metadata through fig export/import', async () => {
await initCodec()