feat(vue): promote color and fill primitives

- Add accessible OkHCL channel sliders and use Reka ColorSlider for standard color channels

- Split fill state and binding-aware swatches from application popover composition

- Add keyboard-operable gradient stops, shared demos, generated docs, and focused coverage
This commit is contained in:
Danila Poyarkov 2026-07-13 23:41:04 +03:00
parent bf90a70d05
commit 422f83331f
51 changed files with 1679 additions and 489 deletions

View file

@ -21,6 +21,7 @@
- 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.
- Add accessible color-channel sliders, binding-aware fill primitives, and keyboard-operable gradient stops while separating fill state from popover composition.
- 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.

View file

@ -14,11 +14,14 @@ const SDK_COMPONENT_PAGES = [
{ text: 'PropertyListItem', slug: 'property-list-item' },
{ text: 'ColorPickerRoot', slug: 'color-picker-root' },
{ text: 'ColorInputRoot', slug: 'color-input-root' },
{ text: 'FillPickerRoot', slug: 'fill-picker-root' },
{ text: 'ChannelSlider', slug: 'channel-slider', canonical: true },
{ text: 'FillRoot', slug: 'fill-root', canonical: true },
{ text: 'FillSwatch', slug: 'fill-swatch', canonical: true },
{ text: 'FillPickerRoot', slug: 'fill-picker-root', canonical: true },
{ text: 'FontPickerRoot', slug: 'font-picker-root' },
{ text: 'GradientEditorRoot', slug: 'gradient-editor-root' },
{ text: 'GradientEditorBar', slug: 'gradient-editor-bar' },
{ text: 'GradientEditorStop', slug: 'gradient-editor-stop' },
{ text: 'GradientEditorStop', slug: 'gradient-editor-stop', canonical: true },
{ text: 'NumberField', slug: 'number-field', canonical: true },
{ text: 'BindableValue', slug: 'bindable-value', canonical: true },
{ text: 'LayoutControlsRoot', slug: 'layout-controls-root' },
@ -54,7 +57,7 @@ const SDK_ADVANCED_PAGES = [
{ text: 'useNodeProps', slug: 'use-node-props' },
{ text: 'useSceneComputed', slug: 'use-scene-computed' },
{ text: 'useColorVariableBinding', slug: 'use-color-variable-binding' },
{ text: 'useFillPicker', slug: 'use-fill-picker' },
{ text: 'useFillPicker', slug: 'use-fill-picker', canonical: true },
{ text: 'useGradientStops', slug: 'use-gradient-stops' },
{ text: 'useFontPicker', slug: 'use-font-picker' },
{ text: 'usePropScrub', slug: 'use-prop-scrub' },
@ -112,7 +115,7 @@ export const sdkSidebar = (prefix: string): DefaultTheme.SidebarItem[] => [
{ text: 'Overview', link: `${prefix}/programmable/sdk/api/advanced/` },
...SDK_ADVANCED_PAGES.map((page) => ({
text: page.text,
link: `${prefix}/programmable/sdk/api/advanced/${page.slug}`
link: `${'canonical' in page ? '' : prefix}/programmable/sdk/api/advanced/${page.slug}`
}))
]
}

View file

@ -5,9 +5,13 @@ description: Low-level fill-category and conversion helper.
# useFillPicker
`useFillPicker(fill, onUpdate)` returns derived fill category state plus conversion helpers for switching between solid, gradient, and image fills.
::: warning Deprecated
Use `useFill()` or the [FillRoot](../components/fill-root) primitive. `useFillPicker()` remains as a
migration wrapper until the picker cleanup slice.
:::
Use it when building custom fill pickers beyond `FillPickerRoot`.
`useFillPicker(fill, onUpdate)` returns legacy fill category state plus conversion helpers for
switching between solid, gradient, and image fills.
## Related APIs

View file

@ -0,0 +1,7 @@
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
export default defineComponentMetaLoader([
'packages/vue/src/primitives/ChannelSlider/ChannelSliderRoot.vue',
'packages/vue/src/primitives/ChannelSlider/ChannelSliderTrack.vue',
'packages/vue/src/primitives/ChannelSlider/ChannelSliderThumb.vue'
])

View file

@ -0,0 +1,56 @@
---
title: ChannelSlider
description: Accessible scalar channel slider for OkHCL controls.
---
<script setup lang="ts">
import ColorFillDemo from '#vue/primitives/Fill/demo/ColorFillDemo.vue'
import { data } from './channel-slider.data'
</script>
# ChannelSlider
`ChannelSlider` provides the same Root/Track/Thumb composition and keyboard behavior as Reka's
Slider while accepting an arbitrary scalar channel. OpenPencil uses it only for OkHCL channels;
standard RGB, HSL, and HSB controls should use Reka `ColorSlider` directly.
<ColorFillDemo />
## Anatomy
- `ChannelSliderRoot` — controlled value, range, step, orientation, and accessible channel label
- `ChannelSliderTrack` — polymorphic track element
- `ChannelSliderThumb` — slider thumb with range ARIA and formatted value text
Arrow keys step the value. Page Up and Page Down use larger steps, while Home and End move to the
range boundaries. The primitive is temporary until [Reka issue #2798](https://github.com/unovue/reka-ui/issues/2798)
provides first-class OkHCL support.
```vue twoslash
<script setup lang="ts">
import { ref } from 'vue'
import {
ChannelSliderRoot,
ChannelSliderThumb,
ChannelSliderTrack
} from '@open-pencil/vue'
const chroma = ref(0.16)
</script>
<template>
<ChannelSliderRoot v-model="chroma" label="Chroma" :min="0" :max="0.4" :step="0.001">
<ChannelSliderTrack />
<ChannelSliderThumb />
</ChannelSliderRoot>
</template>
```
## Generated API reference
<SdkComponentAPI :components="data.components" />
## Related APIs
- [useColorModel](../composables/use-color-model)
- [FillRoot](./fill-root)

View file

@ -5,7 +5,12 @@ description: Headless popover-based fill picker primitive.
# FillPickerRoot
`FillPickerRoot` is a headless popover-based fill picker for solid, gradient, and image fills.
::: warning Deprecated
Compose [FillRoot](./fill-root), [FillSwatch](./fill-swatch), and your application's popover instead.
`FillPickerRoot` remains temporarily available for migration compatibility.
:::
`FillPickerRoot` is the legacy popover-based fill picker for solid, gradient, and image fills.
## Props

View file

@ -0,0 +1,5 @@
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
export default defineComponentMetaLoader([
'packages/vue/src/primitives/Fill/FillRoot.vue'
])

View file

@ -0,0 +1,51 @@
---
title: FillRoot
description: Headless fill category state and conversion actions.
---
<script setup lang="ts">
import ColorFillDemo from '#vue/primitives/Fill/demo/ColorFillDemo.vue'
import { data } from './fill-root.data'
</script>
# FillRoot
`FillRoot` owns solid, gradient, and image category state without owning a popover or visual swatch.
Compose it around the picker surface appropriate for your application.
<ColorFillDemo />
Its default slot exposes the current fill, category, transparency, swatch background, and grouped
conversion actions. Category changes emit immutable `Fill` values and are no-ops when the requested
category is already active.
```vue twoslash
<script setup lang="ts">
import { ref } from 'vue'
import type { Fill } from '@open-pencil/scene-graph'
import { FillRoot } from '@open-pencil/vue'
const fill = ref<Fill>({
type: 'SOLID',
color: { r: 0.2, g: 0.5, b: 0.9, a: 1 },
opacity: 1,
visible: true
})
</script>
<template>
<FillRoot :fill="fill" @update="fill = $event" v-slot="model">
<button @click="model.actions.toGradient">Gradient</button>
</FillRoot>
</template>
```
## Generated API reference
<SdkComponentAPI :components="data.components" />
## Related APIs
- [FillSwatch](./fill-swatch)
- [ChannelSlider](./channel-slider)
- [useColorModel](../composables/use-color-model)

View file

@ -0,0 +1,5 @@
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
export default defineComponentMetaLoader([
'packages/vue/src/primitives/Fill/FillSwatch.vue'
])

View file

@ -0,0 +1,48 @@
---
title: FillSwatch
description: Binding-aware semantic preview for solid, gradient, and image fills.
---
<script setup lang="ts">
import ColorFillDemo from '#vue/primitives/Fill/demo/ColorFillDemo.vue'
import { data } from './fill-swatch.data'
</script>
# FillSwatch
`FillSwatch` exposes a semantic preview for a fill without imposing application styling. Its slot
provides the effective fill, preview background, category, and transparency state. The root also
publishes `data-fill-type`, `data-fill-category`, and `data-transparent` for styling and tests.
When nested in `BindableValueRoot<Color>`, a bound solid fill previews the provider's resolved color
and forwards the binding state attributes. Reading or focusing a swatch never detaches the binding.
Consumers can place a checkerboard below the slot's `background` value for transparent paints.
```vue twoslash
<script setup lang="ts">
import type { Fill } from '@open-pencil/scene-graph'
import { FillSwatch } from '@open-pencil/vue'
const fill: Fill = {
type: 'SOLID',
color: { r: 0.2, g: 0.5, b: 0.9, a: 0.5 },
opacity: 1,
visible: true
}
</script>
<template>
<FillSwatch :fill="fill" label="Brand fill" v-slot="swatch">
<span :style="{ background: swatch.background }" />
</FillSwatch>
</template>
```
## Generated API reference
<SdkComponentAPI :components="data.components" />
## Related APIs
- [FillRoot](./fill-root)
- [BindableValue](./bindable-value)

View file

@ -0,0 +1,5 @@
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
export default defineComponentMetaLoader([
'packages/vue/src/primitives/GradientEditor/GradientEditorStop.vue'
])

View file

@ -1,70 +1,53 @@
---
title: GradientEditorStop
description: Headless slot primitive for a single gradient stop row.
description: Accessible primitive for a selected or draggable gradient stop.
---
<script setup lang="ts">
import { data } from './gradient-editor-stop.data'
</script>
# GradientEditorStop
`GradientEditorStop` is a headless primitive for rendering and editing a single gradient stop.
`GradientEditorStop` renders a polymorphic gradient stop with documented selection and dragging
state. Interactive stops use the slider role and expose their percentage position through ARIA.
## Props
Use interactive stops on the gradient bar. Arrow keys nudge by `positionStep`; holding Shift uses a
10× step. Home and End move to either boundary, while Delete and Backspace emit `remove` when the
stop is removable. Handled keys stop propagation so editor-level deletion and movement shortcuts
do not run. Native Tab order cycles between stops.
<SdkPropsTable
:rows="[
{ name: 'stop', type: 'GradientStop', description: 'Current stop value.', required: true },
{ name: 'index', type: 'number', description: 'Current stop index.', required: true },
{ name: 'active', type: 'boolean', description: 'Whether this stop is active.', required: true }
]"
/>
Set `interactive="false"` when reusing the primitive around a composite stop row. The row still
exposes slot actions and `data-selected`/`data-dragging`, but does not enter the slider tab order.
## Events
```vue twoslash
<script setup lang="ts">
import type { GradientStop } from '@open-pencil/scene-graph'
import { GradientEditorStop } from '@open-pencil/vue'
<SdkEventsTable
:rows="[
{ name: 'select', payload: 'index: number', description: 'Emitted when the stop is selected.' },
{ name: 'updatePosition', payload: 'index: number, position: number', description: 'Emitted when the stop position changes.' },
{ name: 'updateColor', payload: 'index: number, hex: string', description: 'Emitted when the stop color changes.' },
{ name: 'updateOpacity', payload: 'index: number, opacity: number', description: 'Emitted when the stop opacity changes.' },
{ name: 'remove', payload: 'index: number', description: 'Emitted when the stop is removed.' }
]"
/>
## Slots
<SdkSlotsTable
:rows="[
{ name: 'default', props: 'stop state + update handlers', description: 'Full gradient stop render contract.' }
]"
/>
### Default slot props
```ts
{
stop: GradientStop
index: number
active: boolean
positionPercent: number
opacityPercent: number
hex: string
css: string
select: () => void
updatePosition: (position: number) => void
updateColor: (hex: string) => void
updateOpacity: (opacity: number) => void
remove: () => void
const stop: GradientStop = {
color: { r: 0.4, g: 0.2, b: 0.9, a: 1 },
position: 0.5
}
</script>
<template>
<GradientEditorStop
:stop="stop"
:index="0"
active
label="Middle gradient stop"
@update-position="(_index, position) => console.log(position)"
/>
</template>
```
## Example
## Generated API reference
```vue
<GradientEditorStop :stop="stop" :index="index" :active="active" v-slot="ctx">
<MyGradientStopRow v-bind="ctx" />
</GradientEditorStop>
```
<SdkComponentAPI :components="data.components" />
## Related APIs
- [GradientEditorRoot](./gradient-editor-root)
- [GradientEditorBar](./gradient-editor-bar)
- [useColorModel](../composables/use-color-model)

View file

@ -37,7 +37,10 @@ description: Component reference for headless Vue primitives in @open-pencil/vue
<SdkCardGroup>
<SdkCard title="ColorPickerRoot" to="/programmable/sdk/api/components/color-picker-root" description="Popover-based color picker primitive." />
<SdkCard title="ColorInputRoot" to="/programmable/sdk/api/components/color-input-root" description="Headless color input helper." />
<SdkCard title="FillPickerRoot" to="/programmable/sdk/api/components/fill-picker-root" description="Popover-based fill picker primitive." />
<SdkCard title="ChannelSlider" to="/programmable/sdk/api/components/channel-slider" description="Accessible scalar slider for OkHCL channels." />
<SdkCard title="FillRoot" to="/programmable/sdk/api/components/fill-root" description="Fill category state and conversion actions." />
<SdkCard title="FillSwatch" to="/programmable/sdk/api/components/fill-swatch" description="Binding-aware semantic fill preview." />
<SdkCard title="FillPickerRoot" to="/programmable/sdk/api/components/fill-picker-root" description="Deprecated popover-based fill picker compatibility primitive." />
<SdkCard title="FontPickerRoot" to="/programmable/sdk/api/components/font-picker-root" description="Searchable font picker primitive." />
<SdkCard title="GradientEditorRoot" to="/programmable/sdk/api/components/gradient-editor-root" description="Root primitive for gradient editing." />
<SdkCard title="GradientEditorBar" to="/programmable/sdk/api/components/gradient-editor-bar" description="Draggable gradient bar primitive." />

View file

@ -104,6 +104,8 @@ expansion heuristics. `PropertyListRoot` is controlled and
editor-agnostic; OpenPencil panels connect it to selection and undo through
`useEditorPropertyList()`. `useColorModel()` provides precise scene-color/Reka bridges, reactive
RGB/HSL/HSB/OkHCL channels, extensible format state, and shared slider presentation data.
`FillRoot` and `FillSwatch` separate fill behavior and binding-aware previews from popover
composition; `ChannelSlider` provides accessible scalar OkHCL controls until Reka supports them.
## Public API tiers
@ -167,6 +169,8 @@ These are the main APIs most SDK consumers should start with.
- `BindableValueRoot`
- `BindableValueTrigger`
- `BindableValuePicker`
- `FillRoot` / `FillSwatch`
- `ChannelSliderRoot` / `ChannelSliderTrack` / `ChannelSliderThumb`
### Advanced API
@ -179,7 +183,8 @@ These exports are intentionally public, but they are lower-level or more special
- `provideBindingProvider()`
- `useBindingProvider()`
- `useNumberBindingProvider()`
- `useFillPicker()`
- `useFill()`
- `useFillPicker()` (deprecated migration wrapper)
- `useGradientStops()`
- `useFontPicker()`
- `useOkHCL()`

View file

@ -115,6 +115,13 @@ export { useVariablesEditor } from '#vue/variables/editor/use'
export { useVariablesTable } from '#vue/variables/table/use'
export { usePageList } from '#vue/primitives/PageList/usePageList'
export { useFillPicker } from '#vue/primitives/FillPicker/useFillPicker'
export {
fillCategory,
fillIsTransparent,
fillSwatchBackground,
useFill
} from '#vue/primitives/Fill'
export type { FillActions, FillCategory } from '#vue/primitives/Fill'
export { useGradientStops } from '#vue/primitives/GradientEditor/useGradientStops'
export { useFontPicker } from '#vue/primitives/FontPicker/useFontPicker'
@ -122,6 +129,26 @@ export { useFontPicker } from '#vue/primitives/FontPicker/useFontPicker'
export { CanvasRoot, CanvasSurface, useCanvasContext } from '#vue/canvas'
export type { CanvasContext } from '#vue/canvas'
export { ColorInputRoot, ColorPickerRoot } from '#vue/primitives/ColorPicker'
export {
ChannelSliderRoot,
ChannelSliderThumb,
ChannelSliderTrack
} from '#vue/primitives/ChannelSlider'
export type {
ChannelSliderOrientation,
ChannelSliderPartProps,
ChannelSliderRootProps,
ChannelSliderRootSlotProps,
ChannelSliderThumbSlotProps
} from '#vue/primitives/ChannelSlider'
export { FillRoot, FillSwatch } from '#vue/primitives/Fill'
export type {
FillRootSlotProps,
FillRootSlots,
FillSwatchProps,
FillSwatchSlotProps,
FillSwatchSlots
} from '#vue/primitives/Fill'
export { FillPickerRoot } from '#vue/primitives/FillPicker'
export { FontPickerRoot } from '#vue/primitives/FontPicker'
export type { FontFamilyOption, FontPickerUI } from '#vue/primitives/FontPicker'
@ -130,6 +157,12 @@ export {
GradientEditorBar,
GradientEditorStop
} from '#vue/primitives/GradientEditor'
export type {
GradientEditorStopActions,
GradientEditorStopProps,
GradientEditorStopSlotProps,
GradientEditorStopSlots
} from '#vue/primitives/GradientEditor'
export { LayerTreeRoot, LayerTreeItem, useLayerTree } from '#vue/primitives/LayerTree'
export type { LayerDragInstruction, LayerTreeContext, LayerNode } from '#vue/primitives/LayerTree'
export { LayoutControlsRoot, useLayoutControlsContext } from '#vue/primitives/LayoutControls'

View file

@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed } from 'vue'
import { SliderRoot } from 'reka-ui'
import { provideChannelSlider } from '#vue/primitives/ChannelSlider/context'
import type {
ChannelSliderRootProps,
ChannelSliderRootSlotProps
} from '#vue/primitives/ChannelSlider/types'
/**
* Scalar color-channel slider used for OkHCL until Reka supports that color space.
*
* @deprecated-when-upstream Replace with Reka ColorSlider after
* https://github.com/unovue/reka-ui/issues/2798 lands.
*/
const {
modelValue,
label,
as = 'span',
asChild = false,
min = 0,
max = 100,
step = 1,
orientation = 'horizontal',
disabled = false,
inverted = false,
formatValueText = String
} = defineProps<ChannelSliderRootProps>()
const emit = defineEmits<{
'update:modelValue': [value: number]
valueCommit: [value: number]
}>()
defineSlots<{
default?: (props: ChannelSliderRootSlotProps) => unknown
}>()
defineOptions({ inheritAttrs: false })
const sliderValue = computed({
get: () => [modelValue],
set: (values: number[]) => emit('update:modelValue', values[0] ?? min)
})
const value = computed(() => modelValue)
const labelRef = computed(() => label)
const minRef = computed(() => min)
const maxRef = computed(() => max)
const stepRef = computed(() => step)
const disabledRef = computed(() => disabled)
const orientationRef = computed(() => orientation)
const valueText = computed(() => formatValueText(modelValue))
const slotProps = computed<ChannelSliderRootSlotProps>(() => ({
value: value.value,
min: minRef.value,
max: maxRef.value,
step: stepRef.value,
disabled: disabledRef.value,
orientation: orientationRef.value
}))
provideChannelSlider({
value,
label: labelRef,
valueText,
min: minRef,
max: maxRef,
step: stepRef,
disabled: disabledRef,
orientation: orientationRef
})
function commit(values: number[]) {
emit('valueCommit', values[0] ?? min)
}
</script>
<template>
<SliderRoot
v-bind="$attrs"
v-model="sliderValue"
:as="as"
:as-child="asChild"
:min="min"
:max="max"
:step="step"
:orientation="orientation"
:disabled="disabled"
:inverted="inverted"
data-slot="root"
@value-commit="commit"
>
<slot v-bind="slotProps" />
</SliderRoot>
</template>

View file

@ -0,0 +1,32 @@
<script setup lang="ts">
import { SliderThumb } from 'reka-ui'
import { useChannelSlider } from '#vue/primitives/ChannelSlider/context'
import type {
ChannelSliderPartProps,
ChannelSliderThumbSlotProps
} from '#vue/primitives/ChannelSlider/types'
const { as = 'span', asChild = false } = defineProps<ChannelSliderPartProps>()
const context = useChannelSlider()
defineSlots<{
default?: (props: ChannelSliderThumbSlotProps) => unknown
}>()
</script>
<template>
<SliderThumb
:as="as"
:as-child="asChild"
:aria-label="context.label.value"
:aria-valuetext="context.valueText.value"
data-slot="thumb"
>
<slot
:value="context.value.value"
:value-text="context.valueText.value"
:label="context.label.value"
/>
</SliderThumb>
</template>

View file

@ -0,0 +1,13 @@
<script setup lang="ts">
import { SliderTrack } from 'reka-ui'
import type { ChannelSliderPartProps } from '#vue/primitives/ChannelSlider/types'
const { as = 'span', asChild = false } = defineProps<ChannelSliderPartProps>()
</script>
<template>
<SliderTrack :as="as" :as-child="asChild" data-slot="track">
<slot />
</SliderTrack>
</template>

View file

@ -0,0 +1,28 @@
import type { ComputedRef, InjectionKey } from 'vue'
import { inject, provide } from 'vue'
import type { ChannelSliderOrientation } from '#vue/primitives/ChannelSlider/types'
interface ChannelSliderContext {
value: ComputedRef<number>
label: ComputedRef<string>
valueText: ComputedRef<string>
min: ComputedRef<number>
max: ComputedRef<number>
step: ComputedRef<number>
disabled: ComputedRef<boolean>
orientation: ComputedRef<ChannelSliderOrientation>
}
const CHANNEL_SLIDER_KEY: InjectionKey<ChannelSliderContext> = Symbol('ChannelSlider')
export function provideChannelSlider(context: ChannelSliderContext) {
provide(CHANNEL_SLIDER_KEY, context)
}
export function useChannelSlider(): ChannelSliderContext {
const context = inject(CHANNEL_SLIDER_KEY)
if (!context)
throw new Error('[open-pencil] ChannelSlider part must be used inside ChannelSliderRoot')
return context
}

View file

@ -0,0 +1,10 @@
export { default as ChannelSliderRoot } from '#vue/primitives/ChannelSlider/ChannelSliderRoot.vue'
export { default as ChannelSliderTrack } from '#vue/primitives/ChannelSlider/ChannelSliderTrack.vue'
export { default as ChannelSliderThumb } from '#vue/primitives/ChannelSlider/ChannelSliderThumb.vue'
export type {
ChannelSliderOrientation,
ChannelSliderPartProps,
ChannelSliderRootProps,
ChannelSliderRootSlotProps,
ChannelSliderThumbSlotProps
} from '#vue/primitives/ChannelSlider/types'

View file

@ -0,0 +1,41 @@
import type { PrimitiveProps } from 'reka-ui'
export type ChannelSliderOrientation = 'horizontal' | 'vertical'
export interface ChannelSliderRootProps extends PrimitiveProps {
/** Controlled channel value. */
modelValue: number
/** Accessible channel name announced by the thumb. */
label: string
/** Minimum channel value. */
min?: number
/** Maximum channel value. */
max?: number
/** Channel increment. */
step?: number
/** Slider orientation. */
orientation?: ChannelSliderOrientation
/** Prevents pointer and keyboard interaction. */
disabled?: boolean
/** Reverses the visual direction of the slider. */
inverted?: boolean
/** Formats the value announced by assistive technology. */
formatValueText?: (value: number) => string
}
export type ChannelSliderPartProps = PrimitiveProps
export interface ChannelSliderRootSlotProps {
value: number
min: number
max: number
step: number
disabled: boolean
orientation: ChannelSliderOrientation
}
export interface ChannelSliderThumbSlotProps {
value: number
valueText: string
label: string
}

View file

@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { expect, userEvent, within } from 'storybook/test'
import ColorFillDemo from '#vue/primitives/Fill/demo/ColorFillDemo.vue'
const meta = {
title: 'Vue SDK/Primitives/Color and Fill',
component: ColorFillDemo,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Fill state, binding-aware swatches, and the temporary scalar channel slider for OkHCL controls.'
}
}
}
} satisfies Meta<typeof ColorFillDemo>
export default meta
type Story = StoryObj<typeof meta>
export const StateMatrix: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await expect(canvas.getByRole('img', { name: 'Transparent fill' })).toHaveAttribute(
'data-transparent'
)
await expect(canvas.getByRole('img', { name: 'Bound token fill' })).toHaveAttribute(
'data-bound'
)
await userEvent.click(canvas.getByRole('button', { name: 'Gradient' }))
await expect(canvas.getByRole('img', { name: 'Editable fill' })).toHaveAttribute(
'data-fill-category',
'GRADIENT'
)
const slider = canvas.getByRole('slider', { name: 'Chroma' })
await userEvent.click(slider)
await userEvent.keyboard('{End}')
await expect(slider).toHaveAttribute('aria-valuenow', '0.4')
await userEvent.keyboard('{Home}')
await expect(slider).toHaveAttribute('aria-valuenow', '0')
}
}

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { FillRootSlots } from './types'
import { useFill } from './useFill'
import type { Fill } from '@open-pencil/scene-graph'
const { fill } = defineProps<{ fill: Fill }>()
const emit = defineEmits<{ update: [fill: Fill] }>()
defineSlots<FillRootSlots>()
const model = useFill(
computed(() => fill),
(updated) => emit('update', updated)
)
</script>
<template>
<slot
:fill="fill"
:category="model.category.value"
:swatch-background="model.swatchBackground.value"
:transparent="model.transparent.value"
:actions="model.actions"
/>
</template>

View file

@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Primitive } from 'reka-ui'
import { useOptionalBindableValue } from '#vue/primitives/BindableValue/context'
import type { FillSwatchProps, FillSwatchSlots } from './types'
import { fillCategory, fillIsTransparent, fillSwatchBackground } from './useFill'
import type { Color } from '@open-pencil/scene-graph/primitives'
const { fill, label, as = 'span', asChild = false } = defineProps<FillSwatchProps>()
defineSlots<FillSwatchSlots>()
defineOptions({ inheritAttrs: false })
const binding = useOptionalBindableValue<Color>()
const bindingState = computed(() => binding?.state.value)
const boundColor = computed(() =>
bindingState.value === 'bound' ? binding?.resolvedValue.value : undefined
)
const effectiveFill = computed(() =>
fill.type === 'SOLID' && boundColor.value ? { ...fill, color: boundColor.value } : fill
)
const category = computed(() => fillCategory(effectiveFill.value))
const background = computed(() => fillSwatchBackground(effectiveFill.value))
const transparent = computed(() => fillIsTransparent(effectiveFill.value))
const accessibleLabel = computed(() => label ?? `${category.value.toLowerCase()} fill`)
const stateAttrs = computed(() => binding?.stateAttrs.value)
const slotProps = computed(() => ({
fill: effectiveFill.value,
color: effectiveFill.value.color,
category: category.value,
background: background.value,
transparent: transparent.value,
bindingState: bindingState.value,
stateAttrs: stateAttrs.value
}))
</script>
<template>
<Primitive
v-bind="{ ...$attrs, ...stateAttrs }"
:as="as"
:as-child="asChild"
:aria-label="accessibleLabel"
:data-fill-type="effectiveFill.type"
:data-fill-category="category"
:data-transparent="transparent ? '' : undefined"
:style="{ '--open-pencil-fill-swatch-background': background }"
role="img"
aria-roledescription="fill swatch"
data-slot="swatch"
>
<slot v-bind="slotProps" />
</Primitive>
</template>

View file

@ -0,0 +1,135 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Fill, Variable } from '@open-pencil/scene-graph'
import type { Color } from '@open-pencil/scene-graph/primitives'
import type { BindingProvider, BindingTarget } from '#vue/controls/binding-provider/types'
import BindableValueRoot from '#vue/primitives/BindableValue/BindableValueRoot.vue'
import ChannelSliderRoot from '#vue/primitives/ChannelSlider/ChannelSliderRoot.vue'
import ChannelSliderThumb from '#vue/primitives/ChannelSlider/ChannelSliderThumb.vue'
import ChannelSliderTrack from '#vue/primitives/ChannelSlider/ChannelSliderTrack.vue'
import FillRoot from '#vue/primitives/Fill/FillRoot.vue'
import FillSwatch from '#vue/primitives/Fill/FillSwatch.vue'
const solid: Fill = {
type: 'SOLID',
color: { r: 0.22, g: 0.48, b: 0.96, a: 1 },
opacity: 1,
visible: true
}
const transparent: Fill = {
...solid,
color: { ...solid.color, a: 0.45 }
}
const gradient: Fill = {
...solid,
type: 'GRADIENT_LINEAR',
gradientStops: [
{ color: { r: 0.55, g: 0.24, b: 0.98, a: 1 }, position: 0 },
{ color: { r: 0.08, g: 0.72, b: 0.65, a: 0.65 }, position: 1 }
]
}
const editableFill = ref<Fill>(structuredClone(solid))
const chroma = ref(0.16)
const target: BindingTarget[] = [{ nodeId: 'demo', path: 'fills/0/color' }]
const variable: Variable = {
id: 'color/brand',
name: 'Color/Brand',
type: 'COLOR',
collectionId: 'demo',
valuesByMode: { default: { r: 0.65, g: 0.3, b: 0.95, a: 1 } },
description: '',
hiddenFromPublishing: false
}
const boundColor: Color = { r: 0.65, g: 0.3, b: 0.95, a: 1 }
const provider: BindingProvider<Color> = {
listVariables: () => [variable],
filterVariables: () => [variable],
getBound: () => variable,
getState: () => 'bound',
resolve: (variableId) => (variableId === variable.id ? boundColor : undefined),
bind: () => undefined,
unbind: () => undefined
}
</script>
<template>
<div
class="w-full max-w-[560px] space-y-5 rounded-lg border border-[var(--vp-c-divider)] bg-[var(--vp-c-bg-soft)] p-5 text-[var(--vp-c-text-1)]"
>
<section>
<h3 class="mb-2 text-sm font-semibold">Fill swatches</h3>
<div class="flex flex-wrap gap-4">
<FillSwatch
v-for="(item, name) in { Solid: solid, Transparent: transparent, Gradient: gradient }"
:key="name"
v-slot="swatch"
:fill="item"
:label="`${name} fill`"
class="relative size-10 overflow-hidden rounded-md border border-[var(--vp-c-divider)] bg-[conic-gradient(#ddd_25%,#fff_0_50%,#ddd_0_75%,#fff_0)] bg-[size:10px_10px]"
>
<span class="absolute inset-0" :style="{ background: swatch.background }" />
</FillSwatch>
<BindableValueRoot :provider="provider" :targets="target" :value="solid.color">
<FillSwatch
v-slot="swatch"
:fill="solid"
label="Bound token fill"
class="relative size-10 overflow-hidden rounded-md border border-[var(--vp-c-divider)]"
>
<span class="absolute inset-0" :style="{ background: swatch.background }" />
</FillSwatch>
</BindableValueRoot>
</div>
</section>
<section>
<h3 class="mb-2 text-sm font-semibold">Fill state</h3>
<FillRoot v-slot="fillModel" :fill="editableFill" @update="editableFill = $event">
<div class="flex items-center gap-2">
<FillSwatch
v-slot="swatch"
:fill="editableFill"
label="Editable fill"
class="relative size-8 overflow-hidden rounded border border-[var(--vp-c-divider)]"
>
<span class="absolute inset-0" :style="{ background: swatch.background }" />
</FillSwatch>
<button class="rounded border px-2 py-1 text-xs" @click="fillModel.actions.toSolid">
Solid
</button>
<button class="rounded border px-2 py-1 text-xs" @click="fillModel.actions.toGradient">
Gradient
</button>
<button class="rounded border px-2 py-1 text-xs" @click="fillModel.actions.toImage">
Image
</button>
</div>
</FillRoot>
</section>
<section>
<h3 class="mb-2 text-sm font-semibold">OkHCL channel</h3>
<ChannelSliderRoot
v-model="chroma"
v-slot="slider"
label="Chroma"
:min="0"
:max="0.4"
:step="0.001"
:format-value-text="(value) => `${Math.round(value * 100)}%`"
class="relative flex h-4 touch-none items-center"
>
<ChannelSliderTrack
class="absolute h-3 w-full rounded bg-[linear-gradient(to_right,#ddd,#7c3aed)]"
/>
<ChannelSliderThumb
class="block size-4 rounded-full border-2 border-white bg-violet-600 shadow outline-none focus-visible:ring-2"
/>
<output class="ml-auto pl-4 text-xs tabular-nums"
>{{ Math.round(slider.value * 100) }}%</output
>
</ChannelSliderRoot>
</section>
</div>
</template>

View file

@ -0,0 +1,17 @@
export { default as FillRoot } from '#vue/primitives/Fill/FillRoot.vue'
export { default as FillSwatch } from '#vue/primitives/Fill/FillSwatch.vue'
export {
fillCategory,
fillIsTransparent,
fillSwatchBackground,
useFill
} from '#vue/primitives/Fill/useFill'
export type {
FillActions,
FillCategory,
FillRootSlotProps,
FillRootSlots,
FillSwatchProps,
FillSwatchSlotProps,
FillSwatchSlots
} from '#vue/primitives/Fill/types'

View file

@ -0,0 +1,48 @@
import type { PrimitiveProps } from 'reka-ui'
import type { Fill } from '@open-pencil/scene-graph'
import type { Color } from '@open-pencil/scene-graph/primitives'
import type { BindingState } from '#vue/controls/binding-provider/types'
import type { BindableValueStateAttrs } from '#vue/primitives/BindableValue/types'
export type FillCategory = 'SOLID' | 'GRADIENT' | 'IMAGE'
export interface FillActions {
toSolid: () => void
toGradient: () => void
toImage: () => void
}
export interface FillRootSlotProps {
fill: Fill
category: FillCategory
swatchBackground: string
transparent: boolean
actions: FillActions
}
export interface FillRootSlots {
default?: (props: FillRootSlotProps) => unknown
}
export interface FillSwatchProps extends PrimitiveProps {
/** Fill represented by the swatch. */
fill: Fill
/** Accessible name for the preview. */
label?: string
}
export interface FillSwatchSlotProps {
fill: Fill
color: Color
category: FillCategory
background: string
transparent: boolean
bindingState: BindingState | undefined
stateAttrs: BindableValueStateAttrs | undefined
}
export interface FillSwatchSlots {
default?: (props: FillSwatchSlotProps) => unknown
}

View file

@ -0,0 +1,91 @@
import { computed } from 'vue'
import type { Ref } from 'vue'
import { colorToCSS } from '@open-pencil/core/color'
import type { Fill, GradientStop } from '@open-pencil/scene-graph'
import type { Color } from '@open-pencil/scene-graph/primitives'
import type { FillCategory } from './types'
const FILL_CATEGORY: Partial<Record<Fill['type'], FillCategory>> = {
SOLID: 'SOLID',
GRADIENT_LINEAR: 'GRADIENT',
GRADIENT_RADIAL: 'GRADIENT',
GRADIENT_ANGULAR: 'GRADIENT',
GRADIENT_DIAMOND: 'GRADIENT',
IMAGE: 'IMAGE'
}
function effectiveColor(color: Color, opacity: number): Color {
return { ...color, a: color.a * opacity }
}
function gradientCSS(stops: GradientStop[], opacity: number): string {
return stops
.map((stop) => `${colorToCSS(effectiveColor(stop.color, opacity))} ${stop.position * 100}%`)
.join(', ')
}
export function fillCategory(fill: Fill): FillCategory {
return FILL_CATEGORY[fill.type] ?? 'SOLID'
}
export function fillIsTransparent(fill: Fill): boolean {
if (fill.opacity < 1) return true
if (fillCategory(fill) === 'GRADIENT')
return fill.gradientStops?.some((stop) => stop.color.a < 1) ?? fill.color.a < 1
return fill.color.a < 1
}
export function fillSwatchBackground(fill: Fill): string {
if (fillCategory(fill) === 'GRADIENT' && fill.gradientStops?.length) {
return `linear-gradient(to right, ${gradientCSS(fill.gradientStops, fill.opacity)})`
}
return colorToCSS(effectiveColor(fill.color, fill.opacity))
}
/** Fill category state and immutable conversion actions without picker or popover behavior. */
export function useFill(fill: Ref<Fill>, onUpdate: (fill: Fill) => void) {
const category = computed(() => fillCategory(fill.value))
const swatchBackground = computed(() => fillSwatchBackground(fill.value))
const transparent = computed(() => fillIsTransparent(fill.value))
function toSolid() {
if (category.value === 'SOLID') return
const color = fill.value.gradientStops?.[0]?.color ?? fill.value.color
onUpdate({ ...fill.value, type: 'SOLID', color: { ...color } })
}
function toGradient() {
if (category.value === 'GRADIENT') return
const gradientStops: GradientStop[] = fill.value.gradientStops?.length
? structuredClone(fill.value.gradientStops)
: [
{ color: { ...fill.value.color }, position: 0 },
{ color: { r: 1, g: 1, b: 1, a: 1 }, position: 1 }
]
onUpdate({
...fill.value,
type: 'GRADIENT_LINEAR',
gradientStops,
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 0, m12: 0.5 }
})
}
function toImage() {
if (category.value === 'IMAGE') return
onUpdate({ ...fill.value, type: 'IMAGE' })
}
const actions = { toSolid, toGradient, toImage }
return {
category,
swatchBackground,
transparent,
actions,
toSolid,
toGradient,
toImage
}
}

View file

@ -1 +1,2 @@
/** @deprecated Compose `FillRoot`, `FillSwatch`, and a popover in the consumer. */
export { default as FillPickerRoot } from '#vue/primitives/FillPicker/FillPickerRoot.vue'

View file

@ -1,70 +1,17 @@
import { computed, type Ref } from 'vue'
import type { Ref } from 'vue'
import { colorToCSS } from '@open-pencil/core/color'
import type { Fill, GradientStop } from '@open-pencil/scene-graph'
import type { Fill } from '@open-pencil/scene-graph'
type FillCategory = 'SOLID' | 'GRADIENT' | 'IMAGE'
import { useFill } from '#vue/primitives/Fill/useFill'
const FILL_CATEGORY: Record<string, FillCategory> = {
SOLID: 'SOLID',
GRADIENT_LINEAR: 'GRADIENT',
GRADIENT_RADIAL: 'GRADIENT',
GRADIENT_ANGULAR: 'GRADIENT',
GRADIENT_DIAMOND: 'GRADIENT',
IMAGE: 'IMAGE'
}
function gradientCSS(stops: GradientStop[]): string {
return stops.map((s) => `${colorToCSS(s.color)} ${s.position * 100}%`).join(', ')
}
/**
* Returns category and conversion helpers for a single fill value.
*
* This composable is useful for fill pickers that switch between solid,
* gradient, and image modes while keeping a live fill model in sync.
*/
/** @deprecated Use `useFill()` and compose popover behavior in the consumer. */
export function useFillPicker(fill: Ref<Fill>, onUpdate: (fill: Fill) => void) {
const category = computed(() => FILL_CATEGORY[fill.value.type] ?? 'SOLID')
function toSolid() {
if (category.value === 'SOLID') return
const color = fill.value.gradientStops?.[0]?.color ?? fill.value.color
onUpdate({ ...fill.value, type: 'SOLID', color: { ...color } })
}
function toGradient() {
if (category.value === 'GRADIENT') return
const stops: GradientStop[] = fill.value.gradientStops?.length
? fill.value.gradientStops
: [
{ color: { ...fill.value.color }, position: 0 },
{ color: { r: 1, g: 1, b: 1, a: 1 }, position: 1 }
]
onUpdate({
...fill.value,
type: 'GRADIENT_LINEAR',
gradientStops: stops,
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 0, m12: 0.5 }
})
}
function toImage() {
if (category.value === 'IMAGE') return
onUpdate({ ...fill.value, type: 'IMAGE' })
}
const swatchBg = computed(() => {
if (category.value === 'GRADIENT' && fill.value.gradientStops?.length)
return `linear-gradient(to right, ${gradientCSS(fill.value.gradientStops)})`
return colorToCSS(fill.value.color)
})
const model = useFill(fill, onUpdate)
return {
category,
swatchBg,
toSolid,
toGradient,
toImage
category: model.category,
swatchBg: model.swatchBackground,
toSolid: model.toSolid,
toGradient: model.toGradient,
toImage: model.toImage
}
}

View file

@ -1,14 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Primitive } from 'reka-ui'
import { colorToCSS, colorToHexRaw } from '@open-pencil/core/color'
import type { GradientStop } from '@open-pencil/scene-graph'
import type {
GradientEditorStopActions,
GradientEditorStopProps,
GradientEditorStopSlots
} from '#vue/primitives/GradientEditor/types'
const { stop, index, active } = defineProps<{
stop: GradientStop
index: number
active: boolean
}>()
const {
stop,
index,
active,
dragging = false,
interactive = true,
removable = true,
positionStep = 1,
label,
as = 'div',
asChild = false
} = defineProps<GradientEditorStopProps>()
const emit = defineEmits<{
select: [index: number]
@ -18,28 +30,75 @@ const emit = defineEmits<{
remove: [index: number]
}>()
defineSlots<GradientEditorStopSlots>()
defineOptions({ inheritAttrs: false })
const positionPercent = computed(() => Math.round(stop.position * 100))
const opacityPercent = computed(() => Math.round(stop.color.a * 100))
const hex = computed(() => colorToHexRaw(stop.color))
const css = computed(() => colorToCSS(stop.color))
const actions = {
const accessibleLabel = computed(() => label ?? `Gradient stop ${index + 1}`)
const actions: GradientEditorStopActions = {
select: () => emit('select', index),
updatePosition: (pos: number) => emit('updatePosition', index, pos),
updateColor: (hexValue: string) => emit('updateColor', index, hexValue),
updateOpacity: (opacity: number) => emit('updateOpacity', index, opacity),
updatePosition: (position) => emit('updatePosition', index, position),
updateColor: (hexValue) => emit('updateColor', index, hexValue),
updateOpacity: (opacity) => emit('updateOpacity', index, opacity),
remove: () => emit('remove', index)
}
function onKeydown(event: KeyboardEvent) {
if (!interactive) return
const amount = positionStep * (event.shiftKey ? 10 : 1)
let nextPosition: number | undefined
if (event.code === 'ArrowLeft' || event.code === 'ArrowDown')
nextPosition = positionPercent.value - amount
else if (event.code === 'ArrowRight' || event.code === 'ArrowUp')
nextPosition = positionPercent.value + amount
else if (event.code === 'Home') nextPosition = 0
else if (event.code === 'End') nextPosition = 100
else if ((event.code === 'Delete' || event.code === 'Backspace') && removable) {
event.preventDefault()
event.stopPropagation()
actions.remove()
return
}
if (nextPosition === undefined) return
event.preventDefault()
event.stopPropagation()
actions.updatePosition(Math.max(0, Math.min(100, nextPosition)))
}
</script>
<template>
<slot
:stop="stop"
:index="index"
:active="active"
:position-percent="positionPercent"
:opacity-percent="opacityPercent"
:hex="hex"
:css="css"
:actions="actions"
/>
<Primitive
v-bind="$attrs"
:as="as"
:as-child="asChild"
:data-selected="active ? '' : undefined"
:data-dragging="dragging ? '' : undefined"
:role="interactive ? 'slider' : undefined"
:tabindex="interactive ? 0 : undefined"
:aria-label="interactive ? accessibleLabel : undefined"
:aria-valuemin="interactive ? 0 : undefined"
:aria-valuemax="interactive ? 100 : undefined"
:aria-valuenow="interactive ? positionPercent : undefined"
:aria-valuetext="interactive ? `${positionPercent}%` : undefined"
data-slot="stop"
@click="actions.select"
@focus="actions.select"
@keydown="onKeydown"
>
<slot
:stop="stop"
:index="index"
:active="active"
:selected="active"
:dragging="dragging"
:position-percent="positionPercent"
:opacity-percent="opacityPercent"
:hex="hex"
:css="css"
:actions="actions"
/>
</Primitive>
</template>

View file

@ -1,3 +1,9 @@
export { default as GradientEditorRoot } from '#vue/primitives/GradientEditor/GradientEditorRoot.vue'
export { default as GradientEditorBar } from '#vue/primitives/GradientEditor/GradientEditorBar.vue'
export { default as GradientEditorStop } from '#vue/primitives/GradientEditor/GradientEditorStop.vue'
export type {
GradientEditorStopActions,
GradientEditorStopProps,
GradientEditorStopSlotProps,
GradientEditorStopSlots
} from '#vue/primitives/GradientEditor/types'

View file

@ -0,0 +1,39 @@
import type { PrimitiveProps } from 'reka-ui'
import type { GradientStop } from '@open-pencil/scene-graph'
export interface GradientEditorStopProps extends PrimitiveProps {
stop: GradientStop
index: number
active: boolean
dragging?: boolean
interactive?: boolean
removable?: boolean
positionStep?: number
label?: string
}
export interface GradientEditorStopActions {
select: () => void
updatePosition: (position: number) => void
updateColor: (hex: string) => void
updateOpacity: (opacity: number) => void
remove: () => void
}
export interface GradientEditorStopSlotProps {
stop: GradientStop
index: number
active: boolean
selected: boolean
dragging: boolean
positionPercent: number
opacityPercent: number
hex: string
css: string
actions: GradientEditorStopActions
}
export interface GradientEditorStopSlots {
default?: (props: GradientEditorStopSlotProps) => unknown
}

View file

@ -1,9 +1,11 @@
import { computed, ref, type Ref } from 'vue'
import { colorToCSS, parseColor } from '@open-pencil/core/color'
import { colorToCSS } from '@open-pencil/core/color'
import type { Fill, GradientStop, GradientTransform } from '@open-pencil/scene-graph'
import type { Color } from '@open-pencil/scene-graph/primitives'
import { useColorModel } from '#vue/controls/color-model/use'
type GradientSubtype =
| 'GRADIENT_LINEAR'
| 'GRADIENT_RADIAL'
@ -82,10 +84,8 @@ export function useGradientStops(fill: Ref<Fill>, onUpdate: (fill: Fill) => void
}
function updateStopColor(index: number, hex: string) {
const color = parseColor(hex.startsWith('#') ? hex : `#${hex}`)
const s = [...stops.value]
s[index] = { ...s[index], color: { ...color, a: s[index].color.a } }
emitStops(s)
selectStop(index)
colorModel.updateHex(hex)
}
function updateStopOpacity(index: number, opacity: number) {
@ -104,6 +104,11 @@ export function useGradientStops(fill: Ref<Fill>, onUpdate: (fill: Fill) => void
emitStops(s)
}
const colorModel = useColorModel({
color: activeColor,
onUpdate: updateActiveColor
})
function dragStop(index: number, position: number) {
const s = [...stops.value]
s[index] = { ...s[index], position }

View file

@ -2,7 +2,7 @@
import { inputNumberValue } from '@open-pencil/vue'
import { colorToCSS } from '@open-pencil/core/color'
import PickerSlider from '@/components/color-picker-panel/PickerSlider.vue'
import StandardColorSlider from '@/components/color-picker-panel/StandardColorSlider.vue'
import { useColorPickerPanelContext } from '@/components/color-picker-panel/context'
const ctx = useColorPickerPanelContext()
@ -38,30 +38,36 @@ const ctx = useColorPickerPanelContext()
/>
</div>
<PickerSlider
label="S"
:model-value="ctx.hsbColor.s"
:min="0"
:max="100"
<StandardColorSlider
label="Saturation"
:model-value="ctx.rekaColor"
channel="saturation"
color-space="hsb"
:step="0.1"
:display="{ value: Math.round(ctx.hsbColor.s), min: 0, max: 100, step: 1 }"
:gradient-style="ctx.sliderGradient.hsbSaturation"
:number-value="Math.round(ctx.hsbColor.s)"
:number-min="0"
:number-max="100"
suffix="%"
:thumb-fill="colorToCSS(ctx.sliderPreview.hsbSaturation)"
data-test-id="color-slider-hsb-s"
@update:model-value="ctx.updateHSBChannelValue('s', $event)"
@update="ctx.onRekaColorUpdate"
@update-number="ctx.updateHSBChannelValue('s', $event)"
/>
<PickerSlider
label="B"
:model-value="ctx.hsbColor.b"
:min="0"
:max="100"
<StandardColorSlider
label="Brightness"
:model-value="ctx.rekaColor"
channel="brightness"
color-space="hsb"
:step="0.1"
:display="{ value: Math.round(ctx.hsbColor.b), min: 0, max: 100, step: 1 }"
:gradient-style="ctx.sliderGradient.hsbBrightness"
:number-value="Math.round(ctx.hsbColor.b)"
:number-min="0"
:number-max="100"
suffix="%"
:thumb-fill="colorToCSS(ctx.sliderPreview.hsbBrightness)"
data-test-id="color-slider-hsb-b"
@update:model-value="ctx.updateHSBChannelValue('b', $event)"
@update="ctx.onRekaColorUpdate"
@update-number="ctx.updateHSBChannelValue('b', $event)"
/>
<p class="text-[10px] leading-4 text-muted">{{ ctx.panels.colorHintHsb }}</p>

View file

@ -2,7 +2,7 @@
import { inputNumberValue } from '@open-pencil/vue'
import { colorToCSS } from '@open-pencil/core/color'
import PickerSlider from '@/components/color-picker-panel/PickerSlider.vue'
import StandardColorSlider from '@/components/color-picker-panel/StandardColorSlider.vue'
import { useColorPickerPanelContext } from '@/components/color-picker-panel/context'
const ctx = useColorPickerPanelContext()
@ -38,30 +38,36 @@ const ctx = useColorPickerPanelContext()
/>
</div>
<PickerSlider
label="S"
:model-value="ctx.hslColor.s ?? 0"
:min="0"
:max="100"
<StandardColorSlider
label="Saturation"
:model-value="ctx.rekaColor"
channel="saturation"
color-space="hsl"
:step="0.1"
:display="{ value: Math.round(ctx.hslColor.s ?? 0), min: 0, max: 100, step: 1 }"
:gradient-style="ctx.sliderGradient.hslSaturation"
:number-value="Math.round(ctx.hslColor.s ?? 0)"
:number-min="0"
:number-max="100"
suffix="%"
:thumb-fill="colorToCSS(ctx.sliderPreview.hslSaturation)"
data-test-id="color-slider-hsl-s"
@update:model-value="ctx.updateHSLChannelValue('s', $event)"
@update="ctx.onRekaColorUpdate"
@update-number="ctx.updateHSLChannelValue('s', $event)"
/>
<PickerSlider
label="L"
:model-value="ctx.hslColor.l ?? 0"
:min="0"
:max="100"
<StandardColorSlider
label="Lightness"
:model-value="ctx.rekaColor"
channel="lightness"
color-space="hsl"
:step="0.1"
:display="{ value: Math.round(ctx.hslColor.l ?? 0), min: 0, max: 100, step: 1 }"
:gradient-style="ctx.sliderGradient.hslLightness"
:number-value="Math.round(ctx.hslColor.l ?? 0)"
:number-min="0"
:number-max="100"
suffix="%"
:thumb-fill="colorToCSS(ctx.sliderPreview.hslLightness)"
data-test-id="color-slider-hsl-l"
@update:model-value="ctx.updateHSLChannelValue('l', $event)"
@update="ctx.onRekaColorUpdate"
@update-number="ctx.updateHSLChannelValue('l', $event)"
/>
<p class="text-[10px] leading-4 text-muted">{{ ctx.panels.colorHintHsl }}</p>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { colorToCSS } from '@open-pencil/core/color'
import PickerSlider from '@/components/color-picker-panel/PickerSlider.vue'
import StandardColorSlider from '@/components/color-picker-panel/StandardColorSlider.vue'
import { useColorPickerPanelContext } from '@/components/color-picker-panel/context'
const ctx = useColorPickerPanelContext()
@ -9,31 +9,35 @@ const ctx = useColorPickerPanelContext()
<template>
<template v-if="ctx.fieldFormat !== 'okhcl'">
<PickerSlider
label="H"
:model-value="ctx.hslColor.h ?? 0"
:min="0"
:max="360"
:step="1"
gradient-style="background: linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000);"
<StandardColorSlider
label="Hue"
:model-value="ctx.rekaColor"
channel="hue"
color-space="hsl"
:number-value="Math.round(ctx.hslColor.h ?? 0)"
:number-min="0"
:number-max="360"
:thumb-fill="colorToCSS(ctx.sliderPreview.hue)"
:ui="{ root: 'gap-0', label: 'hidden', input: 'hidden' }"
data-test-id="color-slider-hue"
@update:model-value="ctx.updateRGBAHue"
@update="ctx.updateRGBAHue($event.space === 'hsl' ? $event.h : 0)"
@update-number="ctx.updateRGBAHue"
/>
<PickerSlider
label="A"
:model-value="ctx.color.a"
:min="0"
:max="1"
:step="0.001"
<StandardColorSlider
label="Alpha"
:model-value="ctx.rekaColor"
channel="alpha"
color-space="rgb"
:step="0.1"
:number-value="Math.round(ctx.color.a * 100)"
:number-min="0"
:number-max="100"
suffix="%"
checkerboard
:gradient-style="`background: linear-gradient(to right, transparent, ${colorToCSS({ ...ctx.color, a: 1 })})`"
:thumb-fill="colorToCSS(ctx.color)"
:ui="{ root: 'gap-0', label: 'hidden', input: 'hidden' }"
data-test-id="color-slider-alpha"
@update:model-value="ctx.updateRGBAAlpha"
@update="ctx.updateRGBAAlpha($event.alpha)"
@update-number="ctx.updateRGBAAlpha($event / 100)"
/>
</template>
</template>

View file

@ -0,0 +1,84 @@
<script setup lang="ts">
import { ChannelSliderRoot, ChannelSliderThumb, ChannelSliderTrack } from '@open-pencil/vue'
import type { ColorSliderUI } from '@/components/color-picker-panel/ui'
import NumberField from '@/components/inputs/NumberField.vue'
import { useColorSliderUI } from '@/components/color-picker-panel/ui'
interface OkhclChannelSliderProps {
label: string
modelValue: number
min: number
max: number
step?: number
displayValue: number
displayMin: number
displayMax: number
displayStep?: number
suffix?: string
gradient?: string
checkerboard?: boolean
thumbFill?: string
formatValueText?: (value: number) => string
ui?: ColorSliderUI
}
const {
label,
modelValue,
min,
max,
step = 1,
displayValue,
displayMin,
displayMax,
displayStep = 1,
suffix,
gradient,
checkerboard = false,
thumbFill = '#fff',
formatValueText,
ui
} = defineProps<OkhclChannelSliderProps>()
const emit = defineEmits<{
'update:modelValue': [value: number]
updateDisplay: [value: number]
}>()
const styles = useColorSliderUI(
() => checkerboard,
() => ui
)
</script>
<template>
<div :class="styles.root.value">
<span :class="styles.label.value">{{ label }}</span>
<ChannelSliderRoot
:class="styles.slider.value"
:model-value="modelValue"
:label="label"
:min="min"
:max="max"
:step="step"
:format-value-text="formatValueText"
data-slot="slider"
@update:model-value="emit('update:modelValue', $event)"
>
<ChannelSliderTrack :class="styles.track.value" :style="gradient" />
<ChannelSliderThumb :class="styles.thumb.value" :style="{ background: thumbFill }" />
</ChannelSliderRoot>
<NumberField
:class="styles.input.value"
:aria-label="label"
:model-value="displayValue"
:min="displayMin"
:max="displayMax"
:step="displayStep"
:suffix="suffix"
:ui="{ leading: 'hidden' }"
@update:model-value="emit('updateDisplay', $event)"
/>
</div>
</template>

View file

@ -2,83 +2,84 @@
import { colorToCSS } from '@open-pencil/core/color'
import { fromPercent, toPercent } from '@open-pencil/vue'
import PickerSlider from '@/components/color-picker-panel/PickerSlider.vue'
import OkhclChannelSlider from '@/components/color-picker-panel/OkhclChannelSlider.vue'
import { useColorPickerPanelContext } from '@/components/color-picker-panel/context'
const ctx = useColorPickerPanelContext()
const percentText = (value: number) => `${Math.round(toPercent(value))}%`
</script>
<template>
<div v-if="ctx.isOkHCLFormat && ctx.okhcl?.okhcl" class="flex flex-col gap-2">
<PickerSlider
label="H"
<OkhclChannelSlider
label="Hue"
:model-value="ctx.okhcl.okhcl.h"
:min="0"
:max="360"
:step="1"
:display="{ value: Math.round(ctx.okhcl.okhcl.h), min: 0, max: 360, step: 1 }"
gradient-style="background: linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000);"
:display-value="Math.round(ctx.okhcl.okhcl.h)"
:display-min="0"
:display-max="360"
:thumb-fill="colorToCSS(ctx.okhclSliderPreview?.okhclHue ?? ctx.color)"
gradient="background: linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000);"
data-test-id="color-slider-okhcl-h"
@update:model-value="ctx.updateOkHCLChannel('h', $event)"
@update-display="ctx.updateOkHCLChannel('h', $event)"
/>
<PickerSlider
label="C"
<OkhclChannelSlider
label="Chroma"
:model-value="ctx.okhcl.okhcl.c"
:min="0"
:max="0.4"
:step="0.001"
:display="{
value: toPercent(ctx.okhcl.okhcl.c),
min: 0,
max: 40,
step: 1,
parse: fromPercent
}"
:gradient-style="ctx.okhclSliderGradient?.okhclChroma ?? undefined"
:display-value="toPercent(ctx.okhcl.okhcl.c)"
:display-min="0"
:display-max="40"
suffix="%"
:gradient="ctx.okhclSliderGradient?.okhclChroma ?? undefined"
:thumb-fill="colorToCSS(ctx.okhclSliderPreview?.okhclChroma ?? ctx.color)"
:format-value-text="percentText"
data-test-id="color-slider-okhcl-c"
@update:model-value="ctx.updateOkHCLChannel('c', $event)"
@update-display="ctx.updateOkHCLChannel('c', fromPercent($event))"
/>
<PickerSlider
label="L"
<OkhclChannelSlider
label="Lightness"
:model-value="ctx.okhcl.okhcl.l"
:min="0"
:max="1"
:step="0.001"
:display="{
value: toPercent(ctx.okhcl.okhcl.l),
min: 0,
max: 100,
step: 1,
parse: fromPercent
}"
:gradient-style="ctx.okhclSliderGradient?.okhclLightness ?? undefined"
:display-value="toPercent(ctx.okhcl.okhcl.l)"
:display-min="0"
:display-max="100"
suffix="%"
:gradient="ctx.okhclSliderGradient?.okhclLightness ?? undefined"
:thumb-fill="colorToCSS(ctx.okhclSliderPreview?.okhclLightness ?? ctx.color)"
:format-value-text="percentText"
data-test-id="color-slider-okhcl-l"
@update:model-value="ctx.updateOkHCLChannel('l', $event)"
@update-display="ctx.updateOkHCLChannel('l', fromPercent($event))"
/>
<PickerSlider
label="A"
<OkhclChannelSlider
label="Alpha"
:model-value="ctx.okhcl.okhcl.a ?? 1"
:min="0"
:max="1"
:step="0.001"
:display="{
value: toPercent(ctx.okhcl.okhcl.a ?? 1),
min: 0,
max: 100,
step: 1,
parse: fromPercent
}"
:display-value="toPercent(ctx.okhcl.okhcl.a ?? 1)"
:display-min="0"
:display-max="100"
suffix="%"
checkerboard
:gradient-style="`background: linear-gradient(to right, transparent, ${colorToCSS(ctx.color)})`"
:gradient="`background: linear-gradient(to right, transparent, ${colorToCSS({ ...ctx.color, a: 1 })})`"
:thumb-fill="colorToCSS(ctx.color)"
:format-value-text="percentText"
data-test-id="color-slider-okhcl-a"
@update:model-value="ctx.updateOkHCLChannel('a', $event)"
@update-display="ctx.updateOkHCLChannel('a', fromPercent($event))"
/>
<div class="flex items-start justify-between gap-2 text-[10px] text-muted">

View file

@ -1,91 +0,0 @@
<script setup lang="ts">
import { inputNumberValue } from '@open-pencil/vue'
import { usePickerSliderUI } from '@/components/ui/picker-slider'
type PickerSliderDisplay = {
value?: number
min?: number
max?: number
step?: number
format?: (value: number) => string | number
parse?: (value: number) => number
}
interface PickerSliderProps {
label: string
modelValue: number
min: number
max: number
step?: number
display?: PickerSliderDisplay
gradientStyle?: string
checkerboard?: boolean
thumbFill?: string
ui?: Partial<
Record<'root' | 'label' | 'track' | 'gradient' | 'range' | 'thumb' | 'input', string>
>
}
const {
label,
modelValue,
min,
max,
step = 1,
display,
gradientStyle,
checkerboard = false,
thumbFill = '#fff',
ui
} = defineProps<PickerSliderProps>()
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const cls = usePickerSliderUI({ checkerboard, ui })
function numberValue(): string | number {
const value = display?.value ?? modelValue
return display?.format ? display.format(value) : value
}
function handleNumberChange(value: number) {
emit('update:modelValue', display?.parse ? display.parse(value) : value)
}
function thumbLeft(): string {
const range = max - min
const ratio = range === 0 ? 0 : (modelValue - min) / range
const clampedRatio = Math.max(0, Math.min(1, ratio))
return `calc(${clampedRatio * 100}% - ${clampedRatio * 14}px)`
}
</script>
<template>
<div :class="cls.root">
<span :class="cls.label">{{ label }}</span>
<div :class="cls.track">
<div :class="cls.gradient" :style="gradientStyle" />
<input
type="range"
:class="cls.range"
:min="min"
:max="max"
:step="step"
:value="modelValue"
@input="emit('update:modelValue', inputNumberValue($event))"
/>
<div :class="cls.thumb" :style="{ left: thumbLeft(), background: thumbFill }" />
</div>
<input
type="number"
:class="cls.input"
:min="display?.min ?? min"
:max="display?.max ?? max"
:step="display?.step ?? step"
:value="numberValue()"
@change="handleNumberChange(inputNumberValue($event))"
/>
</div>
</template>

View file

@ -0,0 +1,79 @@
<script setup lang="ts">
import { ColorSliderRoot, ColorSliderThumb, ColorSliderTrack } from 'reka-ui'
import type { Color as RekaColor, ColorChannel, ColorSpace } from 'reka-ui'
import type { ColorSliderUI } from '@/components/color-picker-panel/ui'
import NumberField from '@/components/inputs/NumberField.vue'
import { useColorSliderUI } from '@/components/color-picker-panel/ui'
interface StandardColorSliderProps {
label: string
modelValue: RekaColor
channel: ColorChannel
colorSpace?: ColorSpace
step?: number
numberValue: number
numberMin: number
numberMax: number
numberStep?: number
suffix?: string
checkerboard?: boolean
thumbFill?: string
ui?: ColorSliderUI
}
const {
label,
modelValue,
channel,
colorSpace = 'hsl',
step,
numberValue,
numberMin,
numberMax,
numberStep = 1,
suffix,
checkerboard = false,
thumbFill = '#fff',
ui
} = defineProps<StandardColorSliderProps>()
const emit = defineEmits<{
update: [color: RekaColor]
updateNumber: [value: number]
}>()
const styles = useColorSliderUI(
() => checkerboard,
() => ui
)
</script>
<template>
<div :class="styles.root.value">
<span :class="styles.label.value">{{ label }}</span>
<ColorSliderRoot
:class="styles.slider.value"
:model-value="modelValue"
:channel="channel"
:color-space="colorSpace"
:step="step"
data-slot="slider"
@update:color="emit('update', $event)"
>
<ColorSliderTrack :class="styles.track.value" />
<ColorSliderThumb :class="styles.thumb.value" :style="{ background: thumbFill }" />
</ColorSliderRoot>
<NumberField
:class="styles.input.value"
:aria-label="label"
:model-value="numberValue"
:min="numberMin"
:max="numberMax"
:step="numberStep"
:suffix="suffix"
:ui="{ leading: 'hidden' }"
@update:model-value="emit('updateNumber', $event)"
/>
</div>
</template>

View file

@ -0,0 +1,19 @@
import { tv } from 'tailwind-variants'
import { computed } from 'vue'
import type { ComponentUI } from '@/components/ui/types'
import theme from '@/theme/color-slider'
export type ColorSliderUI = ComponentUI<typeof theme>
export function useColorSliderUI(checkerboard: () => boolean, ui: () => ColorSliderUI | undefined) {
const styles = computed(() => tv(theme)({ checkerboard: checkerboard() }))
return {
root: computed(() => styles.value.root({ class: ui()?.root })),
label: computed(() => styles.value.label({ class: ui()?.label })),
slider: computed(() => styles.value.slider({ class: ui()?.slider })),
track: computed(() => styles.value.track({ class: ui()?.track })),
thumb: computed(() => styles.value.thumb({ class: ui()?.thumb })),
input: computed(() => styles.value.input({ class: ui()?.input }))
}
}

View file

@ -1,11 +1,13 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge'
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
import { applySolidFillColor, FillPickerRoot, useI18n } from '@open-pencil/vue'
import { applySolidFillColor, FillRoot, useI18n } from '@open-pencil/vue'
import GradientEditor from './GradientEditor.vue'
import ColorPickerPanel from '@/components/color-picker-panel/ColorPickerPanel.vue'
import ImageFillPicker from './ImageFillPicker.vue'
import GradientEditor from '@/components/fill-picker/GradientEditor.vue'
import ImageFillPicker from '@/components/fill-picker/ImageFillPicker.vue'
import FillSwatch from '@/components/ui/FillSwatch.vue'
import Tip from '@/components/ui/Tip.vue'
import { usePopoverUI } from '@/components/ui/popover'
@ -37,70 +39,76 @@ const { panels } = useI18n()
</script>
<template>
<FillPickerRoot
:fill="fill"
:ui="{
content: cls.content,
swatch: 'size-5 shrink-0 cursor-pointer rounded border border-border p-0'
}"
@update="emit('update', $event)"
>
<template #trigger="{ style }">
<button
data-test-id="fill-picker-swatch"
class="size-5 shrink-0 cursor-pointer rounded border border-border p-0"
:style="{ ...style, background: swatchBackground ?? style.background }"
/>
</template>
<template #default="{ fill: currentFill, category, toSolid, toGradient, toImage }">
<div class="mb-2 flex items-center gap-0.5">
<Tip :label="panels.solid">
<button
:class="tabClass(category === 'SOLID')"
data-test-id="fill-picker-tab-solid"
@click="toSolid"
>
<icon-lucide-square class="size-3.5" />
</button>
</Tip>
<Tip :label="panels.linearGradient">
<button
:class="tabClass(category === 'GRADIENT')"
data-test-id="fill-picker-tab-gradient"
@click="toGradient"
>
<icon-lucide-blend class="size-3.5" />
</button>
</Tip>
<Tip :label="panels.image">
<button
:class="tabClass(category === 'IMAGE')"
data-test-id="fill-picker-tab-image"
@click="toImage"
>
<icon-lucide-image class="size-3.5" />
</button>
</Tip>
</div>
<FillRoot :fill="fill" @update="emit('update', $event)" v-slot="root">
<PopoverRoot>
<PopoverTrigger as-child>
<button
type="button"
:aria-label="panels.fill"
data-test-id="fill-picker-swatch"
class="size-5 shrink-0 cursor-pointer rounded border-0 bg-transparent p-0"
>
<FillSwatch :fill="fill" class="size-full" v-slot="swatch">
<span
class="pointer-events-none absolute inset-0"
:style="{ background: swatchBackground ?? swatch.background }"
/>
</FillSwatch>
</button>
</PopoverTrigger>
<ColorPickerPanel
v-if="category === 'SOLID'"
:color="currentFill.color"
:okhcl="okhcl"
@update="emit('update', applySolidFillColor(currentFill, $event))"
/>
<PopoverPortal>
<PopoverContent :class="cls.content" :side-offset="4" side="left">
<div class="mb-2 flex items-center gap-0.5">
<Tip :label="panels.solid">
<button
:class="tabClass(root.category === 'SOLID')"
data-test-id="fill-picker-tab-solid"
@click="root.actions.toSolid"
>
<icon-lucide-square class="size-3.5" />
</button>
</Tip>
<Tip :label="panels.linearGradient">
<button
:class="tabClass(root.category === 'GRADIENT')"
data-test-id="fill-picker-tab-gradient"
@click="root.actions.toGradient"
>
<icon-lucide-blend class="size-3.5" />
</button>
</Tip>
<Tip :label="panels.image">
<button
:class="tabClass(root.category === 'IMAGE')"
data-test-id="fill-picker-tab-image"
@click="root.actions.toImage"
>
<icon-lucide-image class="size-3.5" />
</button>
</Tip>
</div>
<GradientEditor
v-if="category === 'GRADIENT'"
:fill="currentFill"
@update="emit('update', $event)"
/>
<ColorPickerPanel
v-if="root.category === 'SOLID'"
:color="root.fill.color"
:okhcl="okhcl"
@update="emit('update', applySolidFillColor(root.fill, $event))"
/>
<ImageFillPicker
v-if="category === 'IMAGE'"
:fill="currentFill"
@update="emit('update', $event)"
/>
</template>
</FillPickerRoot>
<GradientEditor
v-if="root.category === 'GRADIENT'"
:fill="root.fill"
@update="emit('update', $event)"
/>
<ImageFillPicker
v-if="root.category === 'IMAGE'"
:fill="root.fill"
@update="emit('update', $event)"
/>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</FillRoot>
</template>

View file

@ -40,12 +40,20 @@ const { panels } = useI18n()
@drag-stop="root.actions.dragStop"
v-slot="bar"
>
<div
<GradientEditorStop
v-for="(stop, idx) in bar.stops"
:key="idx"
class="absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-sm border-2 shadow-sm"
:stop="stop"
:index="idx"
:active="idx === bar.activeStopIndex"
:dragging="idx === bar.draggingIndex"
:removable="bar.stops.length > 2"
class="absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-sm border-2 shadow-sm data-[selected]:border-white data-[dragging]:cursor-grabbing"
:class="idx === bar.activeStopIndex ? 'border-white' : 'border-white/60'"
:style="{ left: `${stop.position * 100}%`, background: colorToCSS(stop.color) }"
@select="root.actions.selectStop"
@update-position="root.actions.updateStopPosition"
@remove="root.actions.removeStop"
@pointerdown.stop="bar.actions.stopPointerDown(idx, $event)"
/>
</GradientEditorBar>
@ -69,6 +77,9 @@ const { panels } = useI18n()
:stop="stop"
:index="idx"
:active="idx === root.activeStopIndex"
:removable="root.stops.length > 2"
:interactive="false"
class="flex items-center gap-1 py-0.5 data-[selected]:rounded data-[selected]:bg-hover/50"
@select="root.actions.selectStop"
@update-position="root.actions.updateStopPosition"
@update-color="root.actions.updateStopColor"
@ -76,49 +87,44 @@ const { panels } = useI18n()
@remove="root.actions.removeStop"
v-slot="s"
>
<div
class="flex items-center gap-1 py-0.5"
:class="{ 'rounded bg-hover/50': s.active }"
@click="s.actions.select"
<NumberField
class="w-11"
suffix="%"
:model-value="s.positionPercent"
:min="0"
:max="100"
@update:model-value="s.actions.updatePosition(Number($event))"
@click.stop
/>
<button
class="size-4 shrink-0 cursor-pointer rounded border border-border p-0"
:style="{ background: s.css }"
@click.stop="s.actions.select"
/>
<input
class="min-w-0 flex-1 rounded border border-border bg-input px-1 py-0.5 font-mono text-[11px] text-surface"
:value="s.hex"
maxlength="6"
@change="s.actions.updateColor(inputValue($event))"
@click.stop
/>
<NumberField
class="w-9"
suffix="%"
:model-value="s.opacityPercent"
:min="0"
:max="100"
@update:model-value="s.actions.updateOpacity(Number($event))"
@click.stop
/>
<button
v-if="root.stops.length > 2"
class="flex size-4 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted hover:text-surface"
aria-label="Remove gradient stop"
@click.stop="s.actions.remove"
>
<NumberField
class="w-11"
suffix="%"
:model-value="s.positionPercent"
:min="0"
:max="100"
@update:model-value="s.actions.updatePosition(Number($event))"
@click.stop
/>
<button
class="size-4 shrink-0 cursor-pointer rounded border border-border p-0"
:style="{ background: s.css }"
@click.stop="s.actions.select"
/>
<input
class="min-w-0 flex-1 rounded border border-border bg-input px-1 py-0.5 font-mono text-[11px] text-surface"
:value="s.hex"
maxlength="6"
@change="s.actions.updateColor(inputValue($event))"
@click.stop
/>
<NumberField
class="w-9"
suffix="%"
:model-value="s.opacityPercent"
:min="0"
:max="100"
@update:model-value="s.actions.updateOpacity(Number($event))"
@click.stop
/>
<button
v-if="root.stops.length > 2"
class="flex size-4 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted hover:text-surface"
@click.stop="s.actions.remove"
>
</button>
</div>
<icon-lucide-minus class="size-3" />
</button>
</GradientEditorStop>
</div>

View file

@ -0,0 +1,55 @@
<script lang="ts">
import type { VNode } from 'vue'
import type { Fill } from '@open-pencil/scene-graph'
import type { FillSwatchSlotProps } from '@open-pencil/vue'
import type { ComponentUI } from '@/components/ui/types'
import type theme from '@/theme/fill-swatch'
export type FillSwatchUI = ComponentUI<typeof theme>
export interface FillSwatchProps {
fill: Fill
label?: string
ui?: FillSwatchUI
}
export interface FillSwatchSlots {
default?(props: FillSwatchSlotProps): VNode[]
}
</script>
<script setup lang="ts">
import { computed, normalizeClass, useAttrs } from 'vue'
import { tv } from 'tailwind-variants'
import { FillSwatch as FillSwatchPrimitive } from '@open-pencil/vue'
import fillSwatchTheme from '@/theme/fill-swatch'
const { fill, label, ui } = defineProps<FillSwatchProps>()
const attrs = useAttrs()
const styles = computed(() => tv(fillSwatchTheme)())
const forwardedAttrs = computed(() => {
const { class: _class, ...rest } = attrs
return rest
})
defineSlots<FillSwatchSlots>()
defineOptions({ inheritAttrs: false })
</script>
<template>
<FillSwatchPrimitive
v-slot="swatch"
v-bind="forwardedAttrs"
:fill="fill"
:label="label"
:class="styles.root({ class: [ui?.root, normalizeClass(attrs.class)] })"
>
<slot v-bind="swatch">
<span
:class="styles.preview({ class: ui?.preview })"
:style="{ background: swatch.background }"
/>
</slot>
</FillSwatchPrimitive>
</template>

View file

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

View file

@ -0,0 +1,2 @@
export const CHECKERBOARD_BACKGROUND =
'bg-[var(--color-checkerboard)] bg-[image:linear-gradient(45deg,var(--color-checkerboard-muted)_25%,transparent_25%),linear-gradient(-45deg,var(--color-checkerboard-muted)_25%,transparent_25%),linear-gradient(45deg,transparent_75%,var(--color-checkerboard-muted)_75%),linear-gradient(-45deg,transparent_75%,var(--color-checkerboard-muted)_75%)] bg-[size:8px_8px] bg-[position:0_0,0_4px,4px_-4px,-4px_0]'

24
src/theme/color-slider.ts Normal file
View file

@ -0,0 +1,24 @@
import { CHECKERBOARD_BACKGROUND } from '@/theme/checkerboard'
export default {
slots: {
root: 'flex items-center gap-2',
label: 'w-4 shrink-0 text-[10px] font-medium text-muted',
slider: 'relative flex h-3 flex-1 touch-none items-center rounded-md select-none',
track: 'absolute inset-0 overflow-hidden rounded-md',
thumb:
'block size-3.5 rounded-full border-2 border-white shadow-sm outline-none ring-offset-1 focus-visible:ring-2 focus-visible:ring-primary',
input: 'w-14 shrink-0'
},
variants: {
checkerboard: {
true: {
slider: CHECKERBOARD_BACKGROUND
},
false: {}
}
},
defaultVariants: {
checkerboard: false
}
} as const

8
src/theme/fill-swatch.ts Normal file
View file

@ -0,0 +1,8 @@
import { CHECKERBOARD_BACKGROUND } from '@/theme/checkerboard'
export default {
slots: {
root: `relative block overflow-hidden rounded border border-border ${CHECKERBOARD_BACKGROUND}`,
preview: 'pointer-events-none absolute inset-0'
}
} as const

View file

@ -61,16 +61,15 @@ async function chooseFormat(label: 'RGB' | 'HSL' | 'HSB' | 'OkHCL') {
}
async function dragSlider(testId: string, ratio: number) {
const slider = page.getByTestId(testId).locator('input[type="range"]')
const slider = page.getByTestId(testId).locator(':scope > [data-orientation="horizontal"]')
const box = await slider.boundingBox()
if (!box) throw new Error(`Missing slider: ${testId}`)
const y = box.y + box.height / 2
await page.mouse.move(box.x + 2, y)
await page.mouse.down()
await page.mouse.move(box.x + Math.max(2, Math.min(box.width - 2, box.width * ratio)), y, {
steps: 20
await slider.click({
position: {
x: Math.max(2, Math.min(box.width - 2, box.width * ratio)),
y: box.height / 2
}
})
await page.mouse.up()
await canvas.waitForRender()
}
@ -137,7 +136,7 @@ test('hsb saturation and brightness sliders both affect fill color', async () =>
await chooseFormat('HSB')
const beforeS = await getSelectedFill()
await dragSlider('color-slider-hsb-s', 0.15)
await dragSlider('color-slider-hsb-s', 0.75)
const afterS = await getSelectedFill()
expect(afterS).not.toBeNull()
expect(
@ -157,8 +156,25 @@ test('hsb saturation and brightness sliders both affect fill color', async () =>
).toBe(true)
})
test('gradient stops support keyboard nudging and removal', async () => {
await openFillPicker()
await page.getByTestId('fill-picker-tab-gradient').click()
await page.getByTestId('fill-picker-add-stop').click()
const stops = page.getByTestId('fill-picker-gradient-bar').getByRole('slider')
await expect(stops).toHaveCount(3)
const first = stops.first()
await first.focus()
const before = Number(await first.getAttribute('aria-valuenow'))
await first.press('ArrowRight')
await expect(first).toHaveAttribute('aria-valuenow', String(Math.min(100, before + 1)))
await first.press('Delete')
await expect(stops).toHaveCount(2)
})
test('okhcl channels preserve intent metadata while updating the fill', async () => {
await openFillPicker()
await page.getByTestId('fill-picker-tab-solid').click()
await chooseFormat('OkHCL')
await dragSlider('color-slider-okhcl-c', 0.6)

View file

@ -0,0 +1,97 @@
import { describe, expect, test } from 'bun:test'
import { ref } from 'vue'
import type { Fill } from '@open-pencil/scene-graph'
import {
fillCategory,
fillIsTransparent,
fillSwatchBackground,
useFill,
useGradientStops
} from '@open-pencil/vue'
const solid: Fill = {
type: 'SOLID',
color: { r: 0.2, g: 0.4, b: 0.6, a: 1 },
opacity: 1,
visible: true
}
function gradient(): Fill {
return {
...solid,
type: 'GRADIENT_LINEAR',
gradientStops: [
{ color: { r: 1, g: 0, b: 0, a: 1 }, position: 0 },
{ color: { r: 0, g: 0, b: 1, a: 0.5 }, position: 1 }
]
}
}
describe('fill model', () => {
test('classifies supported fill categories', () => {
expect(fillCategory(solid)).toBe('SOLID')
expect(fillCategory(gradient())).toBe('GRADIENT')
expect(fillCategory({ ...solid, type: 'IMAGE' })).toBe('IMAGE')
})
test('reports transparency from fill opacity and gradient stops', () => {
expect(fillIsTransparent(solid)).toBe(false)
expect(fillIsTransparent({ ...solid, opacity: 0.5 })).toBe(true)
expect(fillIsTransparent(gradient())).toBe(true)
})
test('builds solid and gradient swatch backgrounds', () => {
expect(fillSwatchBackground(solid)).toStartWith('rgb')
expect(fillSwatchBackground(gradient())).toStartWith('linear-gradient')
})
test('converts between categories without mutating the source', () => {
const source = ref(structuredClone(solid))
const updates: Fill[] = []
const model = useFill(source, (fill) => updates.push(fill))
model.toSolid()
expect(updates).toHaveLength(0)
model.toGradient()
expect(updates).toHaveLength(1)
expect(updates[0].type).toBe('GRADIENT_LINEAR')
expect(updates[0].gradientStops).toHaveLength(2)
expect(source.value).toEqual(solid)
source.value = updates[0]
model.toImage()
expect(updates[1].type).toBe('IMAGE')
})
})
describe('gradient stop color model', () => {
test('preserves alpha while applying hex through useColorModel', () => {
const source = ref(gradient())
let updated = source.value
const model = useGradientStops(source, (fill) => {
updated = fill
source.value = fill
})
model.updateStopColor(1, '00ff00')
expect(updated.gradientStops?.[1].color).toEqual({ r: 0, g: 1, b: 0, a: 0.5 })
})
test('clamps keyboard-compatible percent position updates', () => {
const source = ref(gradient())
let updated = source.value
const model = useGradientStops(source, (fill) => {
updated = fill
source.value = fill
})
model.updateStopPosition(0, 125)
expect(updated.gradientStops?.[0].position).toBe(1)
model.updateStopPosition(0, -5)
expect(updated.gradientStops?.[0].position).toBe(0)
})
})