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') +}