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:
parent
93160b1bfb
commit
ab1ba02c94
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
|
|
|
|||
113
src/components/properties/VariableScrubInput.vue
Normal file
113
src/components/properties/VariableScrubInput.vue
Normal 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>
|
||||
Loading…
Reference in a new issue