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:
parent
5d9730ae04
commit
ffb12824eb
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()`
|
||||
|
|
|
|||
16
packages/vue/src/controls/binding-provider/context.ts
Normal file
16
packages/vue/src/controls/binding-provider/context.ts
Normal 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
|
||||
}
|
||||
20
packages/vue/src/controls/binding-provider/index.ts
Normal file
20
packages/vue/src/controls/binding-provider/index.ts
Normal 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'
|
||||
64
packages/vue/src/controls/binding-provider/number.ts
Normal file
64
packages/vue/src/controls/binding-provider/number.ts
Normal 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
|
||||
})
|
||||
}
|
||||
75
packages/vue/src/controls/binding-provider/open-pencil.ts
Normal file
75
packages/vue/src/controls/binding-provider/open-pencil.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
30
packages/vue/src/controls/binding-provider/types.ts
Normal file
30
packages/vue/src/controls/binding-provider/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
245
packages/vue/src/primitives/BindableValue/BindableValueRoot.vue
Normal file
245
packages/vue/src/primitives/BindableValue/BindableValueRoot.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
21
packages/vue/src/primitives/BindableValue/context.ts
Normal file
21
packages/vue/src/primitives/BindableValue/context.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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>
|
||||
18
packages/vue/src/primitives/BindableValue/index.ts
Normal file
18
packages/vue/src/primitives/BindableValue/index.ts
Normal 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'
|
||||
86
packages/vue/src/primitives/BindableValue/types.ts
Normal file
86
packages/vue/src/primitives/BindableValue/types.ts
Normal 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>
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue