feat(app): extend variable binding to more inspector fields

Add VariableScrubInput — reusable wrapper that combines ScrubInput
with variable binding picker and detach button. Wire it into:

- Corner radius (uniform + 4 independent corners)
- Opacity
- Auto-layout gap (primary + cross axis)
- Padding (horizontal/vertical + 4 individual sides)
- Font size

All fields get bind, detach, create, search, and detach-on-edit
behavior automatically through the shared component.
This commit is contained in:
Danila Poyarkov 2026-05-04 12:47:38 +03:00
parent 93160b1bfb
commit ab1ba02c94
7 changed files with 208 additions and 22 deletions

View file

@ -6,7 +6,26 @@ import type { VariableCollection } from '@open-pencil/core/scene-graph'
const FALLBACK_NUMBER_VARIABLE_NAME = 'New number'
type NumberBindingPath = 'width' | 'height'
export type NumberBindingPath =
| 'width'
| 'height'
| 'minWidth'
| 'maxWidth'
| 'minHeight'
| 'maxHeight'
| 'cornerRadius'
| 'topLeftRadius'
| 'topRightRadius'
| 'bottomLeftRadius'
| 'bottomRightRadius'
| 'itemSpacing'
| 'counterAxisSpacing'
| 'paddingTop'
| 'paddingRight'
| 'paddingBottom'
| 'paddingLeft'
| 'opacity'
| 'fontSize'
export function useNumberVariableBinding(path: NumberBindingPath) {
const binding = useVariableBinding({

View file

@ -56,6 +56,7 @@ 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 type { NumberBindingPath } from '#vue/controls/number-variable-binding/use'
export { useVariableBinding } from '#vue/controls/variable-binding/use'
export type {
VariableBindingState,

View file

@ -4,6 +4,7 @@ import { computed, ref } from 'vue'
import { useAppearance, useI18n } from '@open-pencil/vue'
import ScrubInput from '@/components/ScrubInput.vue'
import VariableScrubInput from '@/components/properties/VariableScrubInput.vue'
import Tip from '@/components/ui/Tip.vue'
import { useSectionUI } from '@/components/ui/section'
@ -65,7 +66,23 @@ function onToggleCorners() {
</div>
<div class="flex gap-1.5">
<VariableScrubInput
v-if="node"
suffix="%"
:model-value="opacityPercent"
:min="0"
:max="100"
:node-id="node.id"
binding-path="opacity"
@update:model-value="updateProp('opacity', $event / 100)"
@commit="(v: number, p: number) => commitProp('opacity', v / 100, p / 100)"
>
<template #icon>
<icon-lucide-blend class="size-3" />
</template>
</VariableScrubInput>
<ScrubInput
v-else
suffix="%"
:model-value="opacityPercent"
:min="0"
@ -79,8 +96,22 @@ function onToggleCorners() {
</ScrubInput>
<template v-if="hasCornerRadius">
<VariableScrubInput
v-if="!showIndependentCorners && node"
data-test-id="corner-radius-input"
:model-value="cornerRadiusValue"
:min="0"
:node-id="node.id"
binding-path="cornerRadius"
@update:model-value="updateProp('cornerRadius', $event)"
@commit="(v: number, p: number) => commitProp('cornerRadius', v, p)"
>
<template #icon>
<icon-lucide-square-round-corner class="size-3" />
</template>
</VariableScrubInput>
<ScrubInput
v-if="!showIndependentCorners"
v-else-if="!showIndependentCorners"
data-test-id="corner-radius-input"
:model-value="cornerRadiusValue"
:min="0"
@ -121,10 +152,12 @@ function onToggleCorners() {
data-test-id="independent-corners-grid"
class="mt-1.5 grid grid-cols-2 gap-1.5"
>
<ScrubInput
<VariableScrubInput
data-test-id="corner-tl-input"
:model-value="node.topLeftRadius"
:min="0"
:node-id="node.id"
binding-path="topLeftRadius"
@update:model-value="updateCornerProp('topLeftRadius', $event)"
@commit="(v: number, p: number) => commitCornerProp('topLeftRadius', v, p)"
>
@ -139,11 +172,13 @@ function onToggleCorners() {
<path d="M1 11V4a3 3 0 0 1 3-3h7" />
</svg>
</template>
</ScrubInput>
<ScrubInput
</VariableScrubInput>
<VariableScrubInput
data-test-id="corner-tr-input"
:model-value="node.topRightRadius"
:min="0"
:node-id="node.id"
binding-path="topRightRadius"
@update:model-value="updateCornerProp('topRightRadius', $event)"
@commit="(v: number, p: number) => commitCornerProp('topRightRadius', v, p)"
>
@ -158,11 +193,13 @@ function onToggleCorners() {
<path d="M11 11V4a3 3 0 0 0-3-3H1" />
</svg>
</template>
</ScrubInput>
<ScrubInput
</VariableScrubInput>
<VariableScrubInput
data-test-id="corner-bl-input"
:model-value="node.bottomLeftRadius"
:min="0"
:node-id="node.id"
binding-path="bottomLeftRadius"
@update:model-value="updateCornerProp('bottomLeftRadius', $event)"
@commit="(v: number, p: number) => commitCornerProp('bottomLeftRadius', v, p)"
>
@ -177,11 +214,13 @@ function onToggleCorners() {
<path d="M1 1v7a3 3 0 0 0 3 3h7" />
</svg>
</template>
</ScrubInput>
<ScrubInput
</VariableScrubInput>
<VariableScrubInput
data-test-id="corner-br-input"
:model-value="node.bottomRightRadius"
:min="0"
:node-id="node.id"
binding-path="bottomRightRadius"
@update:model-value="updateCornerProp('bottomRightRadius', $event)"
@commit="(v: number, p: number) => commitCornerProp('bottomRightRadius', v, p)"
>
@ -196,7 +235,7 @@ function onToggleCorners() {
<path d="M11 1v7a3 3 0 0 1-3 3H1" />
</svg>
</template>
</ScrubInput>
</VariableScrubInput>
</div>
</div>
</template>

View file

@ -13,6 +13,7 @@ import {
import AppSelect from '@/components/ui/AppSelect.vue'
import ScrubInput from '@/components/ScrubInput.vue'
import VariableScrubInput from '@/components/properties/VariableScrubInput.vue'
import ClipContentControl from '@/components/properties/LayoutSection/ClipContentControl.vue'
import PaddingControls from '@/components/properties/LayoutSection/PaddingControls.vue'
import { useSelectUI } from '@/components/ui/select'
@ -57,12 +58,14 @@ function isAlignmentActive(primary: LayoutAlign, counter: string) {
<div class="mt-2 flex items-center gap-1.5">
<template v-if="ctx.node.layoutWrap === 'WRAP'">
<ScrubInput
<VariableScrubInput
data-test-id="layout-gap-input"
class="min-w-0 flex-1"
:label="ctx.node.layoutMode === 'VERTICAL' ? panels.verticalGap : panels.horizontalGap"
:model-value="Math.round(ctx.node.itemSpacing)"
:min="0"
:node-id="ctx.node.id"
binding-path="itemSpacing"
@update:model-value="ctx.updateProp('itemSpacing', $event)"
@commit="(v: number, p: number) => ctx.commitProp('itemSpacing', v, p)"
>
@ -73,13 +76,15 @@ function isAlignmentActive(primary: LayoutAlign, counter: string) {
/>
<icon-lucide-align-horizontal-space-between v-else class="size-3.5" />
</template>
</ScrubInput>
<ScrubInput
</VariableScrubInput>
<VariableScrubInput
data-test-id="layout-cross-gap-input"
class="min-w-0 flex-1"
:label="ctx.node.layoutMode === 'VERTICAL' ? panels.horizontalGap : panels.verticalGap"
:model-value="Math.round(ctx.node.counterAxisSpacing)"
:min="0"
:node-id="ctx.node.id"
binding-path="counterAxisSpacing"
@update:model-value="ctx.updateProp('counterAxisSpacing', $event)"
@commit="(v: number, p: number) => ctx.commitProp('counterAxisSpacing', v, p)"
>
@ -90,7 +95,7 @@ function isAlignmentActive(primary: LayoutAlign, counter: string) {
/>
<icon-lucide-align-vertical-space-between v-else class="size-3.5" />
</template>
</ScrubInput>
</VariableScrubInput>
</template>
<template v-else>
<div

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import ScrubInput from '@/components/ScrubInput.vue'
import VariableScrubInput from '@/components/properties/VariableScrubInput.vue'
import { useLayoutControlsContext } from '@open-pencil/vue'
import type { PaddingProp } from '@/components/properties/LayoutSection/types'
@ -19,36 +19,42 @@ const paddingSides: Array<{ prop: PaddingProp; icon: string }> = [
v-if="!ctx.showIndividualPadding && ctx.hasSymmetricPadding"
class="mt-1.5 grid grid-cols-2 gap-1.5"
>
<ScrubInput
<VariableScrubInput
data-test-id="layout-horizontal-padding-input"
:model-value="Math.round(ctx.node.paddingLeft)"
:min="0"
:node-id="ctx.node.id"
binding-path="paddingLeft"
@update:model-value="ctx.setHorizontalPadding"
@commit="ctx.commitHorizontalPadding"
>
<template #icon>
<icon-lucide-separator-vertical class="size-3.5" />
</template>
</ScrubInput>
<ScrubInput
</VariableScrubInput>
<VariableScrubInput
data-test-id="layout-vertical-padding-input"
:model-value="Math.round(ctx.node.paddingTop)"
:min="0"
:node-id="ctx.node.id"
binding-path="paddingTop"
@update:model-value="ctx.setVerticalPadding"
@commit="ctx.commitVerticalPadding"
>
<template #icon>
<icon-lucide-separator-horizontal class="size-3.5" />
</template>
</ScrubInput>
</VariableScrubInput>
</div>
<div v-else-if="ctx.isGrid || ctx.isFlex" class="mt-1.5 grid grid-cols-2 gap-1.5">
<ScrubInput
<VariableScrubInput
v-for="side in paddingSides"
:key="side.prop"
:model-value="Math.round(ctx.node[side.prop])"
:min="0"
:node-id="ctx.node.id"
:binding-path="side.prop"
@update:model-value="ctx.updateProp(side.prop, $event)"
@commit="(v: number, p: number) => ctx.commitProp(side.prop, v, p)"
>
@ -58,6 +64,6 @@ const paddingSides: Array<{ prop: PaddingProp; icon: string }> = [
<icon-lucide-panel-bottom v-else-if="side.icon === 'bottom'" class="size-3.5" />
<icon-lucide-panel-left v-else class="size-3.5" />
</template>
</ScrubInput>
</VariableScrubInput>
</div>
</template>

View file

@ -5,6 +5,7 @@ import { TypographyControlsRoot, useI18n } from '@open-pencil/vue'
import FontPicker from '@/components/FontPicker.vue'
import ScrubInput from '@/components/ScrubInput.vue'
import VariableScrubInput from '@/components/properties/VariableScrubInput.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import Tip from '@/components/ui/Tip.vue'
import { useSectionUI } from '@/components/ui/section'
@ -47,11 +48,13 @@ const sectionCls = useSectionUI()
:options="ctx.weights"
@update:model-value="ctx.setWeight(+$event)"
/>
<ScrubInput
<VariableScrubInput
class="flex-1"
:model-value="ctx.node.value.fontSize"
:min="1"
:max="1000"
:node-id="ctx.node.value.id"
binding-path="fontSize"
@update:model-value="ctx.updateProp('fontSize', $event)"
@commit="(v: number, p: number) => ctx.commitProp('fontSize', v, p)"
/>

View file

@ -0,0 +1,113 @@
<script setup lang="ts">
import ScrubInput from '@/components/ScrubInput.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'
const {
modelValue,
min,
max,
step,
icon,
label,
suffix,
sensitivity,
placeholder,
nodeId,
bindingPath
} = defineProps<{
modelValue: number | symbol
min?: number
max?: number
step?: number
icon?: string
label?: string
suffix?: string
sensitivity?: number
placeholder?: string
nodeId: string
bindingPath: NumberBindingPath
}>()
const emit = defineEmits<{
'update:modelValue': [value: number]
commit: [value: number, previous: number]
}>()
const { panels, dialogs } = useI18n()
const binding = useNumberVariableBinding(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 })
</script>
<template>
<ScrubInput
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)"
>
<template v-if="$slots.icon" #icon>
<slot name="icon" />
</template>
<template #suffix>
<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"
/>
<slot name="after-variable" />
</template>
</ScrubInput>
</template>