diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a9619fb6..dae2d447c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/docs/.vitepress/sdk-sidebar.ts b/packages/docs/.vitepress/sdk-sidebar.ts index ec11132dd..6e814e8bb 100644 --- a/packages/docs/.vitepress/sdk-sidebar.ts +++ b/packages/docs/.vitepress/sdk-sidebar.ts @@ -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}` })) ] } diff --git a/packages/docs/programmable/sdk/api/advanced/use-fill-picker.md b/packages/docs/programmable/sdk/api/advanced/use-fill-picker.md index 0bb55e0da..91ce6a421 100644 --- a/packages/docs/programmable/sdk/api/advanced/use-fill-picker.md +++ b/packages/docs/programmable/sdk/api/advanced/use-fill-picker.md @@ -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 diff --git a/packages/docs/programmable/sdk/api/components/channel-slider.data.ts b/packages/docs/programmable/sdk/api/components/channel-slider.data.ts new file mode 100644 index 000000000..f88e3afce --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/channel-slider.data.ts @@ -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' +]) diff --git a/packages/docs/programmable/sdk/api/components/channel-slider.md b/packages/docs/programmable/sdk/api/components/channel-slider.md new file mode 100644 index 000000000..dc8acbe2f --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/channel-slider.md @@ -0,0 +1,56 @@ +--- +title: ChannelSlider +description: Accessible scalar channel slider for OkHCL controls. +--- + + + +# 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. + + + +## 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 + + + +``` + +## Generated API reference + + + +## Related APIs + +- [useColorModel](../composables/use-color-model) +- [FillRoot](./fill-root) diff --git a/packages/docs/programmable/sdk/api/components/fill-picker-root.md b/packages/docs/programmable/sdk/api/components/fill-picker-root.md index 95422cc16..33f19ac83 100644 --- a/packages/docs/programmable/sdk/api/components/fill-picker-root.md +++ b/packages/docs/programmable/sdk/api/components/fill-picker-root.md @@ -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 diff --git a/packages/docs/programmable/sdk/api/components/fill-root.data.ts b/packages/docs/programmable/sdk/api/components/fill-root.data.ts new file mode 100644 index 000000000..37c6fa8ce --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/fill-root.data.ts @@ -0,0 +1,5 @@ +import { defineComponentMetaLoader } from '#docs/sdk/component-meta' + +export default defineComponentMetaLoader([ + 'packages/vue/src/primitives/Fill/FillRoot.vue' +]) diff --git a/packages/docs/programmable/sdk/api/components/fill-root.md b/packages/docs/programmable/sdk/api/components/fill-root.md new file mode 100644 index 000000000..aed1db37b --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/fill-root.md @@ -0,0 +1,51 @@ +--- +title: FillRoot +description: Headless fill category state and conversion actions. +--- + + + +# 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. + + + +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 + + + +``` + +## Generated API reference + + + +## Related APIs + +- [FillSwatch](./fill-swatch) +- [ChannelSlider](./channel-slider) +- [useColorModel](../composables/use-color-model) diff --git a/packages/docs/programmable/sdk/api/components/fill-swatch.data.ts b/packages/docs/programmable/sdk/api/components/fill-swatch.data.ts new file mode 100644 index 000000000..10ecdd669 --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/fill-swatch.data.ts @@ -0,0 +1,5 @@ +import { defineComponentMetaLoader } from '#docs/sdk/component-meta' + +export default defineComponentMetaLoader([ + 'packages/vue/src/primitives/Fill/FillSwatch.vue' +]) diff --git a/packages/docs/programmable/sdk/api/components/fill-swatch.md b/packages/docs/programmable/sdk/api/components/fill-swatch.md new file mode 100644 index 000000000..a891d4bc2 --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/fill-swatch.md @@ -0,0 +1,48 @@ +--- +title: FillSwatch +description: Binding-aware semantic preview for solid, gradient, and image fills. +--- + + + +# 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`, 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 + + + +``` + +## Generated API reference + + + +## Related APIs + +- [FillRoot](./fill-root) +- [BindableValue](./bindable-value) diff --git a/packages/docs/programmable/sdk/api/components/gradient-editor-stop.data.ts b/packages/docs/programmable/sdk/api/components/gradient-editor-stop.data.ts new file mode 100644 index 000000000..30b4c5341 --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/gradient-editor-stop.data.ts @@ -0,0 +1,5 @@ +import { defineComponentMetaLoader } from '#docs/sdk/component-meta' + +export default defineComponentMetaLoader([ + 'packages/vue/src/primitives/GradientEditor/GradientEditorStop.vue' +]) diff --git a/packages/docs/programmable/sdk/api/components/gradient-editor-stop.md b/packages/docs/programmable/sdk/api/components/gradient-editor-stop.md index 2840244af..1dd06de07 100644 --- a/packages/docs/programmable/sdk/api/components/gradient-editor-stop.md +++ b/packages/docs/programmable/sdk/api/components/gradient-editor-stop.md @@ -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. --- + + # 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. - +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 + + + ``` -## Example +## Generated API reference -```vue - - - -``` + ## Related APIs - [GradientEditorRoot](./gradient-editor-root) - [GradientEditorBar](./gradient-editor-bar) +- [useColorModel](../composables/use-color-model) diff --git a/packages/docs/programmable/sdk/api/components/index.md b/packages/docs/programmable/sdk/api/components/index.md index ba999b273..182673ad3 100644 --- a/packages/docs/programmable/sdk/api/components/index.md +++ b/packages/docs/programmable/sdk/api/components/index.md @@ -37,7 +37,10 @@ description: Component reference for headless Vue primitives in @open-pencil/vue - + + + + diff --git a/packages/vue/README.md b/packages/vue/README.md index 28c278082..d77bf91e3 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -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()` diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index c6b7e1c36..09c996871 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -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' diff --git a/packages/vue/src/primitives/ChannelSlider/ChannelSliderRoot.vue b/packages/vue/src/primitives/ChannelSlider/ChannelSliderRoot.vue new file mode 100644 index 000000000..db02388df --- /dev/null +++ b/packages/vue/src/primitives/ChannelSlider/ChannelSliderRoot.vue @@ -0,0 +1,96 @@ + + + diff --git a/packages/vue/src/primitives/ChannelSlider/ChannelSliderThumb.vue b/packages/vue/src/primitives/ChannelSlider/ChannelSliderThumb.vue new file mode 100644 index 000000000..b9104a47d --- /dev/null +++ b/packages/vue/src/primitives/ChannelSlider/ChannelSliderThumb.vue @@ -0,0 +1,32 @@ + + + diff --git a/packages/vue/src/primitives/ChannelSlider/ChannelSliderTrack.vue b/packages/vue/src/primitives/ChannelSlider/ChannelSliderTrack.vue new file mode 100644 index 000000000..817c6d57d --- /dev/null +++ b/packages/vue/src/primitives/ChannelSlider/ChannelSliderTrack.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/vue/src/primitives/ChannelSlider/context.ts b/packages/vue/src/primitives/ChannelSlider/context.ts new file mode 100644 index 000000000..b3fa5b676 --- /dev/null +++ b/packages/vue/src/primitives/ChannelSlider/context.ts @@ -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 + label: ComputedRef + valueText: ComputedRef + min: ComputedRef + max: ComputedRef + step: ComputedRef + disabled: ComputedRef + orientation: ComputedRef +} + +const CHANNEL_SLIDER_KEY: InjectionKey = 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 +} diff --git a/packages/vue/src/primitives/ChannelSlider/index.ts b/packages/vue/src/primitives/ChannelSlider/index.ts new file mode 100644 index 000000000..52b7bd11b --- /dev/null +++ b/packages/vue/src/primitives/ChannelSlider/index.ts @@ -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' diff --git a/packages/vue/src/primitives/ChannelSlider/types.ts b/packages/vue/src/primitives/ChannelSlider/types.ts new file mode 100644 index 000000000..51603edaa --- /dev/null +++ b/packages/vue/src/primitives/ChannelSlider/types.ts @@ -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 +} diff --git a/packages/vue/src/primitives/Fill/ColorFill.stories.ts b/packages/vue/src/primitives/Fill/ColorFill.stories.ts new file mode 100644 index 000000000..14109432d --- /dev/null +++ b/packages/vue/src/primitives/Fill/ColorFill.stories.ts @@ -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 + +export default meta +type Story = StoryObj + +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') + } +} diff --git a/packages/vue/src/primitives/Fill/FillRoot.vue b/packages/vue/src/primitives/Fill/FillRoot.vue new file mode 100644 index 000000000..f68a802ad --- /dev/null +++ b/packages/vue/src/primitives/Fill/FillRoot.vue @@ -0,0 +1,27 @@ + + + diff --git a/packages/vue/src/primitives/Fill/FillSwatch.vue b/packages/vue/src/primitives/Fill/FillSwatch.vue new file mode 100644 index 000000000..0342c6da8 --- /dev/null +++ b/packages/vue/src/primitives/Fill/FillSwatch.vue @@ -0,0 +1,55 @@ + + + diff --git a/packages/vue/src/primitives/Fill/demo/ColorFillDemo.vue b/packages/vue/src/primitives/Fill/demo/ColorFillDemo.vue new file mode 100644 index 000000000..1a69063aa --- /dev/null +++ b/packages/vue/src/primitives/Fill/demo/ColorFillDemo.vue @@ -0,0 +1,135 @@ + + + diff --git a/packages/vue/src/primitives/Fill/index.ts b/packages/vue/src/primitives/Fill/index.ts new file mode 100644 index 000000000..76974ce17 --- /dev/null +++ b/packages/vue/src/primitives/Fill/index.ts @@ -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' diff --git a/packages/vue/src/primitives/Fill/types.ts b/packages/vue/src/primitives/Fill/types.ts new file mode 100644 index 000000000..f7de98cd1 --- /dev/null +++ b/packages/vue/src/primitives/Fill/types.ts @@ -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 +} diff --git a/packages/vue/src/primitives/Fill/useFill.ts b/packages/vue/src/primitives/Fill/useFill.ts new file mode 100644 index 000000000..4426bbca5 --- /dev/null +++ b/packages/vue/src/primitives/Fill/useFill.ts @@ -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> = { + 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, 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 + } +} diff --git a/packages/vue/src/primitives/FillPicker/index.ts b/packages/vue/src/primitives/FillPicker/index.ts index 136ead9df..1bad8dc62 100644 --- a/packages/vue/src/primitives/FillPicker/index.ts +++ b/packages/vue/src/primitives/FillPicker/index.ts @@ -1 +1,2 @@ +/** @deprecated Compose `FillRoot`, `FillSwatch`, and a popover in the consumer. */ export { default as FillPickerRoot } from '#vue/primitives/FillPicker/FillPickerRoot.vue' diff --git a/packages/vue/src/primitives/FillPicker/useFillPicker.ts b/packages/vue/src/primitives/FillPicker/useFillPicker.ts index 7760f76ea..669fa569d 100644 --- a/packages/vue/src/primitives/FillPicker/useFillPicker.ts +++ b/packages/vue/src/primitives/FillPicker/useFillPicker.ts @@ -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 = { - 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, 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 } } diff --git a/packages/vue/src/primitives/GradientEditor/GradientEditorStop.vue b/packages/vue/src/primitives/GradientEditor/GradientEditorStop.vue index c0756b4b6..bd6551c75 100644 --- a/packages/vue/src/primitives/GradientEditor/GradientEditorStop.vue +++ b/packages/vue/src/primitives/GradientEditor/GradientEditorStop.vue @@ -1,14 +1,26 @@ diff --git a/packages/vue/src/primitives/GradientEditor/index.ts b/packages/vue/src/primitives/GradientEditor/index.ts index d6d89e505..0f8b4006a 100644 --- a/packages/vue/src/primitives/GradientEditor/index.ts +++ b/packages/vue/src/primitives/GradientEditor/index.ts @@ -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' diff --git a/packages/vue/src/primitives/GradientEditor/types.ts b/packages/vue/src/primitives/GradientEditor/types.ts new file mode 100644 index 000000000..0ca49fe89 --- /dev/null +++ b/packages/vue/src/primitives/GradientEditor/types.ts @@ -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 +} diff --git a/packages/vue/src/primitives/GradientEditor/useGradientStops.ts b/packages/vue/src/primitives/GradientEditor/useGradientStops.ts index 5669c6db2..35bdf9212 100644 --- a/packages/vue/src/primitives/GradientEditor/useGradientStops.ts +++ b/packages/vue/src/primitives/GradientEditor/useGradientStops.ts @@ -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, 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, 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 } diff --git a/src/components/color-picker-panel/HsbFields.vue b/src/components/color-picker-panel/HsbFields.vue index 136c85094..ce8b96491 100644 --- a/src/components/color-picker-panel/HsbFields.vue +++ b/src/components/color-picker-panel/HsbFields.vue @@ -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() /> - -

{{ ctx.panels.colorHintHsb }}

diff --git a/src/components/color-picker-panel/HslFields.vue b/src/components/color-picker-panel/HslFields.vue index 4aaa6e790..54e4d4c83 100644 --- a/src/components/color-picker-panel/HslFields.vue +++ b/src/components/color-picker-panel/HslFields.vue @@ -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() /> - -

{{ ctx.panels.colorHintHsl }}

diff --git a/src/components/color-picker-panel/HueAlphaSliders.vue b/src/components/color-picker-panel/HueAlphaSliders.vue index f1a952193..279628859 100644 --- a/src/components/color-picker-panel/HueAlphaSliders.vue +++ b/src/components/color-picker-panel/HueAlphaSliders.vue @@ -1,7 +1,7 @@ + + diff --git a/src/components/color-picker-panel/OkhclFields.vue b/src/components/color-picker-panel/OkhclFields.vue index 678121e3d..96f07e9c1 100644 --- a/src/components/color-picker-panel/OkhclFields.vue +++ b/src/components/color-picker-panel/OkhclFields.vue @@ -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))}%`