From 5371ed11b853d46aca47aeee1be5dd9b09f6d92e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 14 Jul 2026 17:06:28 +0300 Subject: [PATCH] feat(app): migrate paint effect and export panels - Share compact item-row and paint-field anatomy across Fill, Stroke, Effects, and Export - Add COLOR binding transactions with picker rollback and one-step undo - Replace bespoke panel test hooks with semantic selectors and visual coverage --- CHANGELOG.md | 1 + packages/docs/.vitepress/sdk-sidebar.ts | 3 +- .../programmable/sdk/api/advanced/index.md | 1 + .../advanced/use-color-binding-provider.md | 37 ++++ .../api/components/color-picker-root.data.ts | 5 + .../sdk/api/components/color-picker-root.md | 83 ++++--- packages/scene-graph/src/undo.ts | 4 + packages/vue/README.md | 1 + .../src/controls/binding-provider/color.ts | 68 ++++++ .../src/controls/binding-provider/index.ts | 4 + packages/vue/src/controls/effects/helpers.ts | 10 +- .../vue/src/controls/property-list/use.ts | 12 +- packages/vue/src/controls/undo-batch/use.ts | 3 + packages/vue/src/index.ts | 3 +- .../ColorPicker/ColorPickerRoot.vue | 26 ++- src/app/shell/keyboard/registry.ts | 1 + src/components/ColorPicker/ColorPicker.vue | 15 +- .../color-picker-panel/RgbFields.vue | 3 + src/components/fill-picker/FillPicker.vue | 21 +- .../properties/BoundVariableButton.vue | 28 --- src/components/properties/ColorStyleRow.vue | 119 ---------- src/components/properties/EffectsSection.vue | 156 ++++++++------ src/components/properties/ExportSection.vue | 65 +++--- src/components/properties/FillSection.vue | 196 ++++++++++------- .../properties/PropertyListRoot.vue | 4 + src/components/properties/StrokeSection.vue | 204 ++++++++++++------ .../properties/VariablePickerPopover.vue | 191 ---------------- .../binding/VariableBindingPicker.vue | 2 +- src/components/properties/color-style-row.ts | 73 ------- .../properties/item-list/PropertyItemRow.vue | 81 +++++++ .../properties/paint/PaintField.vue | 68 ++++++ .../properties/paint/PaintValue.vue | 42 ++++ src/components/properties/paint/binding.ts | 31 +++ .../{fill-okhcl.ts => paint/okhcl.ts} | 21 +- src/components/ui/panel/PanelItemRow.vue | 41 ++++ src/components/ui/panel/index.ts | 1 + src/theme/paint-field.ts | 10 + src/theme/panel/item-row.ts | 9 + tests/e2e/design/panel.spec.ts | 88 ++++++-- ...paint-effects-export-openpencil-darwin.png | Bin 0 -> 22622 bytes tests/e2e/export/basic.spec.ts | 34 +-- .../properties/corner-stroke-toggles.spec.ts | 19 +- tests/e2e/properties/panel.spec.ts | 121 +++++++++-- tests/e2e/properties/visibility.spec.ts | 14 +- tests/e2e/stroke-picker/basic.spec.ts | 104 +++++++-- tests/e2e/ui/tooltips.webkit.spec.ts | 13 +- tests/e2e/variables/dialog.spec.ts | 7 +- .../controls/color-binding-provider.test.ts | 73 +++++++ tests/helpers/properties.ts | 10 + 49 files changed, 1303 insertions(+), 823 deletions(-) create mode 100644 packages/docs/programmable/sdk/api/advanced/use-color-binding-provider.md create mode 100644 packages/docs/programmable/sdk/api/components/color-picker-root.data.ts create mode 100644 packages/vue/src/controls/binding-provider/color.ts delete mode 100644 src/components/properties/BoundVariableButton.vue delete mode 100644 src/components/properties/ColorStyleRow.vue delete mode 100644 src/components/properties/VariablePickerPopover.vue delete mode 100644 src/components/properties/color-style-row.ts create mode 100644 src/components/properties/item-list/PropertyItemRow.vue create mode 100644 src/components/properties/paint/PaintField.vue create mode 100644 src/components/properties/paint/PaintValue.vue create mode 100644 src/components/properties/paint/binding.ts rename src/components/properties/{fill-okhcl.ts => paint/okhcl.ts} (51%) create mode 100644 src/components/ui/panel/PanelItemRow.vue create mode 100644 src/theme/paint-field.ts create mode 100644 src/theme/panel/item-row.ts create mode 100644 tests/e2e/design/panel.spec.ts-snapshots/design-panel-paint-effects-export-openpencil-darwin.png create mode 100644 tests/engine/vue/controls/color-binding-provider.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dae2d447c..3bac5b2b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - 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. +- Rebuild Fill, Stroke, Effects, and Export controls with shared compact item rows, semantic actions, binding-aware paint fields, and reversible color-picker edits. - 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 6e814e8bb..aaf60872d 100644 --- a/packages/docs/.vitepress/sdk-sidebar.ts +++ b/packages/docs/.vitepress/sdk-sidebar.ts @@ -12,7 +12,7 @@ const SDK_COMPONENT_PAGES = [ { text: 'SegmentedControl', slug: 'segmented-control', canonical: true }, { text: 'PropertyListRoot', slug: 'property-list-root' }, { text: 'PropertyListItem', slug: 'property-list-item' }, - { text: 'ColorPickerRoot', slug: 'color-picker-root' }, + { text: 'ColorPickerRoot', slug: 'color-picker-root', canonical: true }, { text: 'ColorInputRoot', slug: 'color-input-root' }, { text: 'ChannelSlider', slug: 'channel-slider', canonical: true }, { text: 'FillRoot', slug: 'fill-root', canonical: true }, @@ -57,6 +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: 'useColorBindingProvider', slug: 'use-color-binding-provider', canonical: true }, { text: 'useFillPicker', slug: 'use-fill-picker', canonical: true }, { text: 'useGradientStops', slug: 'use-gradient-stops' }, { text: 'useFontPicker', slug: 'use-font-picker' }, diff --git a/packages/docs/programmable/sdk/api/advanced/index.md b/packages/docs/programmable/sdk/api/advanced/index.md index 9f68d512b..bbf64ff3c 100644 --- a/packages/docs/programmable/sdk/api/advanced/index.md +++ b/packages/docs/programmable/sdk/api/advanced/index.md @@ -16,6 +16,7 @@ These APIs are public, but they are more specialized than the main component and ## Picker, variables, locale, and editor internals - [useColorVariableBinding](./use-color-variable-binding) +- [useColorBindingProvider](./use-color-binding-provider) - [useFillPicker](./use-fill-picker) - [useGradientStops](./use-gradient-stops) - [useFontPicker](./use-font-picker) diff --git a/packages/docs/programmable/sdk/api/advanced/use-color-binding-provider.md b/packages/docs/programmable/sdk/api/advanced/use-color-binding-provider.md new file mode 100644 index 000000000..23e309559 --- /dev/null +++ b/packages/docs/programmable/sdk/api/advanced/use-color-binding-provider.md @@ -0,0 +1,37 @@ +--- +title: useColorBindingProvider +description: OpenPencil COLOR variable provider for BindableValue fields. +--- + +# useColorBindingProvider + +`useColorBindingProvider()` adapts OpenPencil COLOR variables to the generic `BindingProvider` +contract. It resolves current-mode colors, binds indexed paint paths, creates variables in a Colors +collection, updates all collection modes when requested, and exposes editor undo transactions. + +Use it with `BindableValueRoot` and explicit targets such as `fills/0/color` or +`strokes/0/color`. Picker focus and opening remain non-destructive; the consumer chooses when an +actual color mutation begins and commits. + +```ts twoslash +import type { Color } from '@open-pencil/scene-graph' +import type { BindingTarget } from '@open-pencil/vue' +import { useColorBindingProvider } from '@open-pencil/vue' + +const provider = useColorBindingProvider() +const targets: BindingTarget[] = [ + { nodeId: 'rectangle-id', path: 'fills/0/color' } +] +const value: Color = { r: 0.2, g: 0.5, b: 0.9, a: 1 } + +provider.getState(targets) +provider.resolve('variable-id') +``` + +The composable requires an editor provided by `provideEditor()`. + +## Related APIs + +- [BindableValue](../components/bindable-value) +- [FillSwatch](../components/fill-swatch) +- [useColorModel](../composables/use-color-model) diff --git a/packages/docs/programmable/sdk/api/components/color-picker-root.data.ts b/packages/docs/programmable/sdk/api/components/color-picker-root.data.ts new file mode 100644 index 000000000..8c9fe5748 --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/color-picker-root.data.ts @@ -0,0 +1,5 @@ +import { defineComponentMetaLoader } from '#docs/sdk/component-meta' + +export default defineComponentMetaLoader([ + 'packages/vue/src/primitives/ColorPicker/ColorPickerRoot.vue' +]) diff --git a/packages/docs/programmable/sdk/api/components/color-picker-root.md b/packages/docs/programmable/sdk/api/components/color-picker-root.md index ab3bf1ce0..78a4cb092 100644 --- a/packages/docs/programmable/sdk/api/components/color-picker-root.md +++ b/packages/docs/programmable/sdk/api/components/color-picker-root.md @@ -1,59 +1,54 @@ --- title: ColorPickerRoot -description: Headless popover-based color picker primitive. +description: Headless popover-based color picker with interaction lifecycle events. --- + + # ColorPickerRoot -`ColorPickerRoot` is a headless popover-based color picker primitive. +`ColorPickerRoot` composes a color swatch trigger with a popover surface while leaving the editor UI +to its slots. The trigger slot receives the current swatch style; the default slot receives the +current scene-graph color. -It provides: +`openChange` reports the complete picker interaction boundary. `cancel` fires before an Escape +close, allowing BindableValue consumers to roll back a variable detach and paint update together. +Opening or focusing the picker does not emit a color update. -- a trigger slot with swatch background styling -- a default trigger fallback -- a content slot with `color` and `update()` +```vue twoslash + - - -## Events - - - -## Slots - - - -## Example - -```vue - - - - - + ``` +## Generated API reference + + + ## Related APIs - [ColorInputRoot](./color-input-root) +- [useColorModel](../composables/use-color-model) +- [BindableValue](./bindable-value) diff --git a/packages/scene-graph/src/undo.ts b/packages/scene-graph/src/undo.ts index 4be260ece..af9780c23 100644 --- a/packages/scene-graph/src/undo.ts +++ b/packages/scene-graph/src/undo.ts @@ -101,6 +101,10 @@ export class UndoManager { this.batches = [] } + get isBatching(): boolean { + return this.batches.length > 0 + } + get canUndo(): boolean { return this.undoStack.length > 0 } diff --git a/packages/vue/README.md b/packages/vue/README.md index d77bf91e3..a240f471b 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -179,6 +179,7 @@ These exports are intentionally public, but they are lower-level or more special - `useNodeProps()` - `useEditorPropertyList()` - `useSceneComputed()` +- `useColorBindingProvider()` - `useColorVariableBinding()` - `provideBindingProvider()` - `useBindingProvider()` diff --git a/packages/vue/src/controls/binding-provider/color.ts b/packages/vue/src/controls/binding-provider/color.ts new file mode 100644 index 000000000..4255084d6 --- /dev/null +++ b/packages/vue/src/controls/binding-provider/color.ts @@ -0,0 +1,68 @@ +import type { Editor } from '@open-pencil/core/editor' +import { randomHex } from '@open-pencil/core/random' +import type { VariableCollection } from '@open-pencil/scene-graph' +import type { Color } from '@open-pencil/scene-graph/primitives' + +import { useOpenPencilBindingProvider } from '#vue/controls/binding-provider/open-pencil' +import type { BindingTarget } from '#vue/controls/binding-provider/types' + +const FALLBACK_COLOR_VARIABLE_NAME = 'New color' + +function colorCollection(editor: Editor): VariableCollection { + const existing = editor + .getCollections() + .find((collection) => + collection.variableIds.some((variableId) => editor.getVariable(variableId)?.type === 'COLOR') + ) + if (existing) return existing + + const collection: VariableCollection = { + id: `col:${randomHex(8)}`, + name: 'Colors', + modes: [{ modeId: 'default', name: 'Mode 1' }], + defaultModeId: 'default', + variableIds: [] + } + editor.addCollection(collection) + return collection +} + +export function createAndBindColorVariable( + editor: Editor, + target: BindingTarget, + value: Color, + name = FALLBACK_COLOR_VARIABLE_NAME +) { + const collection = colorCollection(editor) + const id = `var:${randomHex(8)}` + editor.addVariable({ + id, + name: name.trim() || FALLBACK_COLOR_VARIABLE_NAME, + type: 'COLOR', + collectionId: collection.id, + valuesByMode: Object.fromEntries( + collection.modes.map((mode) => [mode.modeId, structuredClone(value)]) + ), + description: '', + hiddenFromPublishing: false + }) + editor.bindVariable(target.nodeId, target.path, id) +} + +export function setColorVariableValue(editor: Editor, variableId: string, value: Color) { + const variable = editor.getVariable(variableId) + if (!variable) return + const collection = editor.getCollection(variable.collectionId) + if (!collection) return + for (const mode of collection.modes) + editor.updateVariableValue(variableId, mode.modeId, structuredClone(value)) +} + +export function useColorBindingProvider() { + return useOpenPencilBindingProvider({ + type: 'COLOR', + resolve: (editor, variableId) => editor.resolveColorVariable(variableId), + create: createAndBindColorVariable, + setValue: setColorVariableValue + }) +} diff --git a/packages/vue/src/controls/binding-provider/index.ts b/packages/vue/src/controls/binding-provider/index.ts index e29a8a704..2915dde65 100644 --- a/packages/vue/src/controls/binding-provider/index.ts +++ b/packages/vue/src/controls/binding-provider/index.ts @@ -1,3 +1,7 @@ +export { + createAndBindColorVariable, + useColorBindingProvider +} from '#vue/controls/binding-provider/color' export { BINDING_PROVIDER_KEY, provideBindingProvider, diff --git a/packages/vue/src/controls/effects/helpers.ts b/packages/vue/src/controls/effects/helpers.ts index c869d16b8..935acc4f2 100644 --- a/packages/vue/src/controls/effects/helpers.ts +++ b/packages/vue/src/controls/effects/helpers.ts @@ -98,15 +98,19 @@ export function createEffectControlActions(expandedIndex: Ref) { patch(index, { color }) } - function handleRemove(removeFn: (index: number) => void, index: number) { - removeFn(index) + function adjustExpandedAfterRemove(index: number) { if (expandedIndex.value === index) expandedIndex.value = null else if (expandedIndex.value !== null && expandedIndex.value > index) expandedIndex.value-- } + function handleRemove(removeFn: (index: number) => void, index: number) { + removeFn(index) + adjustExpandedAfterRemove(index) + } + function toggleExpand(index: number) { expandedIndex.value = expandedIndex.value === index ? null : index } - return { updateType, updateColor, handleRemove, toggleExpand } + return { updateType, updateColor, handleRemove, adjustExpandedAfterRemove, toggleExpand } } diff --git a/packages/vue/src/controls/property-list/use.ts b/packages/vue/src/controls/property-list/use.ts index ec521df81..7eb575587 100644 --- a/packages/vue/src/controls/property-list/use.ts +++ b/packages/vue/src/controls/property-list/use.ts @@ -34,6 +34,7 @@ export function useEditorPropertyList(propKey: K) { void editor.state.sceneVersion return selectedNodes.value[0] ?? null }) + const selectedNodeIds = computed(() => selectedNodes.value.map((node) => node.id)) const isMulti = computed(() => selectedNodes.value.length > 1) const active = computed(() => selectedNodes.value.length > 0) const isMixed = computed(() => isArrayMixed(propKey)) @@ -159,5 +160,14 @@ export function useEditorPropertyList(propKey: K) { reorder } - return { items, isMixed, isMulti, active, activeNode, actions } + return { + items, + isMixed, + isMulti, + active, + activeNode, + selectedNodeIds, + flush: batch.flush, + actions + } } diff --git a/packages/vue/src/controls/undo-batch/use.ts b/packages/vue/src/controls/undo-batch/use.ts index 6be2c349b..c5c8665ec 100644 --- a/packages/vue/src/controls/undo-batch/use.ts +++ b/packages/vue/src/controls/undo-batch/use.ts @@ -4,6 +4,8 @@ import type { UndoManager } from '@open-pencil/scene-graph' const BATCH_IDLE_MS = 300 +type BatchAwareUndoManager = UndoManager & { readonly isBatching: boolean } + export function useUndoBatch(undo: UndoManager) { let batchKey: string | null = null @@ -26,6 +28,7 @@ export function useUndoBatch(undo: UndoManager) { } function ensure(key: string, label: string) { + if (batchKey === null && (undo as BatchAwareUndoManager).isBatching) return if (batchKey !== key) { flush() undo.beginBatch(label) diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 09c996871..492d14c0e 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -257,7 +257,8 @@ export { provideBindingProvider, useBindingProvider, useOpenPencilBindingProvider, - useNumberBindingProvider + useNumberBindingProvider, + useColorBindingProvider } from '#vue/controls/binding-provider' export type { BindingMutationSource, diff --git a/packages/vue/src/primitives/ColorPicker/ColorPickerRoot.vue b/packages/vue/src/primitives/ColorPicker/ColorPickerRoot.vue index ff516b8fc..3bd79dd06 100644 --- a/packages/vue/src/primitives/ColorPicker/ColorPickerRoot.vue +++ b/packages/vue/src/primitives/ColorPicker/ColorPickerRoot.vue @@ -10,22 +10,37 @@ export interface ColorPickerUI { swatch?: string } -const { color, ui } = defineProps<{ +const { + color, + label = 'Edit color', + ui +} = defineProps<{ color: Color + label?: string ui?: ColorPickerUI }>() -const emit = defineEmits<{ update: [color: Color] }>() +const emit = defineEmits<{ + update: [color: Color] + openChange: [open: boolean] + cancel: [] +}>() const swatchBg = computed(() => colorToCSS(color)) + +function cancelFromEscape(event: KeyboardEvent) { + event.stopPropagation() + emit('cancel') +}