diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c6f95c8..1a9619fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Refine variable-bound number fields with a quiet identity pill, one picker affordance, an accessible variable combobox, and non-destructive focus behavior. - Redesign Position and Appearance controls with aligned panel grids, SDK-owned independent-corner state, and compact type-icon selection headers. - Rebuild Layout size fields with shared variable binding, inline sizing modes, semantic field anatomy, and one-step Hug/Fill-to-Fixed editing. +- Replace the fragmented Vue SDK color-picker model helpers with `useColorModel()`, providing precise Reka bridges, extensible formats, and shared RGB, HSL, HSB, and OkHCL channel behavior. - Upgrade Vue SDK documentation with shared Tailwind demos, source-generated component API tables, and type-aware Twoslash examples in VitePress. - Add desktop image drag-and-drop into the Tauri app window. - Add open-document discovery for live CLI and MCP automation so agents can target the intended document and page. diff --git a/packages/docs/.vitepress/sdk-sidebar.ts b/packages/docs/.vitepress/sdk-sidebar.ts index c580ac118..ec11132dd 100644 --- a/packages/docs/.vitepress/sdk-sidebar.ts +++ b/packages/docs/.vitepress/sdk-sidebar.ts @@ -40,6 +40,7 @@ const SDK_COMPOSABLE_PAGES = [ { text: 'usePosition', slug: 'use-position' }, { text: 'useLayout', slug: 'use-layout' }, { text: 'useAppearance', slug: 'use-appearance' }, + { text: 'useColorModel', slug: 'use-color-model', canonical: true }, { text: 'useTypography', slug: 'use-typography' }, { text: 'useExport', slug: 'use-export' }, { text: 'useFillControls', slug: 'use-fill-controls' }, @@ -101,7 +102,7 @@ export const sdkSidebar = (prefix: string): DefaultTheme.SidebarItem[] => [ { text: 'Overview', link: `${prefix}/programmable/sdk/api/composables/` }, ...SDK_COMPOSABLE_PAGES.map((page) => ({ text: page.text, - link: `${prefix}/programmable/sdk/api/composables/${page.slug}` + link: `${'canonical' in page ? '' : prefix}/programmable/sdk/api/composables/${page.slug}` })) ] }, diff --git a/packages/docs/programmable/sdk/api/advanced/use-okhcl.md b/packages/docs/programmable/sdk/api/advanced/use-okhcl.md index 667cd71e0..81fe1df56 100644 --- a/packages/docs/programmable/sdk/api/advanced/use-okhcl.md +++ b/packages/docs/programmable/sdk/api/advanced/use-okhcl.md @@ -1,13 +1,17 @@ --- title: useOkHCL -description: Work with RGBA and OkHCL color models for fills and strokes. +description: Persist OkHCL intent for selected node fills and strokes. --- # useOkHCL -`useOkHCL()` exposes helpers for reading, enabling, disabling, and updating OkHCL color values on node fills and strokes. +`useOkHCL()` is the editor-aware adapter for OkHCL fill and stroke metadata. It reads stored color +intent, updates nodes with undo, reports preview gamut information, and remembers the selected +field format for each fill or stroke. -Use it when you are building advanced color tooling that needs to switch between standard RGBA editing and perceptual OkHCL editing. +Use [`useColorModel()`](../composables/use-color-model) for framework-agnostic conversion, channel +editing, and slider presentation. Use `useOkHCL()` only where those edits need to be persisted to an +OpenPencil editor. ## Usage @@ -15,24 +19,43 @@ Use it when you are building advanced color tooling that needs to switch between import { useOkHCL } from '@open-pencil/vue' const okhcl = useOkHCL() + +const color = okhcl.getFillOkHCLColor(node, 0) +okhcl.updateFillOkHCL(node, 0, { c: 0.2 }) ``` +## Format state + +```ts +const format = okhcl.getFieldFormat(node, 0, 'fill') +okhcl.setFillFieldFormat(node, 0, 'okhcl') +``` + +Selecting `okhcl` initializes intent from the fill or stroke's current RGBA color. The returned +`fieldOptions` can be used to build a format selector. + +## Preview information + +```ts +const preview = okhcl.getFillPreviewInfo(node, 0) +// { previewColorSpace, clipped } +``` + +Preview information reflects the document render color space and whether the stored OkHCL intent +needed gamut mapping. + ## Returns -- `getFillColorModel()` -- `getStrokeColorModel()` -- `getFillOkHCLColor()` -- `getStrokeOkHCLColor()` -- `enableFillOkHCL()` -- `disableFillOkHCL()` -- `enableStrokeOkHCL()` -- `disableStrokeOkHCL()` -- `updateFillOkHCL()` -- `updateStrokeOkHCL()` -- `modelOptions` +- `getFillOkHCLColor()` / `getStrokeOkHCLColor()` +- `getFillPreviewInfo()` / `getStrokePreviewInfo()` +- `getFieldFormat()` +- `setFillFieldFormat()` / `setStrokeFieldFormat()` +- `updateFillOkHCL()` / `updateStrokeOkHCL()` +- `fieldOptions` ## Related APIs +- [useColorModel](../composables/use-color-model) - [useFillControls](../composables/use-fill-controls) - [useStrokeControls](../composables/use-stroke-controls) - [ColorPickerRoot](../components/color-picker-root) diff --git a/packages/docs/programmable/sdk/api/composables/index.md b/packages/docs/programmable/sdk/api/composables/index.md index 2edd7f12a..370ad2002 100644 --- a/packages/docs/programmable/sdk/api/composables/index.md +++ b/packages/docs/programmable/sdk/api/composables/index.md @@ -27,6 +27,7 @@ These are the main composables most `@open-pencil/vue` consumers will use. - [usePosition](./use-position) - [useLayout](./use-layout) - [useAppearance](./use-appearance) +- [useColorModel](./use-color-model) - [useMask](./use-mask) - [useTypography](./use-typography) - [useExport](./use-export) diff --git a/packages/docs/programmable/sdk/api/composables/use-color-model.md b/packages/docs/programmable/sdk/api/composables/use-color-model.md new file mode 100644 index 000000000..4df7f156a --- /dev/null +++ b/packages/docs/programmable/sdk/api/composables/use-color-model.md @@ -0,0 +1,96 @@ +--- +title: useColorModel +description: Build precise reactive color controls across RGB, HSL, HSB, and OkHCL. +--- + +# useColorModel + +`useColorModel()` is the shared color-state and conversion layer for custom color pickers. It accepts +a scene-graph color, exposes Reka-compatible RGB/HSL/HSB values, and keeps optional OkHCL intent +separate from the gamut-mapped preview color. + +The composable does not require an editor context and does not mutate the scene graph directly. +Pass callbacks to connect it to your own state or persistence layer. + +## Basic usage + +```ts +import { ref } from 'vue' +import { useColorModel } from '@open-pencil/vue' +import type { Color } from '@open-pencil/scene-graph' + +const color = ref({ r: 0.25, g: 0.5, b: 0.9, a: 1 }) + +const model = useColorModel({ + color, + onUpdate: (nextColor) => { + color.value = nextColor + }, +}) + +model.updateHSLChannel('s', 72) +model.updateAlpha(0.8) +``` + +The returned `hex`, `rekaColor`, `rgb`, `hsl`, and `hsb` values are computed refs. Use +`updateHex()` for hex input; it preserves the current alpha. RGB conversion keeps fractional +channel precision rather than rounding through 8-bit values. + +## Format state + +The built-in format identifiers are `hex`, `rgb`, `hsl`, `hsb`, and `okhcl`. The type remains +extensible so applications can add another presentation format without changing the model. + +```ts +const model = useColorModel({ + color, + defaultFormat: 'hsl', + onFormatChange: (format) => savePreferredFormat(format), +}) + +model.setFormat('okhcl') +``` + +Pass `format` for controlled state. Without it, `setFormat()` updates local reactive state. + +## OkHCL intent + +A scene-graph color contains a renderable RGBA value, but it cannot retain an out-of-gamut OkHCL +source by itself. Pass the source intent separately and persist patches through `onUpdateOkHCL`: + +```ts +const model = useColorModel({ + color, + okhcl: () => storedOkhcl.value, + onUpdateOkHCL: (patch) => { + storedOkhcl.value = { ...storedOkhcl.value, ...patch } + }, +}) + +model.updateOkHCLChannel('c', 0.24) +``` + +When no OkHCL callback is supplied, OkHCL edits emit a gamut-mapped scene color through +`onUpdate`. Conversion and gamut handling reuse OpenPencil's culori-backed core color APIs. + +## Slider presentation + +`sliderPreview`, `sliderGradient`, `okhclSliderPreview`, and `okhclSliderGradient` provide the +colors and CSS gradients needed by slider skins. They contain presentation data only; keyboard, +pointer, and ARIA behavior belongs to the slider primitive. + +## Update behavior + +- RGB values use the `0–255` presentation range. +- HSL and HSB saturation/lightness/brightness use `0–100`. +- OkHCL lightness and alpha use `0–1`; chroma is non-negative; hue wraps into `0–360`. +- Alpha is preserved across color-space conversions. +- No-op updates do not call mutation callbacks. +- Editing hue on an achromatic color creates a visible color rather than remaining neutral. + +## Related APIs + +- [ColorPickerRoot](../components/color-picker-root) +- [ColorInputRoot](../components/color-input-root) +- [useFillControls](./use-fill-controls) +- [useStrokeControls](./use-stroke-controls) diff --git a/packages/docs/programmable/sdk/architecture.md b/packages/docs/programmable/sdk/architecture.md index 7b177a298..9c9261a8a 100644 --- a/packages/docs/programmable/sdk/architecture.md +++ b/packages/docs/programmable/sdk/architecture.md @@ -42,6 +42,7 @@ These contain structural/headless primitives and local helpers. - `usePosition` - `useLayout` - `useAppearance` +- `useColorModel` - `useTypography` - `useExport` - `useFillControls` diff --git a/packages/vue/README.md b/packages/vue/README.md index 7729acb99..28c278082 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -102,7 +102,8 @@ dimension can switch that axis to Fixed inside the same provider transaction. `A exposes selection-derived independent-corner presentation state so consumers do not need parallel expansion heuristics. `PropertyListRoot` is controlled and editor-agnostic; OpenPencil panels connect it to selection and undo through -`useEditorPropertyList()`. +`useEditorPropertyList()`. `useColorModel()` provides precise scene-color/Reka bridges, reactive +RGB/HSL/HSB/OkHCL channels, extensible format state, and shared slider presentation data. ## Public API tiers @@ -130,6 +131,7 @@ These are the main APIs most SDK consumers should start with. - `usePosition()` - `useLayout()` - `useAppearance()` +- `useColorModel()` - `useMask()` - `useTypography()` - `useExport()` diff --git a/packages/vue/src/controls/color-model/index.ts b/packages/vue/src/controls/color-model/index.ts new file mode 100644 index 000000000..d16863c4f --- /dev/null +++ b/packages/vue/src/controls/color-model/index.ts @@ -0,0 +1,14 @@ +export { BUILT_IN_COLOR_FORMATS, useColorModel } from '#vue/controls/color-model/use' +export { + applySolidFillColor, + applySolidStrokeColor, + fromPercent, + toPercent +} from '#vue/controls/color-model/model' +export type { + BuiltInColorFormat, + ColorFieldFormat, + ColorFieldOption, + OkHCLControls, + UseColorModelOptions +} from '#vue/controls/color-model/types' diff --git a/packages/vue/src/controls/color-model/model.ts b/packages/vue/src/controls/color-model/model.ts new file mode 100644 index 000000000..d60a38762 --- /dev/null +++ b/packages/vue/src/controls/color-model/model.ts @@ -0,0 +1,214 @@ +import { convertToHsb, convertToHsl, convertToRgb } from 'reka-ui' +import type { Color as RekaColor, HSBColor, HSLColor, RGBColor } from 'reka-ui' + +import { colorToCSS, okhclToRGBA, rgba255ToColor, rgbaToOkHCL } from '@open-pencil/core/color' +import type { OkHCLColor } from '@open-pencil/core/color' +import type { Fill, Stroke } from '@open-pencil/scene-graph' +import type { Color } from '@open-pencil/scene-graph/primitives' + +import type { + HSBChannel, + HSLChannel, + OkHCLChannel, + OkHCLSliderGradientModel, + OkHCLSliderPreviewModel, + RGBChannel, + SliderGradientModel, + SliderPreviewModel +} from '#vue/controls/color-model/types' + +export interface ColorModelValue { + rekaColor: RekaColor + rgb: RGBColor + hsl: HSLColor + hsb: HSBColor + okhcl: OkHCLColor +} + +const OKHCL_CHROMA_MAX = 0.4 +const OKHCL_LIGHTNESS_MID = 0.5 +const OKHCL_HUE_PREVIEW_MIN_CHROMA = 0.15 +const OKHCL_HUE_PREVIEW_FALLBACK_LIGHTNESS = 0.7 + +export function createColorModelValue(color: Color, okhcl?: OkHCLColor | null): ColorModelValue { + const rekaColor = sceneToRekaColor(color) + return { + rekaColor, + rgb: convertToRgb(rekaColor), + hsl: convertToHsl(rekaColor), + hsb: convertToHsb(rekaColor), + okhcl: applyOkHCLPatch(okhcl ?? rgbaToOkHCL(color), {}) + } +} + +export function sceneToRekaColor(color: Color): RGBColor { + return { + space: 'rgb', + r: color.r * 255, + g: color.g * 255, + b: color.b * 255, + alpha: color.a + } +} + +export function rekaToSceneColor(color: RekaColor): Color { + const rgb = convertToRgb(color) + return rgba255ToColor(rgb.r, rgb.g, rgb.b, rgb.alpha) +} + +export function withHue(model: ColorModelValue, hue: number): Color { + return rekaToSceneColor({ + ...model.hsb, + h: normalizeHue(hue), + s: model.hsb.s === 0 ? 100 : model.hsb.s, + b: model.hsb.b === 0 ? 100 : model.hsb.b + }) +} + +export function withAlpha(color: Color, alpha: number): Color { + return { ...color, a: clampUnit(alpha) } +} + +export function withRGBChannel(color: Color, channel: RGBChannel, value255: number): Color { + return { ...color, [channel]: clampUnit(value255 / 255) } +} + +export function withHSLChannel(model: ColorModelValue, channel: HSLChannel, value: number): Color { + const next = { + ...model.hsl, + [channel]: channel === 'h' ? normalizeHue(value) : clampPercent(value) + } + + if (channel === 's' && model.hsl.s === 0 && clampPercent(value) > 0) { + if (model.hsl.l >= 100 || model.hsl.l <= 0) next.l = 50 + } + + return rekaToSceneColor(next) +} + +export function withHSBChannel(model: ColorModelValue, channel: HSBChannel, value: number): Color { + return rekaToSceneColor({ + ...model.hsb, + [channel]: channel === 'h' ? normalizeHue(value) : clampPercent(value) + }) +} + +export function normalizeOkHCLPatch(channel: OkHCLChannel, value: number): Partial { + switch (channel) { + case 'h': + return { h: normalizeHue(value) } + case 'c': + return { c: Math.max(0, value) } + case 'l': + return { l: clampUnit(value) } + case 'a': + return { a: clampUnit(value) } + default: + throw new Error('Unsupported OkHCL channel') + } +} + +export function applyOkHCLPatch(color: OkHCLColor, patch: Partial): OkHCLColor { + return { + h: normalizeHue(patch.h ?? color.h), + c: Math.max(0, patch.c ?? color.c), + l: clampUnit(patch.l ?? color.l), + a: clampUnit(patch.a ?? color.a ?? 1) + } +} + +export function createSliderPreviewModel(model: ColorModelValue): SliderPreviewModel { + return { + hue: rekaToSceneColor({ ...model.hsb, s: 100, b: 100 }), + hslSaturation: rekaToSceneColor(model.hsl), + hslLightness: rekaToSceneColor(model.hsl), + hsbSaturation: rekaToSceneColor(model.hsb), + hsbBrightness: rekaToSceneColor(model.hsb) + } +} + +export function createOkHCLSliderPreviewModel(color: OkHCLColor): OkHCLSliderPreviewModel { + return { + okhclHue: okhclToRGBA({ + ...color, + c: Math.max(color.c, OKHCL_HUE_PREVIEW_MIN_CHROMA), + l: color.l <= 0 || color.l >= 1 ? OKHCL_HUE_PREVIEW_FALLBACK_LIGHTNESS : color.l + }), + okhclChroma: okhclToRGBA(color), + okhclLightness: okhclToRGBA(color) + } +} + +export function createSliderGradientModel(model: ColorModelValue): SliderGradientModel { + const hslGray = rekaToSceneColor({ ...model.hsl, s: 0 }) + const hslColor = rekaToSceneColor({ ...model.hsl, s: 100 }) + const hslBlack = rekaToSceneColor({ ...model.hsl, l: 0 }) + const hslMid = rekaToSceneColor({ ...model.hsl, l: 50 }) + const hslWhite = rekaToSceneColor({ ...model.hsl, l: 100 }) + const hsbGray = rekaToSceneColor({ ...model.hsb, s: 0 }) + const hsbColor = rekaToSceneColor({ ...model.hsb, s: 100 }) + const hsbBlack = rekaToSceneColor({ ...model.hsb, b: 0 }) + const hsbBright = rekaToSceneColor({ ...model.hsb, b: 100 }) + + return { + hslSaturation: gradient(hslGray, hslColor), + hslLightness: gradient(hslBlack, hslMid, hslWhite), + hsbSaturation: gradient(hsbGray, hsbColor), + hsbBrightness: gradient(hsbBlack, hsbBright) + } +} + +export function createOkHCLSliderGradientModel(color: OkHCLColor): OkHCLSliderGradientModel { + return { + okhclChroma: gradient( + okhclToRGBA({ ...color, c: 0 }), + okhclToRGBA({ ...color, c: OKHCL_CHROMA_MAX }) + ), + okhclLightness: gradient( + okhclToRGBA({ ...color, l: 0 }), + okhclToRGBA({ ...color, l: OKHCL_LIGHTNESS_MID }), + okhclToRGBA({ ...color, l: 1 }) + ) + } +} + +export function colorsEqual(left: Color, right: Color): boolean { + return left.r === right.r && left.g === right.g && left.b === right.b && left.a === right.a +} + +export function okhclPatchChangesColor(color: OkHCLColor, patch: Partial): boolean { + return Object.entries(patch).some(([key, value]) => color[key as keyof OkHCLColor] !== value) +} + +export function applySolidFillColor(fill: Fill, color: Color): Fill { + return { ...fill, color, opacity: color.a } +} + +export function applySolidStrokeColor(color: Color): Partial { + return { color, opacity: color.a } +} + +export function toPercent(value: number): number { + return Math.round(value * 100) +} + +export function fromPercent(value: number): number { + return clampUnit(value / 100) +} + +function gradient(...colors: Color[]): string { + return `background: linear-gradient(to right, ${colors.map(colorToCSS).join(', ')});` +} + +function clampUnit(value: number): number { + return Math.max(0, Math.min(1, value)) +} + +function clampPercent(value: number): number { + return Math.max(0, Math.min(100, value)) +} + +function normalizeHue(value: number): number { + const hue = value % 360 + return hue < 0 ? hue + 360 : hue +} diff --git a/packages/vue/src/controls/color-model/types.ts b/packages/vue/src/controls/color-model/types.ts new file mode 100644 index 000000000..17f8e242c --- /dev/null +++ b/packages/vue/src/controls/color-model/types.ts @@ -0,0 +1,66 @@ +import type { Color as RekaColor } from 'reka-ui' +import type { MaybeRefOrGetter } from 'vue' + +import type { OkHCLColor, RenderColorSpace } from '@open-pencil/core/color' +import type { Color } from '@open-pencil/scene-graph/primitives' + +export type BuiltInColorFormat = 'hex' | 'rgb' | 'hsl' | 'hsb' | 'okhcl' +export type ColorFieldFormat = BuiltInColorFormat | (string & {}) + +export type RGBChannel = 'r' | 'g' | 'b' +export type HSLChannel = 'h' | 's' | 'l' +export type HSBChannel = 'h' | 's' | 'b' +export type OkHCLChannel = 'h' | 'c' | 'l' | 'a' + +export interface ColorFieldOption { + value: ColorFieldFormat + label: string +} + +export interface OkHCLControls { + fieldFormat: ColorFieldFormat + fieldOptions: ColorFieldOption[] + okhcl: OkHCLColor | null + previewColorSpace?: RenderColorSpace + clipped?: boolean + setFieldFormat: (format: ColorFieldFormat) => void + updateOkHCL: (patch: Partial) => void +} + +export interface UseColorModelOptions { + color: MaybeRefOrGetter + okhcl?: MaybeRefOrGetter + format?: MaybeRefOrGetter + defaultFormat?: ColorFieldFormat + onUpdate?: (color: Color) => void + onUpdateOkHCL?: (patch: Partial) => void + onFormatChange?: (format: ColorFieldFormat) => void +} + +export interface SliderPreviewModel { + hue: Color + hslSaturation: Color + hslLightness: Color + hsbSaturation: Color + hsbBrightness: Color +} + +export interface OkHCLSliderPreviewModel { + okhclHue: Color + okhclChroma: Color + okhclLightness: Color +} + +export interface SliderGradientModel { + hslSaturation: string + hslLightness: string + hsbSaturation: string + hsbBrightness: string +} + +export interface OkHCLSliderGradientModel { + okhclChroma: string + okhclLightness: string +} + +export type RekaColorValue = RekaColor diff --git a/packages/vue/src/controls/color-model/use.ts b/packages/vue/src/controls/color-model/use.ts new file mode 100644 index 000000000..5ba7e9c0b --- /dev/null +++ b/packages/vue/src/controls/color-model/use.ts @@ -0,0 +1,130 @@ +import { computed, ref, toValue } from 'vue' + +import { colorToHexRaw, okhclToRGBA, parseColor } from '@open-pencil/core/color' +import type { Color } from '@open-pencil/scene-graph/primitives' + +import { + applyOkHCLPatch, + colorsEqual, + createColorModelValue, + createOkHCLSliderGradientModel, + createOkHCLSliderPreviewModel, + createSliderGradientModel, + createSliderPreviewModel, + normalizeOkHCLPatch, + okhclPatchChangesColor, + rekaToSceneColor, + withAlpha, + withHSBChannel, + withHSLChannel, + withHue, + withRGBChannel +} from '#vue/controls/color-model/model' +import type { + ColorFieldFormat, + HSBChannel, + HSLChannel, + OkHCLChannel, + RGBChannel, + RekaColorValue, + UseColorModelOptions +} from '#vue/controls/color-model/types' + +/** Built-in presentation formats understood by the color model. */ +export const BUILT_IN_COLOR_FORMATS = ['hex', 'rgb', 'hsl', 'hsb', 'okhcl'] as const + +/** + * Creates reactive color-space values, channel actions, and slider presentation data without + * requiring an editor context. + */ +export function useColorModel(options: UseColorModelOptions) { + const localFormat = ref(options.defaultFormat ?? 'rgb') + const color = computed(() => toValue(options.color)) + const sourceOkHCL = computed(() => toValue(options.okhcl)) + const value = computed(() => createColorModelValue(color.value, sourceOkHCL.value)) + const format = computed(() => toValue(options.format) ?? localFormat.value) + const hex = computed(() => colorToHexRaw(color.value)) + const sliderPreview = computed(() => createSliderPreviewModel(value.value)) + const sliderGradient = computed(() => createSliderGradientModel(value.value)) + const okhclSliderPreview = computed(() => createOkHCLSliderPreviewModel(value.value.okhcl)) + const okhclSliderGradient = computed(() => createOkHCLSliderGradientModel(value.value.okhcl)) + + function emitColor(nextColor: Color): Color { + if (!colorsEqual(color.value, nextColor)) options.onUpdate?.(nextColor) + return nextColor + } + + function updateHex(input: string) { + const parsed = parseColor(input.startsWith('#') ? input : `#${input}`) + return emitColor({ ...parsed, a: color.value.a }) + } + + function setFormat(nextFormat: ColorFieldFormat) { + if (format.value === nextFormat) return + localFormat.value = nextFormat + options.onFormatChange?.(nextFormat) + } + + function updateFromReka(nextColor: RekaColorValue) { + return emitColor(rekaToSceneColor(nextColor)) + } + + function updateHue(hue: number) { + return emitColor(withHue(value.value, hue)) + } + + function updateAlpha(alpha: number) { + return emitColor(withAlpha(color.value, alpha)) + } + + function updateRGBChannel(channel: RGBChannel, channelValue: number) { + return emitColor(withRGBChannel(color.value, channel, channelValue)) + } + + function updateHSLChannel(channel: HSLChannel, channelValue: number) { + return emitColor(withHSLChannel(value.value, channel, channelValue)) + } + + function updateHSBChannel(channel: HSBChannel, channelValue: number) { + return emitColor(withHSBChannel(value.value, channel, channelValue)) + } + + function updateOkHCLChannel(channel: OkHCLChannel, channelValue: number) { + const patch = normalizeOkHCLPatch(channel, channelValue) + if (!okhclPatchChangesColor(value.value.okhcl, patch)) return value.value.okhcl + + if (options.onUpdateOkHCL) { + options.onUpdateOkHCL(patch) + return applyOkHCLPatch(value.value.okhcl, patch) + } + + const next = applyOkHCLPatch(value.value.okhcl, patch) + emitColor(okhclToRGBA(next)) + return next + } + + return { + color, + format, + hex, + rekaColor: computed(() => value.value.rekaColor), + rgb: computed(() => value.value.rgb), + hsl: computed(() => value.value.hsl), + hsb: computed(() => value.value.hsb), + okhcl: computed(() => value.value.okhcl), + sliderPreview, + sliderGradient, + okhclSliderPreview, + okhclSliderGradient, + setFormat, + updateColor: emitColor, + updateHex, + updateFromReka, + updateHue, + updateAlpha, + updateRGBChannel, + updateHSLChannel, + updateHSBChannel, + updateOkHCLChannel + } +} diff --git a/packages/vue/src/controls/okhcl/helpers.ts b/packages/vue/src/controls/okhcl/helpers.ts index 574d036eb..809358dc6 100644 --- a/packages/vue/src/controls/okhcl/helpers.ts +++ b/packages/vue/src/controls/okhcl/helpers.ts @@ -13,7 +13,7 @@ import { BLACK } from '@open-pencil/core/constants' import type { Editor } from '@open-pencil/core/editor' import type { SceneNode } from '@open-pencil/scene-graph' -import type { ColorFieldFormat } from '#vue/primitives/ColorPicker/types' +import type { ColorFieldFormat } from '#vue/controls/color-model/types' type ColorKind = 'fill' | 'stroke' diff --git a/packages/vue/src/controls/okhcl/use.ts b/packages/vue/src/controls/okhcl/use.ts index 3317892a5..11567c2e6 100644 --- a/packages/vue/src/controls/okhcl/use.ts +++ b/packages/vue/src/controls/okhcl/use.ts @@ -1,5 +1,6 @@ import { ref } from 'vue' +import type { ColorFieldFormat } from '#vue/controls/color-model/types' import { OKHCL_FIELD_OPTIONS, createOkHCLActions, @@ -9,7 +10,6 @@ import { getStrokeOkHCLColor } from '#vue/controls/okhcl/helpers' import { useEditor } from '#vue/editor/context' -import type { ColorFieldFormat } from '#vue/primitives/ColorPicker/types' export function useOkHCL() { const editor = useEditor() diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 00a7475fa..c6b7e1c36 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -91,6 +91,21 @@ export type { } from '#vue/controls/variable-binding/use' export { useEffectsControls } from '#vue/controls/effects/use' export { useStrokeControls } from '#vue/controls/stroke/use' +export { + applySolidFillColor, + applySolidStrokeColor, + BUILT_IN_COLOR_FORMATS, + fromPercent, + toPercent, + useColorModel +} from '#vue/controls/color-model' +export type { + BuiltInColorFormat, + ColorFieldFormat, + ColorFieldOption, + OkHCLControls, + UseColorModelOptions +} from '#vue/controls/color-model' export { useOkHCL } from '#vue/controls/okhcl/use' /** Variables, page navigation, and picker helpers. */ @@ -106,26 +121,7 @@ export { useFontPicker } from '#vue/primitives/FontPicker/useFontPicker' /** Headless structural primitives and their local contexts. */ export { CanvasRoot, CanvasSurface, useCanvasContext } from '#vue/canvas' export type { CanvasContext } from '#vue/canvas' -export { - ColorInputRoot, - ColorPickerRoot, - createColorPickerModel, - createOkHCLSliderGradientModel, - createOkHCLSliderPreviewModel, - createSliderGradientModel, - createSliderPreviewModel, - fromPercent, - rekaToAppColor, - toPercent, - updateAlpha, - updateHSBChannel, - updateHSLChannel, - updateHue, - updateRGBChannel, - applySolidFillColor, - applySolidStrokeColor -} from '#vue/primitives/ColorPicker' -export type { ColorFieldFormat, OkHCLControls } from '#vue/primitives/ColorPicker' +export { ColorInputRoot, ColorPickerRoot } from '#vue/primitives/ColorPicker' export { FillPickerRoot } from '#vue/primitives/FillPicker' export { FontPickerRoot } from '#vue/primitives/FontPicker' export type { FontFamilyOption, FontPickerUI } from '#vue/primitives/FontPicker' diff --git a/packages/vue/src/primitives/ColorPicker/ColorInputRoot.vue b/packages/vue/src/primitives/ColorPicker/ColorInputRoot.vue index 5c035881b..fee775759 100644 --- a/packages/vue/src/primitives/ColorPicker/ColorInputRoot.vue +++ b/packages/vue/src/primitives/ColorPicker/ColorInputRoot.vue @@ -1,9 +1,8 @@ diff --git a/packages/vue/src/primitives/ColorPicker/index.ts b/packages/vue/src/primitives/ColorPicker/index.ts index f29d15091..51586bcfc 100644 --- a/packages/vue/src/primitives/ColorPicker/index.ts +++ b/packages/vue/src/primitives/ColorPicker/index.ts @@ -1,20 +1,2 @@ export { default as ColorInputRoot } from '#vue/primitives/ColorPicker/ColorInputRoot.vue' export { default as ColorPickerRoot } from '#vue/primitives/ColorPicker/ColorPickerRoot.vue' -export { - createColorPickerModel, - createOkHCLSliderGradientModel, - createOkHCLSliderPreviewModel, - createSliderGradientModel, - createSliderPreviewModel, - fromPercent, - rekaToAppColor, - toPercent, - updateAlpha, - updateHSBChannel, - updateHSLChannel, - updateHue, - updateRGBChannel, - applySolidFillColor, - applySolidStrokeColor -} from '#vue/primitives/ColorPicker/model' -export type { ColorFieldFormat, OkHCLControls } from '#vue/primitives/ColorPicker/types' diff --git a/packages/vue/src/primitives/ColorPicker/model.ts b/packages/vue/src/primitives/ColorPicker/model.ts deleted file mode 100644 index 1a68739d2..000000000 --- a/packages/vue/src/primitives/ColorPicker/model.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { convertToHsb, convertToHsl, convertToRgb } from 'reka-ui' -import type { Color as RekaColor, HSBColor, HSLColor, RGBColor } from 'reka-ui' - -import { colorToCSS, okhclToRGBA, rgba255ToColor } from '@open-pencil/core/color' -import type { OkHCLColor } from '@open-pencil/core/color' -import type { Fill, Stroke } from '@open-pencil/scene-graph' -import type { Color } from '@open-pencil/scene-graph/primitives' - -export interface ColorPickerModel { - rekaColor: RekaColor - rgb: RGBColor - hsl: HSLColor - hsb: HSBColor -} - -export interface SliderPreviewModel { - hue: Color - hslSaturation: Color - hslLightness: Color - hsbSaturation: Color - hsbBrightness: Color -} - -export interface OkHCLSliderPreviewModel { - okhclHue: Color - okhclChroma: Color - okhclLightness: Color -} - -export interface SliderGradientModel { - hslSaturation: string - hslLightness: string - hsbSaturation: string - hsbBrightness: string -} - -export interface OkHCLSliderGradientModel { - okhclChroma: string - okhclLightness: string -} - -const OKHCL_CHROMA_MAX = 0.4 -const OKHCL_LIGHTNESS_MID = 0.5 -const OKHCL_HUE_PREVIEW_MIN_CHROMA = 0.15 -const OKHCL_HUE_PREVIEW_FALLBACK_LIGHTNESS = 0.7 - -export function createColorPickerModel(color: Color): ColorPickerModel { - const rekaColor = { - space: 'rgb' as const, - r: Math.round(color.r * 255), - g: Math.round(color.g * 255), - b: Math.round(color.b * 255), - alpha: color.a - } - - return { - rekaColor, - rgb: convertToRgb(rekaColor), - hsl: convertToHsl(rekaColor), - hsb: convertToHsb(rekaColor) - } -} - -export function rekaToAppColor(color: RekaColor): Color { - const rgb = convertToRgb(color) - return rgba255ToColor(rgb.r, rgb.g, rgb.b, rgb.alpha) -} - -export function updateHue(model: ColorPickerModel, hue: number): Color { - const nextSaturation = model.hsb.s === 0 ? 100 : model.hsb.s - const nextBrightness = model.hsb.b === 0 ? 100 : model.hsb.b - - return rekaToAppColor({ - ...model.hsb, - h: hue, - s: nextSaturation, - b: nextBrightness - }) -} - -export function updateAlpha(color: Color, alpha: number): Color { - return { - ...color, - a: clampUnit(alpha) - } -} - -export function updateRGBChannel(color: Color, channel: 'r' | 'g' | 'b', value255: number): Color { - return { - ...color, - [channel]: clampUnit(value255 / 255) - } -} - -export function updateHSLChannel( - model: ColorPickerModel, - channel: 'h' | 's' | 'l', - value: number -): Color { - const next = { - ...model.hsl, - [channel]: channel === 'h' ? value : clampPercent(value) - } - - if (channel === 's' && model.hsl.s === 0 && clampPercent(value) > 0) { - next.h = model.hsl.h - if (model.hsl.l >= 100) next.l = 50 - if (model.hsl.l <= 0) next.l = 50 - } - - return rekaToAppColor(next) -} - -export function updateHSBChannel( - model: ColorPickerModel, - channel: 'h' | 's' | 'b', - value: number -): Color { - return rekaToAppColor({ - ...model.hsb, - [channel]: channel === 'h' ? value : clampPercent(value) - }) -} - -export function createSliderPreviewModel(model: ColorPickerModel): SliderPreviewModel { - return { - hue: rekaToAppColor({ - ...model.hsb, - s: 100, - b: 100 - }), - hslSaturation: rekaToAppColor(model.hsl), - hslLightness: rekaToAppColor(model.hsl), - hsbSaturation: rekaToAppColor(model.hsb), - hsbBrightness: rekaToAppColor(model.hsb) - } -} - -export function createOkHCLSliderPreviewModel(color: OkHCLColor): OkHCLSliderPreviewModel { - return { - okhclHue: okhclToRGBA({ - ...color, - c: Math.max(color.c, OKHCL_HUE_PREVIEW_MIN_CHROMA), - l: color.l <= 0 || color.l >= 1 ? OKHCL_HUE_PREVIEW_FALLBACK_LIGHTNESS : color.l - }), - okhclChroma: okhclToRGBA(color), - okhclLightness: okhclToRGBA(color) - } -} - -export function createOkHCLSliderGradientModel(color: OkHCLColor): OkHCLSliderGradientModel { - const lowChroma = okhclToRGBA({ ...color, c: 0 }) - const highChroma = okhclToRGBA({ ...color, c: OKHCL_CHROMA_MAX }) - const lowLightness = okhclToRGBA({ ...color, l: 0 }) - const midLightness = okhclToRGBA({ ...color, l: OKHCL_LIGHTNESS_MID }) - const highLightness = okhclToRGBA({ ...color, l: 1 }) - - return { - okhclChroma: `background: linear-gradient(to right, ${colorToCSS(lowChroma)}, ${colorToCSS(highChroma)});`, - okhclLightness: `background: linear-gradient(to right, ${colorToCSS(lowLightness)}, ${colorToCSS(midLightness)}, ${colorToCSS(highLightness)});` - } -} - -export function createSliderGradientModel(model: ColorPickerModel): SliderGradientModel { - const hslGray = rekaToAppColor({ - ...model.hsl, - s: 0 - }) - const hslColor = rekaToAppColor({ - ...model.hsl, - s: 100 - }) - const hslBlack = rekaToAppColor({ - ...model.hsl, - l: 0 - }) - const hslMid = rekaToAppColor({ - ...model.hsl, - l: 50 - }) - const hslWhite = rekaToAppColor({ - ...model.hsl, - l: 100 - }) - const hsbGray = rekaToAppColor({ - ...model.hsb, - s: 0 - }) - const hsbColor = rekaToAppColor({ - ...model.hsb, - s: 100 - }) - const hsbBlack = rekaToAppColor({ - ...model.hsb, - b: 0 - }) - const hsbBright = rekaToAppColor({ - ...model.hsb, - b: 100 - }) - - return { - hslSaturation: `background: linear-gradient(to right, ${colorToCSS(hslGray)}, ${colorToCSS(hslColor)});`, - hslLightness: `background: linear-gradient(to right, ${colorToCSS(hslBlack)}, ${colorToCSS(hslMid)}, ${colorToCSS(hslWhite)});`, - hsbSaturation: `background: linear-gradient(to right, ${colorToCSS(hsbGray)}, ${colorToCSS(hsbColor)});`, - hsbBrightness: `background: linear-gradient(to right, ${colorToCSS(hsbBlack)}, ${colorToCSS(hsbBright)});` - } -} - -export function toPercent(value: number): number { - return Math.round(value * 100) -} - -export function fromPercent(value: number): number { - return clampUnit(value / 100) -} - -function clampUnit(value: number): number { - return Math.max(0, Math.min(1, value)) -} - -function clampPercent(value: number): number { - return Math.max(0, Math.min(100, value)) -} - -export function applySolidFillColor(fill: Fill, color: Color): Fill { - return { - ...fill, - color, - opacity: color.a - } -} - -export function applySolidStrokeColor(color: Color): Partial { - return { - color, - opacity: color.a - } -} diff --git a/packages/vue/src/primitives/ColorPicker/types.ts b/packages/vue/src/primitives/ColorPicker/types.ts deleted file mode 100644 index 4375edab8..000000000 --- a/packages/vue/src/primitives/ColorPicker/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { OkHCLColor, RenderColorSpace } from '@open-pencil/core/color' - -export type ColorFieldFormat = 'rgb' | 'hsl' | 'hsb' | 'okhcl' - -export interface ColorFieldOption { - value: ColorFieldFormat - label: string -} - -export interface OkHCLControls { - fieldFormat: ColorFieldFormat - fieldOptions: ColorFieldOption[] - okhcl: OkHCLColor | null - previewColorSpace?: RenderColorSpace - clipped?: boolean - setFieldFormat: (format: ColorFieldFormat) => void - updateOkHCL: (patch: Partial) => void -} diff --git a/src/components/color-picker-panel/context.ts b/src/components/color-picker-panel/context.ts index b68ee477c..d4424b7f3 100644 --- a/src/components/color-picker-panel/context.ts +++ b/src/components/color-picker-panel/context.ts @@ -2,48 +2,28 @@ import { computed, inject, provide, proxyRefs } from 'vue' import type { InjectionKey, ShallowUnwrapRef } from 'vue' import type { Color } from '@open-pencil/scene-graph/primitives' -import { - createColorPickerModel, - createOkHCLSliderGradientModel, - createOkHCLSliderPreviewModel, - createSliderGradientModel, - createSliderPreviewModel, - rekaToAppColor, - updateAlpha, - updateHSBChannel, - updateHSLChannel, - updateHue, - updateRGBChannel, - useI18n -} from '@open-pencil/vue' -import type { OkHCLControls } from '@open-pencil/vue' +import { useColorModel, useI18n } from '@open-pencil/vue' +import type { ColorFieldFormat, OkHCLControls } from '@open-pencil/vue' -type ColorPanelProps = { +interface ColorPanelProps { color: Color okhcl?: OkHCLControls | null } type ColorPanelEmit = (event: 'update', color: Color) => void -type RekaColor = ReturnType['rekaColor'] - function createColorPickerPanelContext(props: ColorPanelProps, emit: ColorPanelEmit) { const { panels } = useI18n() const color = computed(() => props.color) const okhcl = computed(() => props.okhcl ?? null) - const pickerModel = computed(() => createColorPickerModel(color.value)) - const rekaColor = computed(() => pickerModel.value.rekaColor) - const hslColor = computed(() => pickerModel.value.hsl) - const hsbColor = computed(() => pickerModel.value.hsb) - const rgbColor = computed(() => pickerModel.value.rgb) - const sliderPreview = computed(() => createSliderPreviewModel(pickerModel.value)) - const sliderGradient = computed(() => createSliderGradientModel(pickerModel.value)) - const okhclSliderPreview = computed(() => - okhcl.value?.okhcl ? createOkHCLSliderPreviewModel(okhcl.value.okhcl) : null - ) - const okhclSliderGradient = computed(() => - okhcl.value?.okhcl ? createOkHCLSliderGradientModel(okhcl.value.okhcl) : null - ) + const colorModel = useColorModel({ + color, + okhcl: () => okhcl.value?.okhcl, + format: () => okhcl.value?.fieldFormat, + onUpdate: (nextColor) => emit('update', nextColor), + onUpdateOkHCL: (patch) => okhcl.value?.updateOkHCL(patch), + onFormatChange: (format) => okhcl.value?.setFieldFormat(format) + }) const fieldOptions = computed( () => okhcl.value?.fieldOptions ?? [ @@ -52,69 +32,35 @@ function createColorPickerPanelContext(props: ColorPanelProps, emit: ColorPanelE { value: 'hsb', label: panels.value.colorFormatHsb } ] ) - const fieldFormat = computed(() => okhcl.value?.fieldFormat ?? 'rgb') - const isOkHCLFormat = computed(() => fieldFormat.value === 'okhcl' && okhcl.value) - - function updateColor(nextColor: Color) { - emit('update', nextColor) - } - - function onRekaColorUpdate(colorValue: RekaColor) { - updateColor(rekaToAppColor(colorValue)) - } + const isOkHCLFormat = computed(() => colorModel.format.value === 'okhcl' && okhcl.value) function setFieldFormat(value: string) { - okhcl.value?.setFieldFormat(value as NonNullable['fieldFormat']) - } - - function updateRGBAHue(value: number) { - updateColor(updateHue(pickerModel.value, value)) - } - - function updateRGBAAlpha(value: number) { - updateColor(updateAlpha(color.value, value)) - } - - function updateRGBChannelValue(channel: 'r' | 'g' | 'b', value: number) { - updateColor(updateRGBChannel(color.value, channel, value)) - } - - function updateHSLChannelValue(channel: 'h' | 's' | 'l', value: number) { - updateColor(updateHSLChannel(pickerModel.value, channel, value)) - } - - function updateHSBChannelValue(channel: 'h' | 's' | 'b', value: number) { - updateColor(updateHSBChannel(pickerModel.value, channel, value)) - } - - function updateOkHCLChannel(channel: 'h' | 'c' | 'l' | 'a', value: number) { - okhcl.value?.updateOkHCL({ [channel]: value }) + colorModel.setFormat(value as ColorFieldFormat) } return { panels, color, okhcl, - pickerModel, - rekaColor, - hslColor, - hsbColor, - rgbColor, - sliderPreview, - sliderGradient, - okhclSliderPreview, - okhclSliderGradient, + rekaColor: colorModel.rekaColor, + hslColor: colorModel.hsl, + hsbColor: colorModel.hsb, + rgbColor: colorModel.rgb, + sliderPreview: colorModel.sliderPreview, + sliderGradient: colorModel.sliderGradient, + okhclSliderPreview: colorModel.okhclSliderPreview, + okhclSliderGradient: colorModel.okhclSliderGradient, fieldOptions, - fieldFormat, + fieldFormat: colorModel.format, isOkHCLFormat, - onRekaColorUpdate, + onRekaColorUpdate: colorModel.updateFromReka, setFieldFormat, - updateRGBAHue, - updateRGBAAlpha, - updateRGBChannelValue, - updateHSLChannelValue, - updateHSBChannelValue, - updateOkHCLChannel + updateRGBAHue: colorModel.updateHue, + updateRGBAAlpha: colorModel.updateAlpha, + updateRGBChannelValue: colorModel.updateRGBChannel, + updateHSLChannelValue: colorModel.updateHSLChannel, + updateHSBChannelValue: colorModel.updateHSBChannel, + updateOkHCLChannel: colorModel.updateOkHCLChannel } } diff --git a/tests/e2e/color-picker/basic.spec.ts b/tests/e2e/color-picker/basic.spec.ts index e5a7628ba..ea615015a 100644 --- a/tests/e2e/color-picker/basic.spec.ts +++ b/tests/e2e/color-picker/basic.spec.ts @@ -1,5 +1,7 @@ import { expect, test, type Page } from '@playwright/test' +import type { OkHCLPayload } from '@open-pencil/core/color' + import { CanvasHelper } from '#tests/helpers/canvas' let page: Page @@ -29,6 +31,22 @@ async function getSelectedFill() { }) } +async function getSelectedFillOkHCL() { + return page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + const id = [...store.state.selectedIds][0] + if (!id) return null + const node = store.graph.getNode(id) + const entry = node?.pluginData.find( + (value) => value.pluginId === 'open-pencil' && value.key === 'okhcl' + ) + if (!entry) return null + const payload = JSON.parse(entry.value) as Partial + return payload.kind === 'fill' && payload.index === 0 ? (payload.color ?? null) : null + }) +} + async function openFillPicker() { const solidTab = page.getByTestId('fill-picker-tab-solid') if (await solidTab.isVisible().catch(() => false)) return @@ -138,3 +156,19 @@ test('hsb saturation and brightness sliders both affect fill color', async () => beforeB?.color.b !== afterB?.color.b ).toBe(true) }) + +test('okhcl channels preserve intent metadata while updating the fill', async () => { + await openFillPicker() + await chooseFormat('OkHCL') + + await dragSlider('color-slider-okhcl-c', 0.6) + const afterChroma = await getSelectedFill() + const chromaIntent = await getSelectedFillOkHCL() + expect(afterChroma).not.toBeNull() + expect(chromaIntent?.c).toBeGreaterThan(0) + + await dragSlider('color-slider-okhcl-l', 0.75) + const lightnessIntent = await getSelectedFillOkHCL() + expect(lightnessIntent?.l).toBeCloseTo(0.75, 1) + expect(lightnessIntent?.c).toBeCloseTo(chromaIntent?.c ?? 0, 3) +}) diff --git a/tests/engine/color/color-model.test.ts b/tests/engine/color/color-model.test.ts new file mode 100644 index 000000000..d9386a125 --- /dev/null +++ b/tests/engine/color/color-model.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from 'bun:test' + +import { ref } from 'vue' + +import type { Color, OkHCLColor } from '@open-pencil/core' +import type { ColorFieldFormat } from '@open-pencil/vue' +import { BUILT_IN_COLOR_FORMATS, fromPercent, toPercent, useColorModel } from '@open-pencil/vue' + +function expectColorClose(actual: Color, expected: Color, precision = 5) { + expect(actual.r).toBeCloseTo(expected.r, precision) + expect(actual.g).toBeCloseTo(expected.g, precision) + expect(actual.b).toBeCloseTo(expected.b, precision) + expect(actual.a).toBeCloseTo(expected.a, precision) +} + +describe('useColorModel', () => { + const base: Color = { r: 0.4, g: 0.2, b: 0.6, a: 0.75 } + + test('bridges scene and Reka RGB without quantizing channels', () => { + const updates: Color[] = [] + const model = useColorModel({ color: base, onUpdate: (color) => updates.push(color) }) + + expect(model.rekaColor.value).toEqual({ + space: 'rgb', + r: 102, + g: 51, + b: 153, + alpha: 0.75 + }) + + const next = model.updateFromReka(model.rekaColor.value) + expectColorClose(next, base) + expect(updates).toHaveLength(0) + }) + + test('preserves fractional RGB precision through the Reka bridge', () => { + const precise: Color = { r: 0.1234, g: 0.5678, b: 0.9012, a: 0.3456 } + const model = useColorModel({ color: precise }) + expectColorClose(model.updateFromReka(model.rekaColor.value), precise, 8) + }) + + test('updates hue from grayscale colors without staying neutral', () => { + const grayscale: Color = { r: 1, g: 1, b: 1, a: 1 } + const model = useColorModel({ color: grayscale }) + const updated = model.updateHue(220) + expect(updated.r === updated.g && updated.g === updated.b).toBe(false) + expect(updated.a).toBe(1) + }) + + test('formats and updates hex while preserving alpha', () => { + const updates: Color[] = [] + const model = useColorModel({ color: base, onUpdate: (color) => updates.push(color) }) + expect(model.hex.value).toBe('663399') + const updated = model.updateHex('FF8040') + expect(updated).toEqual({ r: 1, g: 128 / 255, b: 64 / 255, a: base.a }) + expect(updates).toEqual([updated]) + }) + + test('normalizes hue and clamps alpha and RGB channels', () => { + const model = useColorModel({ color: base }) + expect(model.updateHue(-20)).toBeDefined() + expect(model.updateAlpha(2).a).toBe(1) + expect(model.updateRGBChannel('r', -20).r).toBe(0) + expect(model.updateRGBChannel('b', 300).b).toBe(1) + }) + + test('updates HSL saturation from white without staying neutral', () => { + const white: Color = { r: 1, g: 1, b: 1, a: 1 } + const model = useColorModel({ color: white }) + const updated = model.updateHSLChannel('s', 40) + expect(updated.r === updated.g && updated.g === updated.b).toBe(false) + }) + + test('updates HSL and HSB channels while preserving alpha', () => { + const model = useColorModel({ color: base }) + expect(model.updateHSLChannel('l', 12.3).a).toBeCloseTo(base.a, 5) + expect(model.updateHSBChannel('b', 45.6).a).toBeCloseTo(base.a, 5) + }) + + test('uses supplied OkHCL intent and emits normalized patches', () => { + const okhcl: OkHCLColor = { h: 250, c: 0.18, l: 0.62, a: 0.75 } + const patches: Partial[] = [] + const model = useColorModel({ + color: base, + okhcl, + onUpdateOkHCL: (patch) => { + patches.push(patch) + } + }) + + expect(model.okhcl.value).toEqual(okhcl) + expect(model.updateOkHCLChannel('h', 370).h).toBe(10) + expect(model.updateOkHCLChannel('c', -1).c).toBe(0) + expect(model.updateOkHCLChannel('l', 2).l).toBe(1) + expect(model.updateOkHCLChannel('a', -1).a).toBe(0) + expect(patches).toEqual([{ h: 10 }, { c: 0 }, { l: 1 }, { a: 0 }]) + }) + + test('updates scene color when an OkHCL adapter is absent', () => { + const updates: Color[] = [] + const model = useColorModel({ color: base, onUpdate: (color) => updates.push(color) }) + model.updateOkHCLChannel('h', model.okhcl.value.h + 20) + expect(updates).toHaveLength(1) + expect(updates[0].a).toBeCloseTo(base.a, 5) + }) + + test('does not emit no-op color or OkHCL updates', () => { + const updates: Color[] = [] + const patches: Partial[] = [] + const okhcl: OkHCLColor = { h: 250, c: 0.18, l: 0.62, a: 0.75 } + const model = useColorModel({ + color: base, + okhcl, + onUpdate: (color) => updates.push(color), + onUpdateOkHCL: (patch) => { + patches.push(patch) + } + }) + + model.updateAlpha(base.a) + model.updateOkHCLChannel('c', okhcl.c) + expect(updates).toHaveLength(0) + expect(patches).toHaveLength(0) + }) + + test('supports local and controlled format state', () => { + const local = useColorModel({ color: base, defaultFormat: 'hsl' }) + expect(local.format.value).toBe('hsl') + local.setFormat('okhcl') + expect(local.format.value).toBe('okhcl') + + const controlledFormat = ref('rgb') + const changes: string[] = [] + const controlled = useColorModel({ + color: base, + format: controlledFormat, + onFormatChange: (format) => changes.push(format) + }) + controlled.setFormat('hsb') + expect(controlled.format.value).toBe('rgb') + expect(changes).toEqual(['hsb']) + controlledFormat.value = 'hsb' + expect(controlled.format.value).toBe('hsb') + }) + + test('exposes built-in formats and culori-backed gradient models', () => { + const model = useColorModel({ color: base }) + expect(BUILT_IN_COLOR_FORMATS).toEqual(['hex', 'rgb', 'hsl', 'hsb', 'okhcl']) + expect(model.sliderGradient.value.hslLightness).toContain('linear-gradient') + expect(model.sliderGradient.value.hsbBrightness).toContain('linear-gradient') + expect(model.okhclSliderGradient.value.okhclChroma).toContain('linear-gradient') + expect(model.okhclSliderPreview.value.okhclHue.a).toBeCloseTo(base.a, 5) + }) + + test('percent helpers roundtrip safely', () => { + expect(fromPercent(toPercent(0.347))).toBeCloseTo(0.35, 2) + }) +}) diff --git a/tests/engine/color/picker-model.test.ts b/tests/engine/color/picker-model.test.ts deleted file mode 100644 index d19604032..000000000 --- a/tests/engine/color/picker-model.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, test } from 'bun:test' - -import type { Color } from '@open-pencil/core' -import { - createColorPickerModel, - fromPercent, - toPercent, - updateAlpha, - updateHSBChannel, - updateHSLChannel, - updateHue, - updateRGBChannel -} from '@open-pencil/vue' - -describe('color picker model', () => { - const base: Color = { r: 0.4, g: 0.2, b: 0.6, a: 0.75 } - - test('updates hue from the shared slider', () => { - const model = createColorPickerModel(base) - const updated = updateHue(model, 180) - expect(updated).not.toEqual(base) - expect(updated.a).toBeCloseTo(base.a, 5) - }) - - test('updates hue from grayscale colors without staying neutral', () => { - const grayscale: Color = { r: 1, g: 1, b: 1, a: 1 } - const updated = updateHue(createColorPickerModel(grayscale), 220) - expect(updated.r === updated.g && updated.g === updated.b).toBe(false) - }) - - test('updates alpha independently', () => { - const updated = updateAlpha(base, 0.25) - expect(updated.a).toBeCloseTo(0.25, 5) - expect(updated.r).toBeCloseTo(base.r, 5) - }) - - test('updates rgb channels in 0-255 space', () => { - const updated = updateRGBChannel(base, 'r', 255) - expect(updated.r).toBeCloseTo(1, 5) - expect(updated.g).toBeCloseTo(base.g, 5) - }) - - test('updates hsl normalized channels smoothly', () => { - const model = createColorPickerModel(base) - const updated = updateHSLChannel(model, 's', 12.3) - expect(updated.a).toBeCloseTo(base.a, 5) - expect(updated).not.toEqual(base) - }) - - test('updates hsl saturation from grayscale colors without staying neutral', () => { - const grayscale: Color = { r: 1, g: 1, b: 1, a: 1 } - const updated = updateHSLChannel(createColorPickerModel(grayscale), 's', 40) - expect(updated.r === updated.g && updated.g === updated.b).toBe(false) - }) - - test('updates hsb normalized channels smoothly', () => { - const model = createColorPickerModel(base) - const updated = updateHSBChannel(model, 'b', 45.6) - expect(updated.a).toBeCloseTo(base.a, 5) - expect(updated).not.toEqual(base) - }) - - test('percent helpers roundtrip safely', () => { - expect(fromPercent(toPercent(0.347))).toBeCloseTo(0.35, 2) - }) -})