396 lines
15 KiB
Vue
396 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import { ref } from 'vue'
|
|
import {
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectItemIndicator,
|
|
SelectItemText,
|
|
SelectPortal,
|
|
SelectRoot,
|
|
SelectTrigger,
|
|
SelectViewport
|
|
} 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, useNumberVariableBinding } from '@open-pencil/vue'
|
|
|
|
import type { LayoutSizing } from '@open-pencil/core/scene-graph'
|
|
import type { SizeLimitProp } from '@open-pencil/vue'
|
|
|
|
type SizeSelectValue = LayoutSizing | `add-${SizeLimitProp}` | `remove-${SizeLimitProp}`
|
|
|
|
type ActiveSizeLimit = {
|
|
prop: SizeLimitProp
|
|
testId: string
|
|
icon: () => string
|
|
value: () => number | null
|
|
setLabel: () => string
|
|
removeLabel: () => string
|
|
}
|
|
|
|
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>>({
|
|
minWidth: null,
|
|
maxWidth: null,
|
|
minHeight: null,
|
|
maxHeight: null
|
|
})
|
|
|
|
const { panels, dialogs } = useI18n()
|
|
const sizingSelect = useSelectUI({ item: 'rounded py-1.5 pr-2 pl-6 text-xs' })
|
|
|
|
const widthLimitItems = [
|
|
{
|
|
prop: 'minWidth' as const,
|
|
addLabel: () => panels.value.addMinWidth,
|
|
removeLabel: () => panels.value.removeMinWidth
|
|
},
|
|
{
|
|
prop: 'maxWidth' as const,
|
|
addLabel: () => panels.value.addMaxWidth,
|
|
removeLabel: () => panels.value.removeMaxWidth
|
|
}
|
|
]
|
|
|
|
const activeSizeLimits: ActiveSizeLimit[] = [
|
|
{
|
|
prop: 'minWidth',
|
|
testId: 'layout-min-width-input',
|
|
icon: () => panels.value.minWidthShort,
|
|
value: () => ctx.node.minWidth,
|
|
setLabel: () => panels.value.setToCurrentWidth,
|
|
removeLabel: () => panels.value.removeMinWidth
|
|
},
|
|
{
|
|
prop: 'maxWidth',
|
|
testId: 'layout-max-width-input',
|
|
icon: () => panels.value.maxWidthShort,
|
|
value: () => ctx.node.maxWidth,
|
|
setLabel: () => panels.value.setToCurrentWidth,
|
|
removeLabel: () => panels.value.removeMaxWidth
|
|
},
|
|
{
|
|
prop: 'minHeight',
|
|
testId: 'layout-min-height-input',
|
|
icon: () => panels.value.minHeightShort,
|
|
value: () => ctx.node.minHeight,
|
|
setLabel: () => panels.value.setToCurrentHeight,
|
|
removeLabel: () => panels.value.removeMinHeight
|
|
},
|
|
{
|
|
prop: 'maxHeight',
|
|
testId: 'layout-max-height-input',
|
|
icon: () => panels.value.maxHeightShort,
|
|
value: () => ctx.node.maxHeight,
|
|
setLabel: () => panels.value.setToCurrentHeight,
|
|
removeLabel: () => panels.value.removeMaxHeight
|
|
}
|
|
]
|
|
|
|
const heightLimitItems = [
|
|
{
|
|
prop: 'minHeight' as const,
|
|
addLabel: () => panels.value.addMinHeight,
|
|
removeLabel: () => panels.value.removeMinHeight
|
|
},
|
|
{
|
|
prop: 'maxHeight' as const,
|
|
addLabel: () => panels.value.addMaxHeight,
|
|
removeLabel: () => panels.value.removeMaxHeight
|
|
}
|
|
]
|
|
|
|
function sizingShortLabel(sizing: LayoutSizing): string | null {
|
|
if (sizing === 'HUG') return panels.value.sizingHugShort
|
|
if (sizing === 'FILL') return panels.value.sizingFillShort
|
|
return null
|
|
}
|
|
|
|
function anchorRef(element: HTMLElement | null): HTMLElement | undefined {
|
|
return element ?? undefined
|
|
}
|
|
|
|
function handleLimitSelect(prop: SizeLimitProp, value: string) {
|
|
if (value === 'CURRENT') ctx.setSizeLimitToCurrent(prop)
|
|
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)
|
|
else ctx.setHeightSizing(value)
|
|
return
|
|
}
|
|
|
|
const [action, prop] = value.split('-') as ['add' | 'remove', SizeLimitProp]
|
|
if (action === 'add') ctx.addSizeLimit(prop)
|
|
else ctx.removeSizeLimit(prop)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<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(resolvedBoundNumber('width') ?? ctx.node.width)"
|
|
:min="0"
|
|
@update:model-value="updateSizeProp('width', $event)"
|
|
@commit="(v: number, p: number) => commitSizeProp('width', v, p)"
|
|
>
|
|
<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)"
|
|
>
|
|
<SelectTrigger
|
|
data-test-id="layout-width-sizing-menu"
|
|
:reference="anchorRef(widthFieldRef)"
|
|
class="flex shrink-0 cursor-pointer items-center self-stretch border-none bg-transparent px-1 text-[11px] text-muted outline-none"
|
|
@pointerdown.stop
|
|
>
|
|
<span class="group-hover:hidden">{{ sizingShortLabel(ctx.widthSizing) }}</span>
|
|
<icon-lucide-chevron-down class="hidden size-3 group-hover:block" />
|
|
</SelectTrigger>
|
|
<SelectPortal>
|
|
<SelectContent
|
|
position="popper"
|
|
align="start"
|
|
:side-offset="4"
|
|
:class="sizingSelect.content"
|
|
>
|
|
<SelectViewport class="p-0.5">
|
|
<SelectItem
|
|
v-for="opt in ctx.widthSizingOptions"
|
|
:key="opt.value"
|
|
:value="opt.value"
|
|
:class="sizingSelect.item"
|
|
>
|
|
<SelectItemIndicator
|
|
class="absolute left-1.5 inline-flex items-center justify-center"
|
|
>
|
|
<icon-lucide-check class="size-3 text-accent" />
|
|
</SelectItemIndicator>
|
|
<SelectItemText>{{ opt.label }}</SelectItemText>
|
|
</SelectItem>
|
|
<SelectItem
|
|
v-for="item in widthLimitItems"
|
|
:key="item.prop"
|
|
:value="`${ctx.node[item.prop] == null ? 'add' : 'remove'}-${item.prop}`"
|
|
:class="sizingSelect.item"
|
|
>
|
|
<SelectItemText>
|
|
{{ ctx.node[item.prop] == null ? item.addLabel() : item.removeLabel() }}
|
|
</SelectItemText>
|
|
</SelectItem>
|
|
</SelectViewport>
|
|
</SelectContent>
|
|
</SelectPortal>
|
|
</SelectRoot>
|
|
</template>
|
|
</ScrubInput>
|
|
</div>
|
|
|
|
<div ref="heightFieldRef" class="min-w-0 flex-1">
|
|
<ScrubInput
|
|
data-test-id="layout-height-input"
|
|
icon="H"
|
|
:model-value="Math.round(resolvedBoundNumber('height') ?? ctx.node.height)"
|
|
:min="0"
|
|
@update:model-value="updateSizeProp('height', $event)"
|
|
@commit="(v: number, p: number) => commitSizeProp('height', v, p)"
|
|
>
|
|
<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)"
|
|
>
|
|
<SelectTrigger
|
|
data-test-id="layout-height-sizing-menu"
|
|
:reference="anchorRef(heightFieldRef)"
|
|
class="flex shrink-0 cursor-pointer items-center self-stretch border-none bg-transparent px-1 text-[11px] text-muted outline-none"
|
|
@pointerdown.stop
|
|
>
|
|
<span class="group-hover:hidden">{{ sizingShortLabel(ctx.heightSizing) }}</span>
|
|
<icon-lucide-chevron-down class="hidden size-3 group-hover:block" />
|
|
</SelectTrigger>
|
|
<SelectPortal>
|
|
<SelectContent
|
|
position="popper"
|
|
align="start"
|
|
:side-offset="4"
|
|
:class="sizingSelect.content"
|
|
>
|
|
<SelectViewport class="p-0.5">
|
|
<SelectItem
|
|
v-for="opt in ctx.heightSizingOptions"
|
|
:key="opt.value"
|
|
:value="opt.value"
|
|
:class="sizingSelect.item"
|
|
>
|
|
<SelectItemIndicator
|
|
class="absolute left-1.5 inline-flex items-center justify-center"
|
|
>
|
|
<icon-lucide-check class="size-3 text-accent" />
|
|
</SelectItemIndicator>
|
|
<SelectItemText>{{ opt.label }}</SelectItemText>
|
|
</SelectItem>
|
|
<SelectItem
|
|
v-for="item in heightLimitItems"
|
|
:key="item.prop"
|
|
:value="`${ctx.node[item.prop] == null ? 'add' : 'remove'}-${item.prop}`"
|
|
:class="sizingSelect.item"
|
|
>
|
|
<SelectItemText>
|
|
{{ ctx.node[item.prop] == null ? item.addLabel() : item.removeLabel() }}
|
|
</SelectItemText>
|
|
</SelectItem>
|
|
</SelectViewport>
|
|
</SelectContent>
|
|
</SelectPortal>
|
|
</SelectRoot>
|
|
</template>
|
|
</ScrubInput>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="
|
|
ctx.node.minWidth != null ||
|
|
ctx.node.maxWidth != null ||
|
|
ctx.node.minHeight != null ||
|
|
ctx.node.maxHeight != null
|
|
"
|
|
class="mt-1.5 grid grid-cols-2 gap-1.5"
|
|
>
|
|
<template v-for="item in activeSizeLimits" :key="item.prop">
|
|
<div
|
|
v-if="item.value() != null"
|
|
:ref="(el) => (limitFieldRefs[item.prop] = el as HTMLElement | null)"
|
|
class="min-w-0"
|
|
>
|
|
<ScrubInput
|
|
:data-test-id="item.testId"
|
|
:icon="item.icon()"
|
|
:model-value="Math.round(item.value() ?? 0)"
|
|
:min="0"
|
|
@update:model-value="ctx.updateSizeLimit(item.prop, $event)"
|
|
@commit="(v: number, p: number) => ctx.commitSizeLimit(item.prop, v, p)"
|
|
>
|
|
<template #suffix>
|
|
<SelectRoot
|
|
:model-value="'VALUE'"
|
|
@update:model-value="(value) => handleLimitSelect(item.prop, value as string)"
|
|
>
|
|
<SelectTrigger
|
|
:data-test-id="`${item.testId}-menu`"
|
|
:reference="anchorRef(limitFieldRefs[item.prop])"
|
|
class="flex shrink-0 cursor-pointer items-center self-stretch border-none bg-transparent px-1 text-[11px] text-muted outline-none"
|
|
@pointerdown.stop
|
|
>
|
|
<icon-lucide-chevron-down class="size-3" />
|
|
</SelectTrigger>
|
|
<SelectPortal>
|
|
<SelectContent
|
|
position="popper"
|
|
align="start"
|
|
:side-offset="4"
|
|
:class="sizingSelect.content"
|
|
>
|
|
<SelectViewport class="p-0.5">
|
|
<SelectItem value="CURRENT" :class="sizingSelect.item">
|
|
<SelectItemText>{{ item.setLabel() }}</SelectItemText>
|
|
</SelectItem>
|
|
<SelectItem value="REMOVE" :class="sizingSelect.item">
|
|
<SelectItemText>{{ item.removeLabel() }}</SelectItemText>
|
|
</SelectItem>
|
|
</SelectViewport>
|
|
</SelectContent>
|
|
</SelectPortal>
|
|
</SelectRoot>
|
|
</template>
|
|
</ScrubInput>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|