Add OkHCL color picker support
This commit is contained in:
parent
049a13a081
commit
04c4c208e7
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
11
packages/vue/src/ColorPicker/types.ts
Normal file
11
packages/vue/src/ColorPicker/types.ts
Normal 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
|
||||
}
|
||||
89
packages/vue/src/controls/useOkHCL.ts
Normal file
89
packages/vue/src/controls/useOkHCL.ts
Normal 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' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
89
src/components/PickerSlider.vue
Normal file
89
src/components/PickerSlider.vue
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
44
src/components/ui/picker-slider.ts
Normal file
44
src/components/ui/picker-slider.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
47
tests/engine/okhcl-toggle-conversion.test.ts
Normal file
47
tests/engine/okhcl-toggle-conversion.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
33
tests/engine/okhcl-ui-logic.test.ts
Normal file
33
tests/engine/okhcl-ui-logic.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue