feat(app): bind size fields to variables
This commit is contained in:
parent
c7db8c6004
commit
7199774177
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
55
packages/vue/src/controls/number-variable-binding/use.ts
Normal file
55
packages/vue/src/controls/number-variable-binding/use.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -172,7 +172,8 @@
|
|||
"createVariable": "Создать переменную",
|
||||
"createColorVariable": "Создать переменную цвета из {value}",
|
||||
"variableName": "Имя переменной",
|
||||
"create": "Создать"
|
||||
"create": "Создать",
|
||||
"createNumberVariable": "Создать числовую переменную из {value}"
|
||||
},
|
||||
"pages": {
|
||||
"newPage": "Новая страница",
|
||||
|
|
|
|||
|
|
@ -172,7 +172,8 @@
|
|||
"createVariable": "创建变量",
|
||||
"createColorVariable": "从 {value} 创建颜色变量",
|
||||
"variableName": "变量名称",
|
||||
"create": "创建"
|
||||
"create": "创建",
|
||||
"createNumberVariable": "从 {value} 创建数字变量"
|
||||
},
|
||||
"pages": {
|
||||
"newPage": "新建页面",
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue