feat(vue): add BindableValue primitives

- Add provider-driven binding state, picker composition, and edit policies

- Integrate NumberField interactions with binding transactions and cancellation

- Migrate numeric variable fields and document the public API
This commit is contained in:
Danila Poyarkov 2026-07-13 11:02:54 +03:00
parent 5d9730ae04
commit ffb12824eb
35 changed files with 1266 additions and 126 deletions

View file

@ -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)

View file

@ -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.

View file

@ -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' },

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -0,0 +1,61 @@
---
title: BindableValue
description: Provider-driven value binding primitives for custom editor controls.
---
<script setup lang="ts">
import BindableValueDemo from '../../../../../vue/src/primitives/BindableValue/demo/BindableValueDemo.vue'
import { data } from './bindable-value.data'
</script>
# 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`.
<BindableValueDemo />
## 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<string, number>([['spacing/md', 16]])
const bindings = new Map<string, string>()
const provider: BindingProvider<number> = {
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.
<SdkComponentAPI :components="data.components" />

View file

@ -41,4 +41,5 @@ description: Component reference for headless Vue primitives in @open-pencil/vue
<SdkCard title="GradientEditorBar" to="/programmable/sdk/api/components/gradient-editor-bar" description="Draggable gradient bar primitive." />
<SdkCard title="GradientEditorStop" to="/programmable/sdk/api/components/gradient-editor-stop" description="Single gradient stop primitive." />
<SdkCard title="NumberField" to="/programmable/sdk/api/components/number-field" description="Numeric field anatomy with scrubbing, expressions, and keyboard stepping." />
<SdkCard title="BindableValue" to="/programmable/sdk/api/components/bindable-value" description="Provider-driven variable and token binding composition." />
</SdkCardGroup>

View file

@ -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)

View file

@ -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()`

View file

@ -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<BindingProvider> = Symbol(
'open-pencil-binding-provider'
)
export function provideBindingProvider(provider: BindingProvider) {
provide(BINDING_PROVIDER_KEY, provider)
}
export function useBindingProvider<V>(): BindingProvider<V> | undefined {
return inject(BINDING_PROVIDER_KEY, undefined) as BindingProvider<V> | undefined
}

View file

@ -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'

View file

@ -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<number>({
type: 'FLOAT',
resolve: (editor, variableId) => editor.resolveNumberVariable(variableId),
create: createAndBindNumberVariable,
setValue: setNumberVariableValue
})
}

View file

@ -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<V> {
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<V>(
options: OpenPencilBindingProviderOptions<V>
): BindingProvider<V> {
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()
}
}

View file

@ -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<V = unknown> {
/** Optional reactive revision consumed by BindableValueRoot. */
revision?: Readonly<Ref<unknown>>
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?<T>(label: string, action: () => T): T
beginBatch?(label: string): void
commitBatch?(): void
rollbackBatch?(): void
}

View file

@ -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 {

View file

@ -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,

View file

@ -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<typeof BindableValueDemo>
export default meta
type Story = StoryObj<typeof meta>
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()
}
}

View file

@ -0,0 +1,25 @@
<script setup lang="ts">
import { ComboboxRoot } from 'reka-ui'
import { useBindableValue } from '#vue/primitives/BindableValue/context'
const ctx = useBindableValue()
function select(value: unknown) {
if (typeof value !== 'object' || value === null || !('id' in value)) return
if (typeof value.id !== 'string') return
ctx.actions.bind(value.id)
}
</script>
<template>
<ComboboxRoot
:open="ctx.open.value"
:model-value="ctx.variable.value"
:ignore-filter="true"
@update:model-value="select"
@update:open="(open: boolean) => (open ? ctx.actions.openPicker() : ctx.actions.closePicker())"
>
<slot v-bind="ctx.slotProps.value" />
</ComboboxRoot>
</template>

View file

@ -0,0 +1,245 @@
<script setup lang="ts" generic="V">
import { computed, onBeforeUnmount, ref } from 'vue'
import { useBindingProvider } from '#vue/controls/binding-provider/context'
import type {
BindingMutationSource,
BindingProvider,
BindingTarget
} from '#vue/controls/binding-provider/types'
import { provideBindableValue } from '#vue/primitives/BindableValue/context'
import type {
BindableValueActions,
BindableValueContext,
BindableValueRootProps,
BindableValueRootSlots,
BindableValueSlotProps,
BindableValueStateAttrs
} from '#vue/primitives/BindableValue/types'
const {
provider: providerProp,
targets: targetsProp,
value: valueProp,
policy: policyProp = 'detach-on-edit',
batchLabel = 'Edit bound value'
} = defineProps<BindableValueRootProps<V>>()
defineSlots<BindableValueRootSlots<V>>()
const injectedProvider = useBindingProvider<V>()
const resolvedProvider = providerProp ?? injectedProvider
if (!resolvedProvider) {
throw new Error(
'[open-pencil] BindableValueRoot requires a provider prop or provideBindingProvider()'
)
}
const provider: BindingProvider<V> = resolvedProvider
const beginProviderBatch = provider.beginBatch
const commitProviderBatch = provider.commitBatch
const rollbackProviderBatch = provider.rollbackBatch
const supportsInteractionBatch =
beginProviderBatch !== undefined &&
commitProviderBatch !== undefined &&
rollbackProviderBatch !== undefined
const targets = computed(() => targetsProp)
const value = computed(() => valueProp)
const policy = computed(() => policyProp)
const open = ref(false)
const searchTerm = ref('')
const state = computed(() => {
void provider.revision?.value
return provider.getState(targets.value)
})
const variable = computed(() => {
const target = targets.value[0]
return state.value === 'bound' && target ? provider.getBound(target) : undefined
})
const resolvedValue = computed(() => {
void provider.revision?.value
const current = variable.value
return current ? provider.resolve(current.id) : undefined
})
const variables = computed(() => {
void provider.revision?.value
return provider.filterVariables(searchTerm.value)
})
const stateAttrs = computed<BindableValueStateAttrs>(() => ({
'data-unbound': state.value === 'unbound' ? '' : undefined,
'data-bound': state.value === 'bound' ? '' : undefined,
'data-mixed': state.value === 'mixed' ? '' : undefined,
'data-picker-open': open.value ? '' : undefined,
'data-policy': policy.value
}))
let interactionActive = false
let detachedForInteraction = false
let bindingSnapshot = new Map<BindingTarget, string>()
let resolvedSnapshot: V | undefined
function runImmediate(label: string, action: () => void) {
if (provider.runBatch) provider.runBatch(label, action)
else action()
}
function bind(variableId: string) {
runImmediate('Bind variable', () => {
for (const target of targets.value) provider.bind(target, variableId)
})
open.value = false
}
function unbind() {
runImmediate('Unbind variable', () => {
for (const target of targets.value) provider.unbind(target)
})
}
function create(name: string) {
const target = targets.value[0]
if (!target || !provider.create) return
runImmediate('Create and bind variable', () => provider.create?.(target, value.value, name))
open.value = false
}
function openPicker() {
open.value = true
}
function closePicker() {
open.value = false
}
function togglePicker() {
open.value = !open.value
}
function setSearchTerm(term: string) {
searchTerm.value = term
}
function snapshotBindings() {
bindingSnapshot = new Map()
for (const target of targets.value) {
const current = provider.getBound(target)
if (current) bindingSnapshot.set(target, current.id)
}
}
function beginMutation(source: BindingMutationSource): boolean {
if (interactionActive) return true
if (state.value === 'unbound') return true
const startedMixed = state.value === 'mixed'
if (!startedMixed && policy.value === 'readonly-when-bound') return false
if (
!startedMixed &&
policy.value === 'edit-variable' &&
(!variable.value || !provider.setValue)
) {
return false
}
interactionActive = true
void source
snapshotBindings()
resolvedSnapshot = resolvedValue.value
if (supportsInteractionBatch) beginProviderBatch(batchLabel)
if (startedMixed || policy.value === 'detach-on-edit') {
detachedForInteraction = true
for (const target of targets.value) provider.unbind(target)
}
return true
}
function applyValue(nextValue: V): boolean {
if (policy.value !== 'edit-variable' || !interactionActive) return false
const current = variable.value
if (!current || !provider.setValue) return false
provider.setValue(current.id, nextValue)
return true
}
function resetInteraction() {
interactionActive = false
detachedForInteraction = false
bindingSnapshot.clear()
resolvedSnapshot = undefined
}
function commitMutation() {
if (!interactionActive) return
if (supportsInteractionBatch) commitProviderBatch()
resetInteraction()
}
function restoreWithoutRollback() {
if (detachedForInteraction) {
for (const [target, variableId] of bindingSnapshot) provider.bind(target, variableId)
} else if (
policy.value === 'edit-variable' &&
variable.value &&
resolvedSnapshot !== undefined &&
provider.setValue
) {
provider.setValue(variable.value.id, resolvedSnapshot)
}
}
function cancelMutation() {
if (!interactionActive) return
if (supportsInteractionBatch) rollbackProviderBatch()
else restoreWithoutRollback()
resetInteraction()
}
const actions: BindableValueActions<V> = {
bind,
unbind,
create,
openPicker,
closePicker,
togglePicker,
setSearchTerm,
beginMutation,
applyValue,
commitMutation,
cancelMutation
}
const slotProps = computed<BindableValueSlotProps<V>>(() => ({
state: state.value,
variable: variable.value,
resolvedValue: resolvedValue.value,
policy: policy.value,
open: open.value,
searchTerm: searchTerm.value,
variables: variables.value,
stateAttrs: stateAttrs.value,
actions
}))
const context: BindableValueContext<V> = {
provider,
targets,
value,
state,
variable,
resolvedValue,
policy,
open,
searchTerm,
variables,
stateAttrs,
slotProps,
actions
}
provideBindableValue(context)
onBeforeUnmount(cancelMutation)
</script>
<template>
<slot v-bind="slotProps" />
</template>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Primitive } from 'reka-ui'
import { useBindableValue } from '#vue/primitives/BindableValue/context'
import type { BindableValueTriggerProps } from '#vue/primitives/BindableValue/types'
const { as = 'button', asChild = false } = defineProps<BindableValueTriggerProps>()
const ctx = useBindableValue()
const semanticAttrs = computed(() => ({
type: !asChild && as === 'button' ? ('button' as const) : undefined,
'aria-expanded': ctx.open.value,
'aria-haspopup': 'listbox' as const
}))
defineOptions({ inheritAttrs: false })
</script>
<template>
<Primitive
v-bind="{ ...$attrs, ...ctx.stateAttrs.value, ...semanticAttrs }"
:as="as"
:as-child="asChild"
data-slot="trigger"
@click="ctx.actions.togglePicker"
>
<slot v-bind="ctx.slotProps.value" />
</Primitive>
</template>

View file

@ -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<BindableValueContext> = Symbol('BindableValue')
export function provideBindableValue<V>(context: BindableValueContext<V>) {
provide(BINDABLE_VALUE_KEY, context as BindableValueContext)
}
export function useBindableValue<V>(): BindableValueContext<V> {
const context = inject(BINDABLE_VALUE_KEY)
if (!context)
throw new Error('[open-pencil] BindableValue part must be used inside BindableValueRoot')
return context as BindableValueContext<V>
}
export function useOptionalBindableValue<V>(): BindableValueContext<V> | undefined {
return inject(BINDABLE_VALUE_KEY) as BindableValueContext<V> | undefined
}

View file

@ -0,0 +1,242 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Variable } from '@open-pencil/scene-graph'
import type {
BindingProvider,
BindingState,
BindingTarget
} from '#vue/controls/binding-provider/types'
import BindableValuePicker from '#vue/primitives/BindableValue/BindableValuePicker.vue'
import BindableValueRoot from '#vue/primitives/BindableValue/BindableValueRoot.vue'
import BindableValueTrigger from '#vue/primitives/BindableValue/BindableValueTrigger.vue'
import NumberFieldInput from '#vue/primitives/NumberField/NumberFieldInput.vue'
import NumberFieldRoot from '#vue/primitives/NumberField/NumberFieldRoot.vue'
import NumberFieldValue from '#vue/primitives/NumberField/NumberFieldValue.vue'
const variables: Variable[] = [
{
id: 'space/md',
name: 'Space/md',
type: 'FLOAT',
collectionId: 'demo',
valuesByMode: { default: 16 },
description: '',
hiddenFromPublishing: false
},
{
id: 'space/lg',
name: 'Space/lg',
type: 'FLOAT',
collectionId: 'demo',
valuesByMode: { default: 24 },
description: '',
hiddenFromPublishing: false
}
]
const revision = ref(0)
const bindings = ref<Record<string, string | undefined>>({
'detach:width': 'space/md',
'readonly:width': 'space/lg',
'edit-variable:width': 'space/md',
'mixed-a:width': 'space/md',
'mixed-b:width': 'space/lg'
})
const detachValue = ref(8)
const readonlyValue = ref(8)
const editVariableValue = ref(8)
const pickerValue = ref(12)
function key(target: BindingTarget) {
return `${target.nodeId}:${target.path}`
}
const provider: BindingProvider<number> = {
revision,
listVariables: () => variables,
filterVariables: (term) =>
variables.filter((variable) => variable.name.toLowerCase().includes(term.toLowerCase())),
getBound: (target) => variables.find((variable) => variable.id === bindings.value[key(target)]),
getState(targets): BindingState {
const ids = new Set(targets.map((target) => bindings.value[key(target)]))
if (ids.size > 1) return 'mixed'
return ids.has(undefined) ? 'unbound' : 'bound'
},
resolve: (variableId) =>
variables.find((variable) => variable.id === variableId)?.valuesByMode.default as
| number
| undefined,
bind(target, variableId) {
bindings.value[key(target)] = variableId
revision.value++
},
unbind(target) {
bindings.value[key(target)] = undefined
revision.value++
},
setValue(variableId, value) {
const variable = variables.find((item) => item.id === variableId)
if (variable) variable.valuesByMode.default = value
revision.value++
},
create(target, value, name) {
const id = `created:${name}`
variables.push({
id,
name,
type: 'FLOAT',
collectionId: 'demo',
valuesByMode: { default: value },
description: '',
hiddenFromPublishing: false
})
bindings.value[key(target)] = id
revision.value++
}
}
const detachTarget: BindingTarget[] = [{ nodeId: 'detach', path: 'width' }]
const readonlyTarget: BindingTarget[] = [{ nodeId: 'readonly', path: 'width' }]
const editVariableTarget: BindingTarget[] = [{ nodeId: 'edit-variable', path: 'width' }]
const mixedTargets: BindingTarget[] = [
{ nodeId: 'mixed-a', path: 'width' },
{ nodeId: 'mixed-b', path: 'width' }
]
const pickerTarget: BindingTarget[] = [{ nodeId: 'picker', path: 'width' }]
</script>
<template>
<div
class="w-full max-w-[560px] space-y-5 rounded-lg border border-[var(--vp-c-divider)] bg-[var(--vp-c-bg-soft)] p-5 text-[var(--vp-c-text-1)]"
>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<p class="mb-1 text-[11px] text-[var(--vp-c-text-2)]">Detach on edit</p>
<BindableValueRoot
v-slot="{ stateAttrs }"
:provider="provider"
:targets="detachTarget"
:value="detachValue"
>
<NumberFieldRoot
v-slot="{ attrs, editing, actions }"
v-model="detachValue"
aria-label="Detach bound value"
>
<div
v-bind="{ ...attrs, ...stateAttrs }"
class="flex h-[26px] items-center rounded bg-[var(--vp-c-bg-alt)] px-2 text-xs data-[bound]:text-[var(--vp-c-brand-1)]"
@pointerdown="!editing && actions.startScrub($event)"
>
<NumberFieldInput class="min-w-0 flex-1 bg-transparent outline-none" />
<NumberFieldValue />
</div>
</NumberFieldRoot>
</BindableValueRoot>
</div>
<div>
<p class="mb-1 text-[11px] text-[var(--vp-c-text-2)]">Read-only bound</p>
<BindableValueRoot
v-slot="{ stateAttrs }"
:provider="provider"
:targets="readonlyTarget"
:value="readonlyValue"
policy="readonly-when-bound"
>
<NumberFieldRoot
v-slot="{ attrs, editing, actions }"
v-model="readonlyValue"
aria-label="Readonly bound value"
>
<div
v-bind="{ ...attrs, ...stateAttrs }"
class="flex h-[26px] items-center rounded bg-[var(--vp-c-bg-alt)] px-2 text-xs data-[bound]:text-[var(--vp-c-brand-1)]"
@pointerdown="!editing && actions.startScrub($event)"
>
<NumberFieldInput class="min-w-0 flex-1 bg-transparent outline-none" />
<NumberFieldValue />
</div>
</NumberFieldRoot>
</BindableValueRoot>
</div>
<div>
<p class="mb-1 text-[11px] text-[var(--vp-c-text-2)]">Edit variable</p>
<BindableValueRoot
v-slot="{ stateAttrs }"
:provider="provider"
:targets="editVariableTarget"
:value="editVariableValue"
policy="edit-variable"
>
<NumberFieldRoot
v-slot="{ attrs, editing, actions }"
v-model="editVariableValue"
aria-label="Edit bound variable"
>
<div
v-bind="{ ...attrs, ...stateAttrs }"
class="flex h-[26px] items-center rounded bg-[var(--vp-c-bg-alt)] px-2 text-xs data-[bound]:text-[var(--vp-c-brand-1)]"
@pointerdown="!editing && actions.startScrub($event)"
>
<NumberFieldInput class="min-w-0 flex-1 bg-transparent outline-none" />
<NumberFieldValue />
</div>
</NumberFieldRoot>
</BindableValueRoot>
</div>
<div>
<p class="mb-1 text-[11px] text-[var(--vp-c-text-2)]">Mixed bindings</p>
<BindableValueRoot
v-slot="{ state, stateAttrs }"
:provider="provider"
:targets="mixedTargets"
:value="0"
>
<div
v-bind="stateAttrs"
class="flex h-[26px] items-center rounded bg-[var(--vp-c-bg-alt)] px-2 text-xs text-[var(--vp-c-text-2)]"
aria-label="Mixed binding value"
>
{{ state }}
</div>
</BindableValueRoot>
</div>
</div>
<BindableValueRoot
v-slot="{ open, stateAttrs }"
:provider="provider"
:targets="pickerTarget"
:value="pickerValue"
>
<div v-bind="stateAttrs" class="relative">
<BindableValueTrigger
class="rounded bg-[var(--vp-c-bg-alt)] px-2 py-1 text-xs"
aria-label="Choose binding"
>
Choose variable
</BindableValueTrigger>
<BindableValuePicker v-if="open" v-slot="{ variables: options, actions }">
<div
class="absolute top-full left-0 z-10 mt-1 w-40 rounded border border-[var(--vp-c-divider)] bg-[var(--vp-c-bg-soft)] p-1"
>
<button
v-for="option in options"
:key="option.id"
class="block w-full rounded px-2 py-1 text-left text-xs hover:bg-[var(--vp-c-bg-alt)]"
type="button"
@click="actions.bind(option.id)"
>
{{ option.name }}
</button>
</div>
</BindableValuePicker>
</div>
</BindableValueRoot>
</div>
</template>

View file

@ -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'

View file

@ -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<V = unknown> {
/** Binding implementation. Falls back to the nearest injected provider. */
provider?: BindingProvider<V>
/** 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<V = unknown> {
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<V = unknown> {
state: BindingState
variable: Variable | undefined
resolvedValue: V | undefined
policy: BoundEditPolicy
open: boolean
searchTerm: string
variables: Variable[]
stateAttrs: BindableValueStateAttrs
actions: BindableValueActions<V>
}
export interface BindableValueRootSlots<V = unknown> {
/** Complete render contract for binding-aware controls. */
default(props: BindableValueSlotProps<V>): VNode[]
}
export interface BindableValueContext<V = unknown> {
provider: BindingProvider<V>
targets: ComputedRef<BindingTarget[]>
value: ComputedRef<V>
state: ComputedRef<BindingState>
variable: ComputedRef<Variable | undefined>
resolvedValue: ComputedRef<V | undefined>
policy: ComputedRef<BoundEditPolicy>
open: Ref<boolean>
searchTerm: Ref<string>
variables: ComputedRef<Variable[]>
stateAttrs: ComputedRef<BindableValueStateAttrs>
slotProps: ComputedRef<BindableValueSlotProps<V>>
actions: BindableValueActions<V>
}

View file

@ -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(() => {

View file

@ -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<NumberFieldRootEmits>()
defineSlots<NumberFieldRootSlots>()
const binding = useOptionalBindableValue<number>()
const editing = ref(false)
const scrubbing = ref(false)
const draftValue = ref('')
@ -45,16 +48,27 @@ const inputRef = ref<HTMLInputElement | null>(null)
const invalidReason = ref<NumberExpressionError | null>(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<NumberFieldEditPolicy>(() => {
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,

View file

@ -113,6 +113,7 @@ export interface NumberFieldContext {
min: ComputedRef<number>
max: ComputedRef<number>
step: ComputedRef<number>
ariaLabel: ComputedRef<string | undefined>
inputRef: Ref<HTMLInputElement | null>
state: ComputedRef<NumberFieldState>
stateAttrs: ComputedRef<NumberFieldStateAttrs>

View file

@ -18,6 +18,7 @@ const emit = defineEmits<{
<Tip :label="label">
<button
v-bind="$attrs"
:aria-label="label"
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-violet-400 hover:text-surface"
@click="emit('detach')"
>

View file

@ -1,10 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BindableValueRoot, useI18n, useNumberBindingProvider } from '@open-pencil/vue'
import NumberField from '@/components/inputs/NumberField.vue'
import BoundVariableButton from '@/components/properties/BoundVariableButton.vue'
import VariablePickerPopover from '@/components/properties/VariablePickerPopover.vue'
import { useI18n, useNumberVariableBinding } from '@open-pencil/vue'
import type { NumberBindingPath } from '@open-pencil/vue'
import type { BindingTarget, NumberBindingPath } from '@open-pencil/vue'
const {
modelValue,
@ -38,78 +40,64 @@ const emit = defineEmits<{
}>()
const { panels, dialogs } = useI18n()
const binding = useNumberVariableBinding(bindingPath)
const provider = useNumberBindingProvider()
const targets = computed<BindingTarget[]>(() => [{ nodeId, path: bindingPath }])
function resolvedValue(): number | symbol {
const variable = binding.getBoundVariable(nodeId)
if (!variable) return modelValue
const resolved = binding.store.resolveNumberVariable(variable.id)
return resolved ?? modelValue
}
function onUpdate(value: number) {
if (binding.getBoundVariable(nodeId)) binding.unbindVariable(nodeId)
emit('update:modelValue', value)
}
function onBind(variableId: string) {
binding.bindVariable(nodeId, variableId)
const resolved = binding.store.resolveNumberVariable(variableId)
if (resolved != null) emit('update:modelValue', resolved)
}
function onCreate(name: string) {
const value = typeof modelValue === 'number' ? modelValue : 0
binding.createAndBindVariable(nodeId, value, name)
}
defineOptions({ inheritAttrs: true })
defineOptions({ inheritAttrs: false })
</script>
<template>
<NumberField
v-bind="$attrs"
:icon="icon"
:label="label"
:suffix="suffix"
:sensitivity="sensitivity"
:placeholder="placeholder"
:model-value="resolvedValue()"
:min="min"
:max="max"
:step="step"
@update:model-value="onUpdate"
@commit="(v: number, p: number) => emit('commit', v, p)"
<BindableValueRoot
v-slot="binding"
:provider="provider"
:targets="targets"
:value="typeof modelValue === 'number' ? modelValue : 0"
>
<template v-if="$slots.icon" #icon>
<slot name="icon" />
</template>
<template #suffix>
<span :class="$slots['after-variable'] ? '' : 'pr-1'" class="flex items-center">
<BoundVariableButton
v-if="binding.getBoundVariable(nodeId)"
:label="panels.detachVariable"
@detach="binding.unbindVariable(nodeId)"
/>
<VariablePickerPopover
v-else
v-model:search-term="binding.searchTerm.value"
:variables="binding.filteredVariables.value"
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:create-label="
panels.createNumberVariable({
value: typeof modelValue === 'number' ? Math.round(modelValue) : 0
})
"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
@select="onBind($event.id)"
@create="onCreate"
/>
</span>
<slot name="after-variable" />
</template>
</NumberField>
<NumberField
v-bind="$attrs"
:icon="icon"
:label="label"
:suffix="suffix"
:sensitivity="sensitivity"
:placeholder="placeholder"
:model-value="modelValue"
:min="min"
:max="max"
:step="step"
@update:model-value="emit('update:modelValue', $event)"
@commit="(value: number, previous: number) => emit('commit', value, previous)"
>
<template v-if="$slots.icon" #icon>
<slot name="icon" />
</template>
<template #suffix>
<span :class="$slots['after-variable'] ? '' : 'pr-1'" class="flex items-center">
<BoundVariableButton
v-if="binding.state === 'bound'"
:label="panels.detachVariable"
@detach="binding.actions.unbind"
/>
<VariablePickerPopover
v-else
:search-term="binding.searchTerm"
:variables="binding.variables"
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:create-label="
panels.createNumberVariable({
value: typeof modelValue === 'number' ? Math.round(modelValue) : 0
})
"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
@update:search-term="binding.actions.setSearchTerm"
@select="binding.actions.bind($event.id)"
@create="binding.actions.create"
/>
</span>
<slot name="after-variable" />
</template>
</NumberField>
</BindableValueRoot>
</template>

View file

@ -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')

View file

@ -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)