feat(app): bind size fields to variables

This commit is contained in:
Danila Poyarkov 2026-05-01 17:20:45 +03:00
parent c7db8c6004
commit 7199774177
16 changed files with 207 additions and 18 deletions

View file

@ -10,7 +10,7 @@
- Use `@use-gesture/vanilla` for wheel gesture lifecycle handling and faster trackpad zoom behavior.
- Add auto-layout inspector controls for min/max dimensions, Auto gap distribution, wrap cross-axis gap, and two-axis padding controls.
- Add signed Tauri updater configuration, release artifacts, startup update checks, and a native Check for Updates menu item.
- Add inspector variable binding controls for fill and stroke colors, including existing variable selection and named color variable creation.
- Add inspector variable binding controls for fill and stroke colors plus width/height sizing, including existing variable selection and named variable creation.
### Fixes

View file

@ -14,6 +14,10 @@ export function createVariableActions(ctx: EditorContext) {
return ctx.graph.resolveColorVariable(id)
}
function resolveNumberVariable(id: string) {
return ctx.graph.resolveNumberVariable(id)
}
function getVariablesForCollection(collectionId: string) {
return ctx.graph.getVariablesForCollection(collectionId)
}
@ -177,6 +181,7 @@ export function createVariableActions(ctx: EditorContext) {
getVariablesByType,
getVariable,
resolveColorVariable,
resolveNumberVariable,
getVariablesForCollection,
getCollection,
getCollections,

View file

@ -0,0 +1,55 @@
import { useVariableBinding } from '#vue/controls/variable-binding/use'
import { randomHex } from '@open-pencil/core/random'
import type { VariableCollection } from '@open-pencil/core/scene-graph'
const FALLBACK_NUMBER_VARIABLE_NAME = 'New number'
type NumberBindingPath = 'width' | 'height'
export function useNumberVariableBinding(path: NumberBindingPath) {
const binding = useVariableBinding({
type: 'FLOAT',
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)
}
return {
...binding,
numberVariables: binding.variables,
createAndBindVariable
}
}

View file

@ -174,6 +174,7 @@ export const panelMessages = i18n('panels', {
create: 'Create',
createVariable: 'Create variable',
createColorVariable: params('Create color variable from {value}'),
createNumberVariable: params('Create number variable from {value}'),
variableName: 'Variable name',
mixed: 'Mixed',
layersCount: params('{count} layers'),

View file

@ -55,6 +55,7 @@ export { useExport } from '#vue/document/export/use'
export type { ExportFormatId } from '#vue/document/export/use'
export { useFillControls } from '#vue/controls/fill/use'
export { useColorVariableBinding } from '#vue/controls/color-variable-binding/use'
export { useNumberVariableBinding } from '#vue/controls/number-variable-binding/use'
export { useVariableBinding } from '#vue/controls/variable-binding/use'
export type {
VariableBindingState,

View file

@ -172,7 +172,8 @@
"createVariable": "Variable erstellen",
"createColorVariable": "Farbvariable aus {value} erstellen",
"variableName": "Variablenname",
"create": "Erstellen"
"create": "Erstellen",
"createNumberVariable": "Zahlenvariable aus {value} erstellen"
},
"pages": {
"newPage": "Neue Seite",

View file

@ -172,7 +172,8 @@
"createVariable": "Crear variable",
"createColorVariable": "Crear variable de color desde {value}",
"variableName": "Nombre de variable",
"create": "Crear"
"create": "Crear",
"createNumberVariable": "Crear variable numérica desde {value}"
},
"pages": {
"newPage": "Nueva página",

View file

@ -172,7 +172,8 @@
"createVariable": "Créer une variable",
"createColorVariable": "Créer une variable de couleur depuis {value}",
"variableName": "Nom de la variable",
"create": "Créer"
"create": "Créer",
"createNumberVariable": "Créer une variable numérique depuis {value}"
},
"pages": {
"newPage": "Nouvelle page",

View file

@ -172,7 +172,8 @@
"createVariable": "Crea variabile",
"createColorVariable": "Crea variabile colore da {value}",
"variableName": "Nome variabile",
"create": "Crea"
"create": "Crea",
"createNumberVariable": "Crea variabile numerica da {value}"
},
"pages": {
"newPage": "Nuova pagina",

View file

@ -172,7 +172,8 @@
"createVariable": "Utwórz zmienną",
"createColorVariable": "Utwórz zmienną koloru z {value}",
"variableName": "Nazwa zmiennej",
"create": "Utwórz"
"create": "Utwórz",
"createNumberVariable": "Utwórz zmienną liczbową z {value}"
},
"pages": {
"newPage": "Nowa strona",

View file

@ -172,7 +172,8 @@
"createVariable": "Создать переменную",
"createColorVariable": "Создать переменную цвета из {value}",
"variableName": "Имя переменной",
"create": "Создать"
"create": "Создать",
"createNumberVariable": "Создать числовую переменную из {value}"
},
"pages": {
"newPage": "Новая страница",

View file

@ -172,7 +172,8 @@
"createVariable": "创建变量",
"createColorVariable": "从 {value} 创建颜色变量",
"variableName": "变量名称",
"create": "创建"
"create": "创建",
"createNumberVariable": "从 {value} 创建数字变量"
},
"pages": {
"newPage": "新建页面",

View file

@ -68,6 +68,7 @@ defineOptions({ inheritAttrs: false })
:max="max === Infinity ? undefined : max"
:step="step"
/>
<slot v-if="editing" name="suffix" />
<ScrubInputDisplay
class="flex flex-1 items-center truncate overflow-hidden text-xs select-none"
:class="$slots.suffix ? 'pr-0' : 'pr-1.5'"

View file

@ -12,8 +12,10 @@ import {
} from 'reka-ui'
import ScrubInput from '@/components/ScrubInput.vue'
import BoundVariableButton from '@/components/properties/BoundVariableButton.vue'
import VariablePickerPopover from '@/components/properties/VariablePickerPopover.vue'
import { useSelectUI } from '@/components/ui/select'
import { useI18n, useLayoutControlsContext } from '@open-pencil/vue'
import { useI18n, useLayoutControlsContext, useNumberVariableBinding } from '@open-pencil/vue'
import type { LayoutSizing } from '@open-pencil/core/scene-graph'
import type { SizeLimitProp } from '@open-pencil/vue'
@ -30,6 +32,8 @@ type ActiveSizeLimit = {
}
const ctx = useLayoutControlsContext()
const widthVariableBinding = useNumberVariableBinding('width')
const heightVariableBinding = useNumberVariableBinding('height')
const widthFieldRef = ref<HTMLElement | null>(null)
const heightFieldRef = ref<HTMLElement | null>(null)
const limitFieldRefs = ref<Record<SizeLimitProp, HTMLElement | null>>({
@ -39,7 +43,7 @@ const limitFieldRefs = ref<Record<SizeLimitProp, HTMLElement | null>>({
maxHeight: null
})
const { panels } = useI18n()
const { panels, dialogs } = useI18n()
const sizingSelect = useSelectUI({ item: 'rounded py-1.5 pr-2 pl-6 text-xs' })
const widthLimitItems = [
@ -118,6 +122,35 @@ function handleLimitSelect(prop: SizeLimitProp, value: string) {
else if (value === 'REMOVE') ctx.removeSizeLimit(prop)
}
function resolvedBoundNumber(axis: 'width' | 'height'): number | undefined {
const binding = axis === 'width' ? widthVariableBinding : heightVariableBinding
const variable = binding.getBoundVariable(ctx.node.id)
return variable ? binding.store.resolveNumberVariable(variable.id) : undefined
}
function updateSizeProp(axis: 'width' | 'height', value: number) {
const binding = axis === 'width' ? widthVariableBinding : heightVariableBinding
if (binding.getBoundVariable(ctx.node.id)) binding.unbindVariable(ctx.node.id)
ctx.updateProp(axis, value)
}
function commitSizeProp(axis: 'width' | 'height', value: number, previous: number) {
ctx.commitProp(axis, value, previous)
}
function bindSizeVariable(axis: 'width' | 'height', variableId: string) {
const binding = axis === 'width' ? widthVariableBinding : heightVariableBinding
binding.bindVariable(ctx.node.id, variableId)
const value = binding.store.resolveNumberVariable(variableId)
if (value != null) ctx.updateProp(axis, value)
}
function createAndBindSizeVariable(axis: 'width' | 'height', name: string) {
const binding = axis === 'width' ? widthVariableBinding : heightVariableBinding
const value = ctx.node[axis]
binding.createAndBindVariable(ctx.node.id, value, name)
}
function handleSizeSelect(axis: 'width' | 'height', value: SizeSelectValue) {
if (value === 'FIXED' || value === 'HUG' || value === 'FILL') {
if (axis === 'width') ctx.setWidthSizing(value)
@ -135,13 +168,35 @@ function handleSizeSelect(axis: 'width' | 'height', value: SizeSelectValue) {
<div class="flex gap-1.5">
<div ref="widthFieldRef" class="min-w-0 flex-1">
<ScrubInput
data-test-id="layout-width-input"
icon="W"
:model-value="Math.round(ctx.node.width)"
:model-value="Math.round(resolvedBoundNumber('width') ?? ctx.node.width)"
:min="0"
@update:model-value="ctx.updateProp('width', $event)"
@commit="(v: number, p: number) => ctx.commitProp('width', v, p)"
@update:model-value="updateSizeProp('width', $event)"
@commit="(v: number, p: number) => commitSizeProp('width', v, p)"
>
<template v-if="ctx.isFlex || ctx.isInAutoLayout" #suffix>
<template #suffix>
<BoundVariableButton
v-if="widthVariableBinding.getBoundVariable(ctx.node.id)"
test-id="layout-width-unbind-variable"
:label="panels.detachVariable"
@detach="widthVariableBinding.unbindVariable(ctx.node.id)"
/>
<VariablePickerPopover
v-else
v-model:search-term="widthVariableBinding.searchTerm.value"
:variables="widthVariableBinding.filteredVariables.value"
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:trigger-test-id="'layout-width-apply-variable'"
:create-label="panels.createNumberVariable({ value: Math.round(ctx.node.width) })"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
:create-test-id="'layout-width-apply-variable-create'"
@select="bindSizeVariable('width', $event.id)"
@create="createAndBindSizeVariable('width', $event)"
/>
<SelectRoot
:model-value="ctx.widthSizing"
@update:model-value="handleSizeSelect('width', $event as SizeSelectValue)"
@ -196,13 +251,35 @@ function handleSizeSelect(axis: 'width' | 'height', value: SizeSelectValue) {
<div ref="heightFieldRef" class="min-w-0 flex-1">
<ScrubInput
data-test-id="layout-height-input"
icon="H"
:model-value="Math.round(ctx.node.height)"
:model-value="Math.round(resolvedBoundNumber('height') ?? ctx.node.height)"
:min="0"
@update:model-value="ctx.updateProp('height', $event)"
@commit="(v: number, p: number) => ctx.commitProp('height', v, p)"
@update:model-value="updateSizeProp('height', $event)"
@commit="(v: number, p: number) => commitSizeProp('height', v, p)"
>
<template v-if="ctx.isFlex || ctx.isInAutoLayout" #suffix>
<template #suffix>
<BoundVariableButton
v-if="heightVariableBinding.getBoundVariable(ctx.node.id)"
test-id="layout-height-unbind-variable"
:label="panels.detachVariable"
@detach="heightVariableBinding.unbindVariable(ctx.node.id)"
/>
<VariablePickerPopover
v-else
v-model:search-term="heightVariableBinding.searchTerm.value"
:variables="heightVariableBinding.filteredVariables.value"
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:trigger-test-id="'layout-height-apply-variable'"
:create-label="panels.createNumberVariable({ value: Math.round(ctx.node.height) })"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
:create-test-id="'layout-height-apply-variable-create'"
@select="bindSizeVariable('height', $event.id)"
@create="createAndBindSizeVariable('height', $event)"
/>
<SelectRoot
:model-value="ctx.heightSizing"
@update:model-value="handleSizeSelect('height', $event as SizeSelectValue)"

View file

@ -81,6 +81,7 @@ function submitCreate() {
:aria-label="triggerLabel"
:title="triggerLabel"
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
@pointerdown.stop
>
<icon-lucide-diamond-plus class="size-3.5" />
</PopoverTrigger>

View file

@ -171,6 +171,47 @@ test('fill color can create and bind a variable', async () => {
canvas.assertNoErrors()
})
test('width can create, bind, and detach a number variable', async () => {
await canvas.clearCanvas()
await canvas.drawRect(200, 200, 80, 80)
await page.locator('[data-test-id="layout-height-input"]').click()
await page.locator('[data-test-id="layout-width-apply-variable"]').click()
await expect(page.getByText('Create number variable from 80')).toBeVisible()
await page.locator('[data-test-id="layout-width-apply-variable-create"]').click()
await page.getByPlaceholder('Variable name').fill('Card/width')
await page.locator('[data-test-id="layout-width-apply-variable-create"]').click()
await canvas.waitForRender()
await expect(page.locator('[data-test-id="layout-width-unbind-variable"]')).toBeVisible()
const boundVariable = await page.evaluate(() => {
const store = window.__OPEN_PENCIL_STORE__!
const id = [...store.state.selectedIds][0]
if (!id) return null
const node = store.getNode(id)
const variableId = node?.boundVariables.width
return variableId ? store.getVariable(variableId)?.name : null
})
expect(boundVariable).toBe('Card/width')
const widthField = page.locator('[data-test-id="layout-width-input"]')
await widthField.click()
const widthInput = widthField.locator('[data-test-id="scrub-input-field"]')
await widthInput.fill('120')
await widthInput.press('Enter')
await canvas.waitForRender()
await expect(page.locator('[data-test-id="layout-width-unbind-variable"]')).toBeHidden()
const directWidth = await page.evaluate(() => {
const store = window.__OPEN_PENCIL_STORE__!
const id = [...store.state.selectedIds][0]
const node = id ? store.getNode(id) : null
return node ? { width: node.width, binding: node.boundVariables.width ?? null } : null
})
expect(directWidth).toEqual({ width: 120, binding: null })
canvas.assertNoErrors()
})
test('alignment buttons align nodes to same X', async () => {
await canvas.clearCanvas()
await canvas.drawRect(50, 200, 60, 60)