diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 63631049e..c0d59a040 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/okhcl.ts b/packages/core/src/okhcl.ts index 6b6b271ca..6464fbc95 100644 --- a/packages/core/src/okhcl.ts +++ b/packages/core/src/okhcl.ts @@ -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 { + 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 { + 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) diff --git a/packages/vue/src/ColorPicker/ColorInputRoot.vue b/packages/vue/src/ColorPicker/ColorInputRoot.vue index 16acaae38..df2e9e9b7 100644 --- a/packages/vue/src/ColorPicker/ColorInputRoot.vue +++ b/packages/vue/src/ColorPicker/ColorInputRoot.vue @@ -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" /> diff --git a/packages/vue/src/ColorPicker/types.ts b/packages/vue/src/ColorPicker/types.ts new file mode 100644 index 000000000..e9f2a5469 --- /dev/null +++ b/packages/vue/src/ColorPicker/types.ts @@ -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) => void +} diff --git a/packages/vue/src/controls/useOkHCL.ts b/packages/vue/src/controls/useOkHCL.ts new file mode 100644 index 000000000..dcdbe9d2a --- /dev/null +++ b/packages/vue/src/controls/useOkHCL.ts @@ -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) { + 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) { + 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' } + ] + } +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 0ad7078d3..aa528fe72 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -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' diff --git a/src/components/ColorInput.vue b/src/components/ColorInput.vue index 1a16e11ba..842c61358 100644 --- a/src/components/ColorInput.vue +++ b/src/components/ColorInput.vue @@ -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] }>()