feat(vue): consolidate color model
- Add a reactive useColorModel API for format state, channel edits, Reka bridges, and slider presentation - Move the app color panel and ColorInputRoot onto the shared model and remove superseded picker helpers - Document the public contract and cover precise RGB plus OkHCL intent round trips
This commit is contained in:
parent
fcdf7f1d4c
commit
bf90a70d05
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}))
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<Color>({ 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)
|
||||
|
|
@ -42,6 +42,7 @@ These contain structural/headless primitives and local helpers.
|
|||
- `usePosition`
|
||||
- `useLayout`
|
||||
- `useAppearance`
|
||||
- `useColorModel`
|
||||
- `useTypography`
|
||||
- `useExport`
|
||||
- `useFillControls`
|
||||
|
|
|
|||
|
|
@ -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()`
|
||||
|
|
|
|||
14
packages/vue/src/controls/color-model/index.ts
Normal file
14
packages/vue/src/controls/color-model/index.ts
Normal file
|
|
@ -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'
|
||||
214
packages/vue/src/controls/color-model/model.ts
Normal file
214
packages/vue/src/controls/color-model/model.ts
Normal file
|
|
@ -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<OkHCLColor> {
|
||||
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>): 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<OkHCLColor>): 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<Stroke> {
|
||||
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
|
||||
}
|
||||
66
packages/vue/src/controls/color-model/types.ts
Normal file
66
packages/vue/src/controls/color-model/types.ts
Normal file
|
|
@ -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<OkHCLColor>) => void
|
||||
}
|
||||
|
||||
export interface UseColorModelOptions {
|
||||
color: MaybeRefOrGetter<Color>
|
||||
okhcl?: MaybeRefOrGetter<OkHCLColor | null | undefined>
|
||||
format?: MaybeRefOrGetter<ColorFieldFormat | undefined>
|
||||
defaultFormat?: ColorFieldFormat
|
||||
onUpdate?: (color: Color) => void
|
||||
onUpdateOkHCL?: (patch: Partial<OkHCLColor>) => 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
|
||||
130
packages/vue/src/controls/color-model/use.ts
Normal file
130
packages/vue/src/controls/color-model/use.ts
Normal file
|
|
@ -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<ColorFieldFormat>(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
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { colorToHexRaw, parseColor } from '@open-pencil/core/color'
|
||||
import { useColorModel } from '#vue/controls/color-model/use'
|
||||
import type { OkHCLControls } from '#vue/controls/color-model/types'
|
||||
|
||||
import type { Color } from '@open-pencil/scene-graph/primitives'
|
||||
import type { OkHCLControls } from '#vue/primitives/ColorPicker/types'
|
||||
|
||||
const {
|
||||
color,
|
||||
|
|
@ -16,20 +15,23 @@ const {
|
|||
}>()
|
||||
|
||||
const emit = defineEmits<{ update: [color: Color] }>()
|
||||
|
||||
const hex = computed(() => colorToHexRaw(color))
|
||||
|
||||
function updateFromHex(value: string) {
|
||||
const parsed = parseColor(value.startsWith('#') ? value : `#${value}`)
|
||||
emit('update', { ...parsed, a: color.a })
|
||||
}
|
||||
const model = useColorModel({
|
||||
color: () => color,
|
||||
onUpdate: (nextColor) => emit('update', nextColor)
|
||||
})
|
||||
|
||||
const actions = {
|
||||
updateFromHex,
|
||||
updateColor: (nextColor: Color) => emit('update', nextColor)
|
||||
updateFromHex: model.updateHex,
|
||||
updateColor: model.updateColor
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot :color="color" :editable="editable" :hex="hex" :actions="actions" :okhcl="okhcl" />
|
||||
<slot
|
||||
:color="color"
|
||||
:editable="editable"
|
||||
:hex="model.hex.value"
|
||||
:actions="actions"
|
||||
:okhcl="okhcl"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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<Stroke> {
|
||||
return {
|
||||
color,
|
||||
opacity: color.a
|
||||
}
|
||||
}
|
||||
|
|
@ -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<OkHCLColor>) => void
|
||||
}
|
||||
|
|
@ -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<typeof createColorPickerModel>['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<OkHCLControls>['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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<OkHCLPayload>
|
||||
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)
|
||||
})
|
||||
|
|
|
|||
158
tests/engine/color/color-model.test.ts
Normal file
158
tests/engine/color/color-model.test.ts
Normal file
|
|
@ -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<OkHCLColor>[] = []
|
||||
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<OkHCLColor>[] = []
|
||||
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<ColorFieldFormat>('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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue