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:
parent
bf90a70d05
commit
422f83331f
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
])
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
|
||||
|
||||
export default defineComponentMetaLoader([
|
||||
'packages/vue/src/primitives/Fill/FillRoot.vue'
|
||||
])
|
||||
51
packages/docs/programmable/sdk/api/components/fill-root.md
Normal file
51
packages/docs/programmable/sdk/api/components/fill-root.md
Normal 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)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
|
||||
|
||||
export default defineComponentMetaLoader([
|
||||
'packages/vue/src/primitives/Fill/FillSwatch.vue'
|
||||
])
|
||||
48
packages/docs/programmable/sdk/api/components/fill-swatch.md
Normal file
48
packages/docs/programmable/sdk/api/components/fill-swatch.md
Normal 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)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
|
||||
|
||||
export default defineComponentMetaLoader([
|
||||
'packages/vue/src/primitives/GradientEditor/GradientEditorStop.vue'
|
||||
])
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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." />
|
||||
|
|
|
|||
|
|
@ -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()`
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
28
packages/vue/src/primitives/ChannelSlider/context.ts
Normal file
28
packages/vue/src/primitives/ChannelSlider/context.ts
Normal 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
|
||||
}
|
||||
10
packages/vue/src/primitives/ChannelSlider/index.ts
Normal file
10
packages/vue/src/primitives/ChannelSlider/index.ts
Normal 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'
|
||||
41
packages/vue/src/primitives/ChannelSlider/types.ts
Normal file
41
packages/vue/src/primitives/ChannelSlider/types.ts
Normal 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
|
||||
}
|
||||
46
packages/vue/src/primitives/Fill/ColorFill.stories.ts
Normal file
46
packages/vue/src/primitives/Fill/ColorFill.stories.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
27
packages/vue/src/primitives/Fill/FillRoot.vue
Normal file
27
packages/vue/src/primitives/Fill/FillRoot.vue
Normal 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>
|
||||
55
packages/vue/src/primitives/Fill/FillSwatch.vue
Normal file
55
packages/vue/src/primitives/Fill/FillSwatch.vue
Normal 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>
|
||||
135
packages/vue/src/primitives/Fill/demo/ColorFillDemo.vue
Normal file
135
packages/vue/src/primitives/Fill/demo/ColorFillDemo.vue
Normal 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>
|
||||
17
packages/vue/src/primitives/Fill/index.ts
Normal file
17
packages/vue/src/primitives/Fill/index.ts
Normal 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'
|
||||
48
packages/vue/src/primitives/Fill/types.ts
Normal file
48
packages/vue/src/primitives/Fill/types.ts
Normal 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
|
||||
}
|
||||
91
packages/vue/src/primitives/Fill/useFill.ts
Normal file
91
packages/vue/src/primitives/Fill/useFill.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
/** @deprecated Compose `FillRoot`, `FillSwatch`, and a popover in the consumer. */
|
||||
export { default as FillPickerRoot } from '#vue/primitives/FillPicker/FillPickerRoot.vue'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
39
packages/vue/src/primitives/GradientEditor/types.ts
Normal file
39
packages/vue/src/primitives/GradientEditor/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
84
src/components/color-picker-panel/OkhclChannelSlider.vue
Normal file
84
src/components/color-picker-panel/OkhclChannelSlider.vue
Normal 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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
79
src/components/color-picker-panel/StandardColorSlider.vue
Normal file
79
src/components/color-picker-panel/StandardColorSlider.vue
Normal 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>
|
||||
19
src/components/color-picker-panel/ui.ts
Normal file
19
src/components/color-picker-panel/ui.ts
Normal 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 }))
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
55
src/components/ui/FillSwatch.vue
Normal file
55
src/components/ui/FillSwatch.vue
Normal 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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
2
src/theme/checkerboard.ts
Normal file
2
src/theme/checkerboard.ts
Normal 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
24
src/theme/color-slider.ts
Normal 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
8
src/theme/fill-swatch.ts
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
97
tests/engine/vue/fill/model.test.ts
Normal file
97
tests/engine/vue/fill/model.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue