diff --git a/AGENTS.md b/AGENTS.md index 482eaa856..caac8871e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,8 @@ Important invariant: all selection mutations in core go through `ctx.setSelected The app editor session (`src/app/editor/session/create.ts`) is a Vue wrapper around core: it creates reactive state, calls `createEditor()`, and assembles app-specific document I/O, autosave, export, vector edit, pen resume, flashes, profiler, and mobile clipboard. Tabs live in `src/app/tabs/`; active editor access lives in `src/app/editor/active-store/`. +Headless SDK fields compose variable/token binding through `BindingProvider` and the `BindableValue` primitives in `packages/vue/src/controls/binding-provider/` and `packages/vue/src/primitives/BindableValue/`. Keep numeric interaction in `NumberField`; providers own binding lookup, mutation, and undo batching. + ## Commands - `bun run check` — type-aware lint + typecheck via oxlint + tsgo + architecture checks (run before committing) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c07ec2d0..1600b254c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Refine Design panel foundations with 26px controls, consistently aligned action rails, shared Tailwind themes, and Storybook component states. - Standardize Vue SDK and app override type names on the `UI` acronym, including `FontPickerUI`. - Add a headless Vue SDK NumberField with pointer scrubbing, keyboard stepping, safe arithmetic expressions, and mixed/bound states; remove the superseded ScrubInput API. +- Add provider-driven BindableValue primitives for variable and token binding, including detach-on-edit, read-only, edit-variable, mixed-value, and undo-batched interactions. - 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 3f09ec44d..a5f6e2284 100644 --- a/packages/docs/.vitepress/sdk-sidebar.ts +++ b/packages/docs/.vitepress/sdk-sidebar.ts @@ -18,6 +18,7 @@ const SDK_COMPONENT_PAGES = [ { text: 'GradientEditorBar', slug: 'gradient-editor-bar' }, { text: 'GradientEditorStop', slug: 'gradient-editor-stop' }, { text: 'NumberField', slug: 'number-field' }, + { text: 'BindableValue', slug: 'bindable-value' }, { text: 'LayoutControlsRoot', slug: 'layout-controls-root' }, { text: 'AppearanceControlsRoot', slug: 'appearance-controls-root' }, { text: 'PositionControlsRoot', slug: 'position-controls-root' }, diff --git a/packages/docs/de/programmable/sdk/api/components/bindable-value.md b/packages/docs/de/programmable/sdk/api/components/bindable-value.md new file mode 100644 index 000000000..d5a57a497 --- /dev/null +++ b/packages/docs/de/programmable/sdk/api/components/bindable-value.md @@ -0,0 +1,10 @@ +--- +title: BindableValue +description: Provider-driven value binding primitives for custom editor controls. +--- + +# BindableValue + +The generated BindableValue API reference and interactive demo currently share one canonical source. + +[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value) diff --git a/packages/docs/es/programmable/sdk/api/components/bindable-value.md b/packages/docs/es/programmable/sdk/api/components/bindable-value.md new file mode 100644 index 000000000..d5a57a497 --- /dev/null +++ b/packages/docs/es/programmable/sdk/api/components/bindable-value.md @@ -0,0 +1,10 @@ +--- +title: BindableValue +description: Provider-driven value binding primitives for custom editor controls. +--- + +# BindableValue + +The generated BindableValue API reference and interactive demo currently share one canonical source. + +[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value) diff --git a/packages/docs/fr/programmable/sdk/api/components/bindable-value.md b/packages/docs/fr/programmable/sdk/api/components/bindable-value.md new file mode 100644 index 000000000..d5a57a497 --- /dev/null +++ b/packages/docs/fr/programmable/sdk/api/components/bindable-value.md @@ -0,0 +1,10 @@ +--- +title: BindableValue +description: Provider-driven value binding primitives for custom editor controls. +--- + +# BindableValue + +The generated BindableValue API reference and interactive demo currently share one canonical source. + +[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value) diff --git a/packages/docs/it/programmable/sdk/api/components/bindable-value.md b/packages/docs/it/programmable/sdk/api/components/bindable-value.md new file mode 100644 index 000000000..d5a57a497 --- /dev/null +++ b/packages/docs/it/programmable/sdk/api/components/bindable-value.md @@ -0,0 +1,10 @@ +--- +title: BindableValue +description: Provider-driven value binding primitives for custom editor controls. +--- + +# BindableValue + +The generated BindableValue API reference and interactive demo currently share one canonical source. + +[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value) diff --git a/packages/docs/pl/programmable/sdk/api/components/bindable-value.md b/packages/docs/pl/programmable/sdk/api/components/bindable-value.md new file mode 100644 index 000000000..d5a57a497 --- /dev/null +++ b/packages/docs/pl/programmable/sdk/api/components/bindable-value.md @@ -0,0 +1,10 @@ +--- +title: BindableValue +description: Provider-driven value binding primitives for custom editor controls. +--- + +# BindableValue + +The generated BindableValue API reference and interactive demo currently share one canonical source. + +[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value) diff --git a/packages/docs/programmable/sdk/api/components/bindable-value.data.ts b/packages/docs/programmable/sdk/api/components/bindable-value.data.ts new file mode 100644 index 000000000..5e9a5de55 --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/bindable-value.data.ts @@ -0,0 +1,23 @@ +import type { Loader } from 'vitepress' + +import { + readComponentMeta, + type SdkComponentMeta +} from '../../../../.vitepress/sdk/component-meta' + +const sources = [ + 'packages/vue/src/primitives/BindableValue/BindableValueRoot.vue', + 'packages/vue/src/primitives/BindableValue/BindableValueTrigger.vue', + 'packages/vue/src/primitives/BindableValue/BindableValuePicker.vue' +] + +export interface BindableValueComponentData { + components: SdkComponentMeta[] +} + +export default { + watch: sources.map((source) => `../../../../../../${source}`), + load(): BindableValueComponentData { + return { components: sources.map(readComponentMeta) } + } +} satisfies Loader diff --git a/packages/docs/programmable/sdk/api/components/bindable-value.md b/packages/docs/programmable/sdk/api/components/bindable-value.md new file mode 100644 index 000000000..a944ca6ad --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/bindable-value.md @@ -0,0 +1,61 @@ +--- +title: BindableValue +description: Provider-driven value binding primitives for custom editor controls. +--- + + + +# BindableValue + +BindableValue composes variable or token binding with fields without coupling the field to a +specific editor store. Applications supply a `BindingProvider`; NumberField consumes the context +automatically when nested beneath `BindableValueRoot`. + + + +## Anatomy + +- `BindableValueRoot` — binding state, policy, resolved value, picker state, and actions +- `BindableValueTrigger` — polymorphic bind-picker trigger +- `BindableValuePicker` — renderless Reka Combobox composition + +## Policies + +- `detach-on-edit` unbinds targets and keeps the complete interaction in one provider undo batch. +- `readonly-when-bound` blocks field editing, scrubbing, and keyboard stepping. +- `edit-variable` sends changes to `provider.setValue()` instead of changing the target value. + +Cancellation rolls back an open provider batch. Providers without undo support still receive +binding changes, with binding snapshots restored where possible. + +## Provider example + +```ts twoslash +import type { BindingProvider, BindingTarget } from '@open-pencil/vue' + +const values = new Map([['spacing/md', 16]]) +const bindings = new Map() + +const provider: BindingProvider = { + listVariables: () => [], + filterVariables: () => [], + getBound: () => undefined, + getState: () => 'unbound', + resolve: id => values.get(id), + bind: (target: BindingTarget, variableId) => { + bindings.set(`${target.nodeId}:${target.path}`, variableId) + }, + unbind: (target: BindingTarget) => { + bindings.delete(`${target.nodeId}:${target.path}`) + } +} +``` + +## Generated API reference + +The following tables are extracted from the Vue source and JSDoc during the documentation build. + + diff --git a/packages/docs/programmable/sdk/api/components/index.md b/packages/docs/programmable/sdk/api/components/index.md index e95492bdc..1cb81cd42 100644 --- a/packages/docs/programmable/sdk/api/components/index.md +++ b/packages/docs/programmable/sdk/api/components/index.md @@ -41,4 +41,5 @@ description: Component reference for headless Vue primitives in @open-pencil/vue + diff --git a/packages/docs/ru/programmable/sdk/api/components/bindable-value.md b/packages/docs/ru/programmable/sdk/api/components/bindable-value.md new file mode 100644 index 000000000..d5a57a497 --- /dev/null +++ b/packages/docs/ru/programmable/sdk/api/components/bindable-value.md @@ -0,0 +1,10 @@ +--- +title: BindableValue +description: Provider-driven value binding primitives for custom editor controls. +--- + +# BindableValue + +The generated BindableValue API reference and interactive demo currently share one canonical source. + +[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value) diff --git a/packages/vue/README.md b/packages/vue/README.md index 5b310033e..88bc9ebc1 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -87,10 +87,12 @@ Main structural primitives include: - `FillPickerRoot` - `FontPickerRoot` - `NumberFieldRoot` / `NumberFieldInput` / `NumberFieldValue` +- `BindableValueRoot` / `BindableValueTrigger` / `BindableValuePicker` These components coordinate structure and state, but do not impose app styling. `NumberField` adds pointer scrubbing, Arrow-key stepping, mixed/bound state attributes, and safe arithmetic -expressions such as `+10`, `*2`, `50%`, and `12*8+4`. +expressions such as `+10`, `*2`, `50%`, and `12*8+4`. `BindableValue` composes fields with a +generic `BindingProvider` and supports detach-on-edit, read-only, and edit-variable policies. ## Public API tiers @@ -145,6 +147,9 @@ These are the main APIs most SDK consumers should start with. - `NumberFieldUnit` - `NumberFieldTrailing` - `NumberFieldMenu` +- `BindableValueRoot` +- `BindableValueTrigger` +- `BindableValuePicker` ### Advanced API @@ -153,6 +158,9 @@ These exports are intentionally public, but they are lower-level or more special - `useNodeProps()` - `useSceneComputed()` - `useColorVariableBinding()` +- `provideBindingProvider()` +- `useBindingProvider()` +- `useNumberBindingProvider()` - `useFillPicker()` - `useGradientStops()` - `useFontPicker()` diff --git a/packages/vue/src/controls/binding-provider/context.ts b/packages/vue/src/controls/binding-provider/context.ts new file mode 100644 index 000000000..29f49acb1 --- /dev/null +++ b/packages/vue/src/controls/binding-provider/context.ts @@ -0,0 +1,16 @@ +import { inject, provide } from 'vue' +import type { InjectionKey } from 'vue' + +import type { BindingProvider } from '#vue/controls/binding-provider/types' + +export const BINDING_PROVIDER_KEY: InjectionKey = Symbol( + 'open-pencil-binding-provider' +) + +export function provideBindingProvider(provider: BindingProvider) { + provide(BINDING_PROVIDER_KEY, provider) +} + +export function useBindingProvider(): BindingProvider | undefined { + return inject(BINDING_PROVIDER_KEY, undefined) as BindingProvider | undefined +} diff --git a/packages/vue/src/controls/binding-provider/index.ts b/packages/vue/src/controls/binding-provider/index.ts new file mode 100644 index 000000000..e29a8a704 --- /dev/null +++ b/packages/vue/src/controls/binding-provider/index.ts @@ -0,0 +1,20 @@ +export { + BINDING_PROVIDER_KEY, + provideBindingProvider, + useBindingProvider +} from '#vue/controls/binding-provider/context' +export { + useOpenPencilBindingProvider, + type OpenPencilBindingProviderOptions +} from '#vue/controls/binding-provider/open-pencil' +export { + createAndBindNumberVariable, + useNumberBindingProvider +} from '#vue/controls/binding-provider/number' +export type { + BindingMutationSource, + BindingProvider, + BindingState, + BindingTarget, + BoundEditPolicy +} from '#vue/controls/binding-provider/types' diff --git a/packages/vue/src/controls/binding-provider/number.ts b/packages/vue/src/controls/binding-provider/number.ts new file mode 100644 index 000000000..7fc8e4f5d --- /dev/null +++ b/packages/vue/src/controls/binding-provider/number.ts @@ -0,0 +1,64 @@ +import type { Editor } from '@open-pencil/core/editor' +import { randomHex } from '@open-pencil/core/random' +import type { VariableCollection } from '@open-pencil/scene-graph' + +import { useOpenPencilBindingProvider } from '#vue/controls/binding-provider/open-pencil' +import type { BindingTarget } from '#vue/controls/binding-provider/types' + +const FALLBACK_NUMBER_VARIABLE_NAME = 'New number' + +function numberCollection(editor: Editor): VariableCollection { + const existing = editor + .getCollections() + .find((collection) => + collection.variableIds.some((variableId) => editor.getVariable(variableId)?.type === 'FLOAT') + ) + if (existing) return existing + + const collection: VariableCollection = { + id: `col:${randomHex(8)}`, + name: 'Numbers', + modes: [{ modeId: 'default', name: 'Mode 1' }], + defaultModeId: 'default', + variableIds: [] + } + editor.addCollection(collection) + return collection +} + +export function createAndBindNumberVariable( + editor: Editor, + target: BindingTarget, + value: number, + name = FALLBACK_NUMBER_VARIABLE_NAME +) { + const collection = numberCollection(editor) + const id = `var:${randomHex(8)}` + editor.addVariable({ + id, + name: name.trim() || FALLBACK_NUMBER_VARIABLE_NAME, + type: 'FLOAT', + collectionId: collection.id, + valuesByMode: Object.fromEntries(collection.modes.map((mode) => [mode.modeId, value])), + description: '', + hiddenFromPublishing: false + }) + editor.bindVariable(target.nodeId, target.path, id) +} + +function setNumberVariableValue(editor: Editor, variableId: string, value: number) { + 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, value) +} + +export function useNumberBindingProvider() { + return useOpenPencilBindingProvider({ + type: 'FLOAT', + resolve: (editor, variableId) => editor.resolveNumberVariable(variableId), + create: createAndBindNumberVariable, + setValue: setNumberVariableValue + }) +} diff --git a/packages/vue/src/controls/binding-provider/open-pencil.ts b/packages/vue/src/controls/binding-provider/open-pencil.ts new file mode 100644 index 000000000..0644074a8 --- /dev/null +++ b/packages/vue/src/controls/binding-provider/open-pencil.ts @@ -0,0 +1,75 @@ +import { useFilter } from 'reka-ui' + +import type { Editor } from '@open-pencil/core/editor' +import type { Variable, VariableType } from '@open-pencil/scene-graph' + +import type { + BindingProvider, + BindingState, + BindingTarget +} from '#vue/controls/binding-provider/types' +import { useEditor } from '#vue/editor/context' +import { useSceneComputed } from '#vue/internal/scene-computed/use' + +export interface OpenPencilBindingProviderOptions { + type: VariableType + resolve(editor: Editor, variableId: string): V | undefined + create?(editor: Editor, target: BindingTarget, value: V, name: string): void + setValue?(editor: Editor, variableId: string, value: V): void +} + +export function useOpenPencilBindingProvider( + options: OpenPencilBindingProviderOptions +): BindingProvider { + const editor = useEditor() + const revision = useSceneComputed(() => editor.state.sceneVersion) + const variables = useSceneComputed(() => editor.getVariablesByType(options.type)) + const { contains } = useFilter({ sensitivity: 'base' }) + + function listVariables(): Variable[] { + return variables.value + } + + function filterVariables(term: string): Variable[] { + if (!term) return variables.value + return variables.value.filter((variable) => contains(variable.name, term)) + } + + function getBound(target: BindingTarget): Variable | undefined { + void revision.value + const variableId = editor.getNode(target.nodeId)?.boundVariables[target.path] + return variableId ? editor.getVariable(variableId) : undefined + } + + function getState(targets: BindingTarget[]): BindingState { + if (targets.length === 0) return 'unbound' + const variableIds = new Set( + targets.map( + (target) => editor.getNode(target.nodeId)?.boundVariables[target.path] ?? undefined + ) + ) + if (variableIds.size > 1) return 'mixed' + return variableIds.has(undefined) ? 'unbound' : 'bound' + } + + return { + revision, + listVariables, + filterVariables, + getBound, + getState, + resolve: (variableId) => options.resolve(editor, variableId), + bind: (target, variableId) => editor.bindVariable(target.nodeId, target.path, variableId), + unbind: (target) => editor.unbindVariable(target.nodeId, target.path), + create: options.create + ? (target, value, name) => options.create?.(editor, target, value, name) + : undefined, + setValue: options.setValue + ? (variableId, value) => options.setValue?.(editor, variableId, value) + : undefined, + runBatch: (label, action) => editor.undo.runBatch(label, action), + beginBatch: (label) => editor.undo.beginBatch(label), + commitBatch: () => editor.undo.commitBatch(), + rollbackBatch: () => editor.undo.rollbackBatch() + } +} diff --git a/packages/vue/src/controls/binding-provider/types.ts b/packages/vue/src/controls/binding-provider/types.ts new file mode 100644 index 000000000..66fa26bda --- /dev/null +++ b/packages/vue/src/controls/binding-provider/types.ts @@ -0,0 +1,30 @@ +import type { Ref } from 'vue' + +import type { Variable } from '@open-pencil/scene-graph' + +export type BindingState = 'unbound' | 'bound' | 'mixed' +export type BoundEditPolicy = 'detach-on-edit' | 'readonly-when-bound' | 'edit-variable' +export type BindingMutationSource = 'edit' | 'scrub' | 'step' + +export interface BindingTarget { + nodeId: string + path: string +} + +export interface BindingProvider { + /** Optional reactive revision consumed by BindableValueRoot. */ + revision?: Readonly> + listVariables(): Variable[] + filterVariables(term: string): Variable[] + getBound(target: BindingTarget): Variable | undefined + getState(targets: BindingTarget[]): BindingState + resolve(variableId: string): V | undefined + bind(target: BindingTarget, variableId: string): void + unbind(target: BindingTarget): void + create?(target: BindingTarget, value: V, name: string): void + setValue?(variableId: string, value: V): void + runBatch?(label: string, action: () => T): T + beginBatch?(label: string): void + commitBatch?(): void + rollbackBatch?(): void +} diff --git a/packages/vue/src/controls/number-variable-binding/use.ts b/packages/vue/src/controls/number-variable-binding/use.ts index 2abc6ed5c..25b6a6dc1 100644 --- a/packages/vue/src/controls/number-variable-binding/use.ts +++ b/packages/vue/src/controls/number-variable-binding/use.ts @@ -1,10 +1,6 @@ -import { randomHex } from '@open-pencil/core/random' -import type { VariableCollection } from '@open-pencil/scene-graph' - +import { createAndBindNumberVariable } from '#vue/controls/binding-provider/number' import { useVariableBinding } from '#vue/controls/variable-binding/use' -const FALLBACK_NUMBER_VARIABLE_NAME = 'New number' - export type NumberBindingPath = | 'width' | 'height' @@ -37,44 +33,8 @@ export function useNumberVariableBinding(path: NumberBindingPath) { path }) - function numberCollection(): VariableCollection { - const existing = binding.store - .getCollections() - .find((collection) => - collection.variableIds.some( - (variableId) => binding.store.getVariable(variableId)?.type === 'FLOAT' - ) - ) - if (existing) return existing - - const collection: VariableCollection = { - id: `col:${randomHex(8)}`, - name: 'Numbers', - modes: [{ modeId: 'default', name: 'Mode 1' }], - defaultModeId: 'default', - variableIds: [] - } - binding.store.addCollection(collection) - return collection - } - - function createAndBindVariable( - nodeId: string, - value: number, - name = FALLBACK_NUMBER_VARIABLE_NAME - ) { - const collection = numberCollection() - const id = `var:${randomHex(8)}` - binding.store.addVariable({ - id, - name: name.trim() || FALLBACK_NUMBER_VARIABLE_NAME, - type: 'FLOAT', - collectionId: collection.id, - valuesByMode: Object.fromEntries(collection.modes.map((mode) => [mode.modeId, value])), - description: '', - hiddenFromPublishing: false - }) - binding.bindVariable(nodeId, id) + function createAndBindVariable(nodeId: string, value: number, name?: string) { + createAndBindNumberVariable(binding.store, { nodeId, path: binding.bindingPath() }, value, name) } return { diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index c33b8605b..1f21939da 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -143,6 +143,36 @@ export { PageListRoot } from '#vue/primitives/PageList' export { PositionControlsRoot } from '#vue/primitives/PositionControls' export { PropertyListRoot, PropertyListItem, usePropertyList } from '#vue/primitives/PropertyList' export type { PropertyListContext } from '#vue/primitives/PropertyList' +export { + BindableValueRoot, + BindableValueTrigger, + BindableValuePicker, + useBindableValue, + useOptionalBindableValue +} from '#vue/primitives/BindableValue' +export type { + BindableValueActions, + BindableValueContext, + BindableValueRootProps, + BindableValueRootSlots, + BindableValueSlotProps, + BindableValueStateAttrs, + BindableValueTriggerProps +} from '#vue/primitives/BindableValue' +export { + provideBindingProvider, + useBindingProvider, + useOpenPencilBindingProvider, + useNumberBindingProvider +} from '#vue/controls/binding-provider' +export type { + BindingMutationSource, + BindingProvider, + BindingState, + BindingTarget, + BoundEditPolicy, + OpenPencilBindingProviderOptions +} from '#vue/controls/binding-provider' export { NumberFieldRoot, NumberFieldInput, diff --git a/packages/vue/src/primitives/BindableValue/BindableValue.stories.ts b/packages/vue/src/primitives/BindableValue/BindableValue.stories.ts new file mode 100644 index 000000000..1ef200215 --- /dev/null +++ b/packages/vue/src/primitives/BindableValue/BindableValue.stories.ts @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { expect, userEvent, within } from 'storybook/test' + +import BindableValueDemo from './demo/BindableValueDemo.vue' + +const meta = { + title: 'Vue SDK/Primitives/BindableValue', + component: BindableValueDemo, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'Provider-driven binding state, policies, picker composition, and NumberField integration.' + } + } + } +} satisfies Meta + +export default meta +type Story = StoryObj + +export const StateMatrix: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const detach = canvas.getByLabelText('Detach bound value') + const readonly = canvas.getByLabelText('Readonly bound value') + + await expect(detach).toHaveAttribute('data-bound') + await expect(readonly).toHaveAttribute('data-bound') + await expect(canvas.getByLabelText('Mixed binding value')).toHaveAttribute('data-mixed') + + await userEvent.click(detach) + const input = canvas.getByRole('spinbutton', { name: 'Detach bound value' }) + await userEvent.clear(input) + await userEvent.type(input, '32{Enter}') + await expect(detach).toHaveAttribute('data-unbound') + await expect(detach).toHaveAttribute('aria-valuenow', '32') + + const editVariable = canvas.getByLabelText('Edit bound variable') + await userEvent.click(editVariable) + const variableInput = canvas.getByRole('spinbutton', { name: 'Edit bound variable' }) + await userEvent.clear(variableInput) + await userEvent.type(variableInput, '40{Enter}') + await expect(editVariable).toHaveAttribute('data-bound') + await expect(editVariable).toHaveAttribute('aria-valuenow', '40') + + await userEvent.click(readonly) + await expect(canvasElement.querySelectorAll('input')).toHaveLength(0) + + await userEvent.click(canvas.getByRole('button', { name: 'Choose binding' })) + await expect(canvas.getByRole('button', { name: 'Space/md' })).toBeVisible() + } +} diff --git a/packages/vue/src/primitives/BindableValue/BindableValuePicker.vue b/packages/vue/src/primitives/BindableValue/BindableValuePicker.vue new file mode 100644 index 000000000..7e38e701d --- /dev/null +++ b/packages/vue/src/primitives/BindableValue/BindableValuePicker.vue @@ -0,0 +1,25 @@ + + + + (open ? ctx.actions.openPicker() : ctx.actions.closePicker())" + > + + + diff --git a/packages/vue/src/primitives/BindableValue/BindableValueRoot.vue b/packages/vue/src/primitives/BindableValue/BindableValueRoot.vue new file mode 100644 index 000000000..3589e088d --- /dev/null +++ b/packages/vue/src/primitives/BindableValue/BindableValueRoot.vue @@ -0,0 +1,245 @@ + + + + + diff --git a/packages/vue/src/primitives/BindableValue/BindableValueTrigger.vue b/packages/vue/src/primitives/BindableValue/BindableValueTrigger.vue new file mode 100644 index 000000000..039e861c3 --- /dev/null +++ b/packages/vue/src/primitives/BindableValue/BindableValueTrigger.vue @@ -0,0 +1,29 @@ + + + + + + + diff --git a/packages/vue/src/primitives/BindableValue/context.ts b/packages/vue/src/primitives/BindableValue/context.ts new file mode 100644 index 000000000..54fbbd218 --- /dev/null +++ b/packages/vue/src/primitives/BindableValue/context.ts @@ -0,0 +1,21 @@ +import { inject, provide } from 'vue' +import type { InjectionKey } from 'vue' + +import type { BindableValueContext } from '#vue/primitives/BindableValue/types' + +export const BINDABLE_VALUE_KEY: InjectionKey = Symbol('BindableValue') + +export function provideBindableValue(context: BindableValueContext) { + provide(BINDABLE_VALUE_KEY, context as BindableValueContext) +} + +export function useBindableValue(): BindableValueContext { + const context = inject(BINDABLE_VALUE_KEY) + if (!context) + throw new Error('[open-pencil] BindableValue part must be used inside BindableValueRoot') + return context as BindableValueContext +} + +export function useOptionalBindableValue(): BindableValueContext | undefined { + return inject(BINDABLE_VALUE_KEY) as BindableValueContext | undefined +} diff --git a/packages/vue/src/primitives/BindableValue/demo/BindableValueDemo.vue b/packages/vue/src/primitives/BindableValue/demo/BindableValueDemo.vue new file mode 100644 index 000000000..f920ffb00 --- /dev/null +++ b/packages/vue/src/primitives/BindableValue/demo/BindableValueDemo.vue @@ -0,0 +1,242 @@ + + + + + + + Detach on edit + + + + + + + + + + + + Read-only bound + + + + + + + + + + + + Edit variable + + + + + + + + + + + + Mixed bindings + + + {{ state }} + + + + + + + + + Choose variable + + + + + {{ option.name }} + + + + + + + diff --git a/packages/vue/src/primitives/BindableValue/index.ts b/packages/vue/src/primitives/BindableValue/index.ts new file mode 100644 index 000000000..19ec036b3 --- /dev/null +++ b/packages/vue/src/primitives/BindableValue/index.ts @@ -0,0 +1,18 @@ +export { default as BindableValueRoot } from '#vue/primitives/BindableValue/BindableValueRoot.vue' +export { default as BindableValueTrigger } from '#vue/primitives/BindableValue/BindableValueTrigger.vue' +export { default as BindableValuePicker } from '#vue/primitives/BindableValue/BindableValuePicker.vue' +export { + BINDABLE_VALUE_KEY, + provideBindableValue, + useBindableValue, + useOptionalBindableValue +} from '#vue/primitives/BindableValue/context' +export type { + BindableValueActions, + BindableValueContext, + BindableValueRootProps, + BindableValueRootSlots, + BindableValueSlotProps, + BindableValueStateAttrs, + BindableValueTriggerProps +} from '#vue/primitives/BindableValue/types' diff --git a/packages/vue/src/primitives/BindableValue/types.ts b/packages/vue/src/primitives/BindableValue/types.ts new file mode 100644 index 000000000..6f15c3190 --- /dev/null +++ b/packages/vue/src/primitives/BindableValue/types.ts @@ -0,0 +1,86 @@ +import type { Component, ComputedRef, Ref, VNode } from 'vue' + +import type { Variable } from '@open-pencil/scene-graph' + +import type { + BindingMutationSource, + BindingProvider, + BindingState, + BindingTarget, + BoundEditPolicy +} from '#vue/controls/binding-provider/types' + +export interface BindableValueTriggerProps { + /** Element or component rendered by the trigger. @default 'button' */ + as?: string | Component + /** Merge trigger behavior into the single child element. @default false */ + asChild?: boolean +} + +export interface BindableValueRootProps { + /** Binding implementation. Falls back to the nearest injected provider. */ + provider?: BindingProvider + /** Node/property pairs participating in this binding. */ + targets: BindingTarget[] + /** Direct field value used when the targets are not consistently bound. */ + value: V + /** Behavior when a consistently bound field is edited. @default 'detach-on-edit' */ + policy?: BoundEditPolicy + /** Undo label for a field interaction transaction. @default 'Edit bound value' */ + batchLabel?: string +} + +export interface BindableValueStateAttrs { + 'data-unbound'?: '' + 'data-bound'?: '' + 'data-mixed'?: '' + 'data-picker-open'?: '' + 'data-policy': BoundEditPolicy +} + +export interface BindableValueActions { + bind(variableId: string): void + unbind(): void + create(name: string): void + openPicker(): void + closePicker(): void + togglePicker(): void + setSearchTerm(term: string): void + beginMutation(source: BindingMutationSource): boolean + applyValue(value: V): boolean + commitMutation(): void + cancelMutation(): void +} + +export interface BindableValueSlotProps { + state: BindingState + variable: Variable | undefined + resolvedValue: V | undefined + policy: BoundEditPolicy + open: boolean + searchTerm: string + variables: Variable[] + stateAttrs: BindableValueStateAttrs + actions: BindableValueActions +} + +export interface BindableValueRootSlots { + /** Complete render contract for binding-aware controls. */ + default(props: BindableValueSlotProps): VNode[] +} + +export interface BindableValueContext { + provider: BindingProvider + targets: ComputedRef + value: ComputedRef + state: ComputedRef + variable: ComputedRef + resolvedValue: ComputedRef + policy: ComputedRef + open: Ref + searchTerm: Ref + variables: ComputedRef + stateAttrs: ComputedRef + slotProps: ComputedRef> + actions: BindableValueActions +} diff --git a/packages/vue/src/primitives/NumberField/NumberFieldInput.vue b/packages/vue/src/primitives/NumberField/NumberFieldInput.vue index 52960cbec..c4219f99e 100644 --- a/packages/vue/src/primitives/NumberField/NumberFieldInput.vue +++ b/packages/vue/src/primitives/NumberField/NumberFieldInput.vue @@ -13,7 +13,7 @@ const ariaAttrs = computed(() => ({ 'aria-valuemin': Number.isFinite(ctx.min.value) ? ctx.min.value : undefined, 'aria-valuemax': Number.isFinite(ctx.max.value) ? ctx.max.value : undefined, 'aria-disabled': ctx.disabled.value ? ('true' as const) : undefined, - 'aria-label': ctx.rootAttrs.value['aria-label'] + 'aria-label': ctx.ariaLabel.value })) watchEffect(() => { diff --git a/packages/vue/src/primitives/NumberField/NumberFieldRoot.vue b/packages/vue/src/primitives/NumberField/NumberFieldRoot.vue index 70a8d2bf8..ee8902ab7 100644 --- a/packages/vue/src/primitives/NumberField/NumberFieldRoot.vue +++ b/packages/vue/src/primitives/NumberField/NumberFieldRoot.vue @@ -9,9 +9,11 @@ import { stepNumberValue } from '#vue/controls/number-expression' import type { NumberExpressionError } from '#vue/controls/number-expression' +import { useOptionalBindableValue } from '#vue/primitives/BindableValue/context' import { provideNumberField } from '#vue/primitives/NumberField/context' import type { NumberFieldActions, + NumberFieldEditPolicy, NumberFieldMutationSource, NumberFieldRootAttrs, NumberFieldRootEmits, @@ -38,6 +40,7 @@ const { const emit = defineEmits() defineSlots() +const binding = useOptionalBindableValue() const editing = ref(false) const scrubbing = ref(false) const draftValue = ref('') @@ -45,16 +48,27 @@ const inputRef = ref(null) const invalidReason = ref(null) const workingValue = ref(0) -const isMixed = computed(() => typeof modelValue === 'symbol') -const numericValue = computed(() => (typeof modelValue === 'number' ? modelValue : 0)) +const isMixed = computed(() => binding?.state.value === 'mixed' || typeof modelValue === 'symbol') +const numericValue = computed(() => { + const resolved = binding?.resolvedValue.value + if (binding?.state.value === 'bound' && typeof resolved === 'number') return resolved + return typeof modelValue === 'number' ? modelValue : 0 +}) const displayValue = computed(() => isMixed.value ? '' : String(normalizeNumberValue(numericValue.value)) ) const disabled = computed(() => disabledProp) -const bound = computed(() => boundProp) +const bound = computed(() => (binding ? binding.state.value === 'bound' : boundProp)) +const effectiveEditPolicy = computed(() => { + if (!binding) return editPolicy + if (binding.policy.value === 'readonly-when-bound') return 'readonly' + if (binding.policy.value === 'detach-on-edit') return 'detach-on-edit' + return 'editable' +}) const minValue = computed(() => min) const maxValue = computed(() => max) const stepValue = computed(() => (Number.isFinite(step) && step > 0 ? step : 1)) +const ariaLabelValue = computed(() => ariaLabel) let interactionStartValue = 0 let interactionStartedMixed = false @@ -66,12 +80,18 @@ let scrubTarget: Element | undefined let scrubPointerId: number | undefined function canMutate(): boolean { - return !disabled.value && !(bound.value && editPolicy === 'readonly') + return !disabled.value && !(bound.value && effectiveEditPolicy.value === 'readonly') } function requestMutation(source: NumberFieldMutationSource): boolean { if (!canMutate()) return false - if (bound.value && editPolicy === 'detach-on-edit' && !detachRequested) { + if (binding && !binding.actions.beginMutation(source)) return false + if ( + !binding && + bound.value && + effectiveEditPolicy.value === 'detach-on-edit' && + !detachRequested + ) { detachRequested = true emit('detach-request', source) } @@ -89,13 +109,16 @@ function beginInteraction() { function updateValue(value: number) { const normalized = normalizeNumberValue(clampNumberValue(value, min, max)) workingValue.value = normalized + if (binding?.actions.applyValue(normalized)) return if (modelValue !== normalized) emit('update:modelValue', normalized) } function restoreInteractionValue() { if (workingValue.value !== interactionStartValue || interactionStartedMixed !== isMixed.value) { workingValue.value = interactionStartValue - emit('update:modelValue', interactionStartValue) + if (!binding?.actions.applyValue(interactionStartValue)) { + emit('update:modelValue', interactionStartValue) + } } } @@ -105,13 +128,14 @@ function finishCommit(value: number) { if (workingValue.value !== interactionStartValue) { emit('commit', workingValue.value, interactionStartValue) } + binding?.actions.commitMutation() } function startEdit() { if (editing.value || !canMutate()) return beginInteraction() - requestMutation('edit') - draftValue.value = isMixed.value ? '' : displayValue.value + if (!requestMutation('edit')) return + draftValue.value = interactionStartedMixed ? '' : String(interactionStartValue) editing.value = true void nextTick(() => { inputRef.value?.focus() @@ -141,6 +165,7 @@ function commitEdit() { invalidReason.value = result.error restoreInteractionValue() editing.value = false + binding?.actions.cancelMutation() emit('invalid', expression, result.error) return } @@ -152,6 +177,7 @@ function cancelEdit() { restoreInteractionValue() invalidReason.value = null editing.value = false + binding?.actions.cancelMutation() } function stopScrubListeners() { @@ -204,6 +230,7 @@ function startScrub(event: PointerEvent) { scrubbing.value = false if (cancelled) { restoreInteractionValue() + binding?.actions.cancelMutation() return } if (!hasMoved) { @@ -213,6 +240,7 @@ function startScrub(event: PointerEvent) { if (workingValue.value !== interactionStartValue) { emit('commit', workingValue.value, interactionStartValue) } + binding?.actions.commitMutation() } stopUp = useEventListener(listenerTarget, 'pointerup', (upEvent: PointerEvent) => { @@ -225,10 +253,10 @@ function startScrub(event: PointerEvent) { function stepValueFromKeyboard(event: KeyboardEvent) { if (event.code !== 'ArrowUp' && event.code !== 'ArrowDown') return false + if (!editing.value) beginInteraction() if (!requestMutation('step')) return true event.preventDefault() - if (!editing.value) beginInteraction() const draftResult = editing.value ? evaluateNumberExpression(draftValue.value, { current: interactionStartValue, @@ -248,7 +276,10 @@ function stepValueFromKeyboard(event: KeyboardEvent) { updateValue(next) draftValue.value = String(next) - if (!editing.value && next !== interactionStartValue) emit('commit', next, interactionStartValue) + if (!editing.value) { + if (next !== interactionStartValue) emit('commit', next, interactionStartValue) + binding?.actions.commitMutation() + } return true } @@ -332,6 +363,7 @@ provideNumberField({ min: minValue, max: maxValue, step: stepValue, + ariaLabel: ariaLabelValue, inputRef, state, stateAttrs, diff --git a/packages/vue/src/primitives/NumberField/types.ts b/packages/vue/src/primitives/NumberField/types.ts index a7cbc9518..e42a46cf1 100644 --- a/packages/vue/src/primitives/NumberField/types.ts +++ b/packages/vue/src/primitives/NumberField/types.ts @@ -113,6 +113,7 @@ export interface NumberFieldContext { min: ComputedRef max: ComputedRef step: ComputedRef + ariaLabel: ComputedRef inputRef: Ref state: ComputedRef stateAttrs: ComputedRef diff --git a/src/components/properties/BoundVariableButton.vue b/src/components/properties/BoundVariableButton.vue index 7ac593480..57035a199 100644 --- a/src/components/properties/BoundVariableButton.vue +++ b/src/components/properties/BoundVariableButton.vue @@ -18,6 +18,7 @@ const emit = defineEmits<{ diff --git a/src/components/properties/VariableNumberField.vue b/src/components/properties/VariableNumberField.vue index cc9f09054..074bc2156 100644 --- a/src/components/properties/VariableNumberField.vue +++ b/src/components/properties/VariableNumberField.vue @@ -1,10 +1,12 @@ - emit('commit', v, p)" + - - - - - - - - - - - + emit('commit', value, previous)" + > + + + + + + + + + + + + diff --git a/tests/e2e/properties/number-field.spec.ts b/tests/e2e/properties/number-field.spec.ts index 9a4079b9e..3a0a5b13a 100644 --- a/tests/e2e/properties/number-field.spec.ts +++ b/tests/e2e/properties/number-field.spec.ts @@ -29,6 +29,7 @@ test('NumberField commits arithmetic and relative expressions', async () => { let input = await editField(field) await expect(field).not.toHaveAttribute('role') await expect(input).toHaveAttribute('role', 'spinbutton') + await expect(input).toHaveAttribute('aria-label', 'X') await expect(field.getByRole('spinbutton')).toHaveCount(1) await input.fill('*2') await input.press('Enter') diff --git a/tests/e2e/properties/panel.spec.ts b/tests/e2e/properties/panel.spec.ts index 5419483a3..7c0e9c828 100644 --- a/tests/e2e/properties/panel.spec.ts +++ b/tests/e2e/properties/panel.spec.ts @@ -202,6 +202,51 @@ test('width can create, bind, and detach a number variable', async () => { editor.canvas.assertNoErrors() }) +test('bound NumberField detach edit is one undo step', async () => { + await editor.canvas.clearCanvas() + await editor.canvas.drawRect(200, 200, 80, 80) + + const field = editor.page.getByTestId('corner-radius-input') + await field.getByLabel('Apply variable').click() + await editor.page.getByText('Create number variable from 0').click() + await editor.page.getByPlaceholder('Variable name').fill('Radius/default') + await editor.page.getByRole('button', { name: 'Create', exact: true }).click() + await editor.canvas.waitForRender() + await expect(field.getByLabel('Detach variable')).toBeVisible() + + const readState = () => + editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + const id = [...store.state.selectedIds][0] + const node = id ? store.getNode(id) : null + const variableId = node?.boundVariables.cornerRadius + return node + ? { + radius: node.cornerRadius, + binding: variableId ? store.getVariable(variableId)?.name : null + } + : null + }) + + await field.click({ position: { x: 40, y: 13 } }) + const input = field.getByTestId('number-field-input') + await input.fill('12') + await input.press('Escape') + await editor.canvas.waitForRender() + expect(await readState()).toEqual({ radius: 0, binding: 'Radius/default' }) + + await field.click({ position: { x: 40, y: 13 } }) + await input.fill('24') + await input.press('Enter') + await editor.canvas.waitForRender() + expect(await readState()).toEqual({ radius: 24, binding: null }) + await editor.canvas.pressKey('Meta+z') + await editor.canvas.waitForRender() + expect(await readState()).toEqual({ radius: 0, binding: 'Radius/default' }) + editor.canvas.assertNoErrors() +}) + test('alignment buttons align nodes to same X', async () => { await editor.canvas.clearCanvas() await editor.canvas.drawRect(50, 200, 60, 60)
Detach on edit
Read-only bound
Edit variable
Mixed bindings