feat(app): migrate layout size controls

- Replace duplicated width and height binding branches with shared semantic fields

- Switch edited Hug and Fill dimensions to Fixed in one reversible transaction

- Add typed LayoutControls actions, documentation, and visual coverage
This commit is contained in:
Danila Poyarkov 2026-07-13 15:34:25 +03:00
parent 12031b76c9
commit fcdf7f1d4c
32 changed files with 617 additions and 516 deletions

View file

@ -258,6 +258,7 @@ Self-review checklist:
- `computeAllLayouts()` must be called after demo creation and after opening .fig files
- Yoga WASM handles flexbox; CSS Grid blocked on upstream (facebook/yoga#1893)
- Auto-layout creation (Shift+A) must recompute layout immediately to update selection bounds
- Editing a Hug/Fill width or height switches only that axis to Fixed on the first value mutation; focus stays non-destructive, and mode plus value changes belong to one undo transaction
## UI

View file

@ -19,6 +19,7 @@
- Add headless PropertySection, SegmentedControl, and typed PropertyList anatomy, with controlled list events and an undo-aware OpenPencil adapter.
- Refine variable-bound number fields with a quiet identity pill, one picker affordance, an accessible variable combobox, and non-destructive focus behavior.
- Redesign Position and Appearance controls with aligned panel grids, SDK-owned independent-corner state, and compact type-icon selection headers.
- Rebuild Layout size fields with shared variable binding, inline sizing modes, semantic field anatomy, and one-step Hug/Fill-to-Fixed editing.
- Upgrade Vue SDK documentation with shared Tailwind demos, source-generated component API tables, and type-aware Twoslash examples in VitePress.
- Add desktop image drag-and-drop into the Tauri app window.
- Add open-document discovery for live CLI and MCP automation so agents can target the intended document and page.

View file

@ -31,8 +31,9 @@ const {
isFlex,
widthSizing,
heightSizing,
setWidthSizing,
setHeightSizing,
setAxisSizing,
updateAxisSize,
commitAxisSize,
} = useLayout()
```

View file

@ -31,8 +31,9 @@ const {
isFlex,
widthSizing,
heightSizing,
setWidthSizing,
setHeightSizing,
setAxisSizing,
updateAxisSize,
commitAxisSize,
} = useLayout()
```

View file

@ -31,8 +31,9 @@ const {
isFlex,
widthSizing,
heightSizing,
setWidthSizing,
setHeightSizing,
setAxisSizing,
updateAxisSize,
commitAxisSize,
} = useLayout()
```

View file

@ -31,8 +31,9 @@ const {
isFlex,
widthSizing,
heightSizing,
setWidthSizing,
setHeightSizing,
setAxisSizing,
updateAxisSize,
commitAxisSize,
} = useLayout()
```

View file

@ -31,8 +31,9 @@ const {
isFlex,
widthSizing,
heightSizing,
setWidthSizing,
setHeightSizing,
setAxisSizing,
updateAxisSize,
commitAxisSize,
} = useLayout()
```

View file

@ -0,0 +1,5 @@
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
export default defineComponentMetaLoader([
'packages/vue/src/primitives/LayoutControls/LayoutControlsRoot.vue'
])

View file

@ -3,13 +3,32 @@ title: LayoutControlsRoot
description: Headless root primitive for auto-layout and sizing controls.
---
<script setup lang="ts">
import { data } from './layout-controls-root.data'
</script>
# LayoutControlsRoot
`LayoutControlsRoot` exposes the slot contract returned by `useLayout()` as a structural primitive.
Use it when you want a reusable layout-controls shell with app-owned markup.
Width and height fields can remain editable while their axis uses Hug or Fill. On the first actual
numeric mutation, `updateAxisSize()` records the sizing transition and changes that axis to Fixed.
Compose the field with `BindableValue` and an interaction-batch-capable provider when sizing mode,
variable detachment, and the numeric value must commit or roll back as one undo step. Focus and
picker opening do not change sizing mode.
Use `setAxisSizing('width', mode)` or `setAxisSizing('height', mode)` for sizing menus. The older
axis-specific setters are not part of the current contract.
## Generated API reference
The following tables are extracted from the Vue source and JSDoc during the documentation build.
<SdkComponentAPI :components="data.components" />
## Related APIs
- [useLayout](../composables/use-layout)
- [BindableValue](./bindable-value)
- [Property Panels guide](../../guides/property-panels)

View file

@ -10,7 +10,8 @@ description: Work with auto-layout, sizing, padding, alignment, and grid tracks.
It exposes state and actions for:
- flex vs grid mode
- width/height sizing
- width/height sizing and editable derived dimensions
- minimum and maximum size limits
- padding
- alignment
- grid template track editing
@ -23,17 +24,42 @@ import { useLayout } from '@open-pencil/vue'
const layout = useLayout()
```
## Basic example
## Axis sizing
```ts
const {
isGrid,
isFlex,
widthSizing,
heightSizing,
setWidthSizing,
setHeightSizing,
setAxisSizing,
updateAxisSize,
commitAxisSize,
} = useLayout()
setAxisSizing('width', 'HUG')
setAxisSizing('height', 'FILL')
```
Connect `updateAxisSize()` and `commitAxisSize()` to a numeric field. Editing a Hug or Fill value
switches only that axis to Fixed on the first actual mutation:
```vue
<NumberFieldRoot
:model-value="layout.node.value?.width ?? 0"
@update:model-value="layout.updateAxisSize('width', $event)"
@commit="(value, previous) => layout.commitAxisSize('width', value, previous)"
/>
```
For one-step commit and Escape rollback across a sizing-mode change, variable detachment, and the
numeric value, compose the NumberField with `BindableValue` using a provider that implements
interaction batches. Merely focusing the field does not switch its sizing mode.
## Size limits
```ts
layout.addSizeLimit('minWidth')
layout.setSizeLimitToCurrent('minWidth')
layout.removeSizeLimit('minWidth')
```
## Practical examples
@ -59,5 +85,7 @@ layout.setAlignment('CENTER', 'MAX')
## Related APIs
- [LayoutControlsRoot](../components/layout-controls-root)
- [BindableValue](../components/bindable-value)
- [usePosition](./use-position)
- [useEditor](./use-editor)

View file

@ -31,8 +31,9 @@ const {
isFlex,
widthSizing,
heightSizing,
setWidthSizing,
setHeightSizing,
setAxisSizing,
updateAxisSize,
commitAxisSize,
} = useLayout()
```

View file

@ -90,14 +90,17 @@ Main structural primitives include:
- `FontPickerRoot`
- `NumberFieldRoot` / `NumberFieldInput` / `NumberFieldValue`
- `BindableValueRoot` / `BindableValueTrigger` / `BindableValuePicker`
- `LayoutControlsRoot`
These components coordinate structure and state, but do not impose app styling. `NumberField`
adds pointer scrubbing, Arrow-key stepping, mixed/bound state attributes, and safe arithmetic
expressions such as `+10`, `*2`, `50%`, and `12*8+4`. `BindableValue` composes fields with a
generic `BindingProvider` and supports detach-on-edit, read-only, and edit-variable policies.
Focusing a bound NumberField is non-destructive; the configured policy begins only on the first
value mutation. `AppearanceControlsRoot` exposes selection-derived independent-corner presentation
state so consumers do not need parallel expansion heuristics. `PropertyListRoot` is controlled and
value mutation. `LayoutControlsRoot` exposes axis-oriented sizing actions; editing a Hug or Fill
dimension can switch that axis to Fixed inside the same provider transaction. `AppearanceControlsRoot`
exposes selection-derived independent-corner presentation state so consumers do not need parallel
expansion heuristics. `PropertyListRoot` is controlled and
editor-agnostic; OpenPencil panels connect it to selection and undo through
`useEditorPropertyList()`.

View file

@ -23,6 +23,7 @@ type LayoutPanelStrings = {
sizingFill: string
}
export type LayoutAxis = 'width' | 'height'
export type SizeLimitProp = 'minWidth' | 'maxWidth' | 'minHeight' | 'maxHeight'
type ValueRef<T> = { readonly value: T }
@ -174,15 +175,42 @@ export function createPaddingActions(editor: Editor, node: ComputedRef<SceneNode
}
}
export function axisSizingPatchForNode(
node: SceneNode,
axis: LayoutAxis,
sizing: LayoutSizing,
isInAutoLayout: boolean
): Partial<SceneNode> {
const patch: Partial<SceneNode> = {}
const isFlex = node.layoutMode === 'HORIZONTAL' || node.layoutMode === 'VERTICAL'
if (isFlex) {
const primary =
(axis === 'width' && node.layoutMode === 'HORIZONTAL') ||
(axis === 'height' && node.layoutMode === 'VERTICAL')
patch[primary ? 'primaryAxisSizing' : 'counterAxisSizing'] = sizing
} else if (sizing === 'HUG' && node.childIds.length > 0) {
patch[axis === 'width' ? 'counterAxisSizing' : 'primaryAxisSizing'] = 'HUG'
if (isInAutoLayout) {
if (axis === 'width') patch.layoutGrow = 0
else patch.layoutAlignSelf = 'AUTO'
}
} else if (axis === 'width') {
if (node.counterAxisSizing === 'HUG') patch.counterAxisSizing = 'FIXED'
if (isInAutoLayout) patch.layoutGrow = sizing === 'FILL' ? 1 : 0
} else {
if (node.primaryAxisSizing === 'HUG') patch.primaryAxisSizing = 'FIXED'
if (isInAutoLayout) patch.layoutAlignSelf = sizing === 'FILL' ? 'STRETCH' : 'AUTO'
}
return patch
}
export function createLayoutActions({
editor,
node,
isFlex,
isInAutoLayout
}: {
editor: Editor
node: ComputedRef<SceneNode | null>
isFlex: ComputedRef<boolean>
isInAutoLayout: ComputedRef<boolean>
}) {
function updateProp(key: string, value: number | string) {
@ -228,33 +256,30 @@ export function createLayoutActions({
}
}
function setWidthSizing(sizing: LayoutSizing) {
function setAxisSizing(axis: LayoutAxis, sizing: LayoutSizing) {
const n = node.value
if (!n) return
if (isFlex.value) {
const key = n.layoutMode === 'HORIZONTAL' ? 'primaryAxisSizing' : 'counterAxisSizing'
updateProp(key, sizing)
} else if (sizing === 'HUG' && n.childIds.length > 0) {
updateProp('counterAxisSizing', 'HUG')
} else {
if (n.counterAxisSizing === 'HUG') updateProp('counterAxisSizing', 'FIXED')
if (isInAutoLayout.value) updateProp('layoutGrow', sizing === 'FILL' ? 1 : 0)
}
editor.updateNodeWithUndo(
n.id,
axisSizingPatchForNode(n, axis, sizing, isInAutoLayout.value),
`Set ${axis} sizing`
)
}
function setHeightSizing(sizing: LayoutSizing) {
function updateAxisSize(axis: LayoutAxis, value: number) {
const n = node.value
if (!n) return
if (isFlex.value) {
const key = n.layoutMode === 'VERTICAL' ? 'primaryAxisSizing' : 'counterAxisSizing'
updateProp(key, sizing)
} else if (sizing === 'HUG' && n.childIds.length > 0) {
updateProp('primaryAxisSizing', 'HUG')
} else {
if (n.primaryAxisSizing === 'HUG') updateProp('primaryAxisSizing', 'FIXED')
if (isInAutoLayout.value)
updateProp('layoutAlignSelf', sizing === 'FILL' ? 'STRETCH' : 'AUTO')
}
const sizing =
axis === 'width'
? widthSizingForNode(n, isInAutoLayout.value)
: heightSizingForNode(n, isInAutoLayout.value)
if (sizing !== 'FIXED') setAxisSizing(axis, 'FIXED')
editor.updateNode(n.id, { [axis]: value })
}
function commitAxisSize(axis: LayoutAxis, _value: number, previous: number) {
const n = node.value
if (n) editor.commitNodeUpdate(n.id, { [axis]: previous }, `Change ${axis}`)
}
function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
@ -293,8 +318,9 @@ export function createLayoutActions({
addSizeLimit,
removeSizeLimit,
commitProp,
setWidthSizing,
setHeightSizing,
setAxisSizing,
updateAxisSize,
commitAxisSize,
setAlignment,
setGapAuto,
setLayoutDirection

View file

@ -44,7 +44,7 @@ export function useLayout() {
toggleIndividualPadding
} = createPaddingActions(editor, node)
const layoutActions = createLayoutActions({ editor, node, isFlex, isInAutoLayout })
const layoutActions = createLayoutActions({ editor, node, isInAutoLayout })
const { updateGridTrack, addTrack, removeTrack } = createGridTrackActions(editor, node)
@ -72,8 +72,9 @@ export function useLayout() {
addSizeLimit: layoutActions.addSizeLimit,
removeSizeLimit: layoutActions.removeSizeLimit,
commitProp: layoutActions.commitProp,
setWidthSizing: layoutActions.setWidthSizing,
setHeightSizing: layoutActions.setHeightSizing,
setAxisSizing: layoutActions.setAxisSizing,
updateAxisSize: layoutActions.updateAxisSize,
commitAxisSize: layoutActions.commitAxisSize,
setHorizontalPadding,
commitHorizontalPadding,
setVerticalPadding,

View file

@ -73,7 +73,7 @@ export type { TestId } from '#vue/testing/test-id'
/** Property-panel composables. */
export { usePosition } from '#vue/controls/position/use'
export { useLayout } from '#vue/controls/layout/use'
export type { SizeLimitProp } from '#vue/controls/layout/helpers'
export type { LayoutAxis, SizeLimitProp } from '#vue/controls/layout/helpers'
export { useAppearance } from '#vue/controls/appearance/use'
export { useMask } from '#vue/controls/mask/use'
export { useTypography } from '#vue/controls/typography/use'
@ -137,7 +137,11 @@ export {
export { LayerTreeRoot, LayerTreeItem, useLayerTree } from '#vue/primitives/LayerTree'
export type { LayerDragInstruction, LayerTreeContext, LayerNode } from '#vue/primitives/LayerTree'
export { LayoutControlsRoot, useLayoutControlsContext } from '#vue/primitives/LayoutControls'
export type { LayoutControlsContext } from '#vue/primitives/LayoutControls'
export type {
LayoutControlsContext,
LayoutControlsRootSlotProps,
LayoutControlsRootSlots
} from '#vue/primitives/LayoutControls'
export { AppearanceControlsRoot } from '#vue/primitives/AppearanceControls'
export type {
AppearanceControlsActions,

View file

@ -129,10 +129,11 @@ function snapshotBindings() {
function beginMutation(source: BindingMutationSource): boolean {
if (interactionActive) return true
if (state.value === 'unbound') return true
const startedUnbound = state.value === 'unbound'
const startedMixed = state.value === 'mixed'
if (!startedMixed && policy.value === 'readonly-when-bound') return false
if (!startedUnbound && !startedMixed && policy.value === 'readonly-when-bound') return false
if (
!startedUnbound &&
!startedMixed &&
policy.value === 'edit-variable' &&
(!variable.value || !provider.setValue)
@ -142,11 +143,11 @@ function beginMutation(source: BindingMutationSource): boolean {
interactionActive = true
void source
snapshotBindings()
if (!startedUnbound) snapshotBindings()
resolvedSnapshot = resolvedValue.value
if (supportsInteractionBatch) beginProviderBatch(batchLabel)
if (startedMixed || policy.value === 'detach-on-edit') {
if (startedMixed || (!startedUnbound && policy.value === 'detach-on-edit')) {
detachedForInteraction = true
for (const target of targets.value) provider.unbind(target)
}

View file

@ -17,5 +17,5 @@ export function useBindableValue<V>(): BindableValueContext<V> {
}
export function useOptionalBindableValue<V>(): BindableValueContext<V> | undefined {
return inject(BINDABLE_VALUE_KEY) as BindableValueContext<V> | undefined
return inject(BINDABLE_VALUE_KEY, undefined) as BindableValueContext<V> | undefined
}

View file

@ -1,9 +1,12 @@
<script setup lang="ts">
import { useLayout } from '#vue/controls/layout/use'
import { provideLayoutControls } from '#vue/primitives/LayoutControls/context'
import { proxyRefs } from 'vue'
import { useLayout } from '#vue/controls/layout/use'
import { provideLayoutControls } from '#vue/primitives/LayoutControls/context'
import type { LayoutControlsRootSlots } from '#vue/primitives/LayoutControls/types'
const ctx = useLayout()
defineSlots<LayoutControlsRootSlots>()
const actions = {
updateProp: ctx.updateProp,
updateSizeLimit: ctx.updateSizeLimit,
@ -12,8 +15,9 @@ const actions = {
addSizeLimit: ctx.addSizeLimit,
removeSizeLimit: ctx.removeSizeLimit,
commitProp: ctx.commitProp,
setWidthSizing: ctx.setWidthSizing,
setHeightSizing: ctx.setHeightSizing,
setAxisSizing: ctx.setAxisSizing,
updateAxisSize: ctx.updateAxisSize,
commitAxisSize: ctx.commitAxisSize,
setHorizontalPadding: ctx.setHorizontalPadding,
commitHorizontalPadding: ctx.commitHorizontalPadding,
setVerticalPadding: ctx.setVerticalPadding,

View file

@ -1,3 +1,7 @@
export { default as LayoutControlsRoot } from '#vue/primitives/LayoutControls/LayoutControlsRoot.vue'
export { useLayoutControlsContext } from '#vue/primitives/LayoutControls/context'
export type { LayoutControlsContext } from '#vue/primitives/LayoutControls/context'
export type {
LayoutControlsRootSlotProps,
LayoutControlsRootSlots
} from '#vue/primitives/LayoutControls/types'

View file

@ -0,0 +1,36 @@
import type { ShallowUnwrapRef, VNode } from 'vue'
import type { useLayout } from '#vue/controls/layout/use'
type LayoutContext = ShallowUnwrapRef<ReturnType<typeof useLayout>>
type LayoutActionKey =
| 'updateProp'
| 'updateSizeLimit'
| 'setSizeLimitToCurrent'
| 'commitSizeLimit'
| 'addSizeLimit'
| 'removeSizeLimit'
| 'commitProp'
| 'setAxisSizing'
| 'updateAxisSize'
| 'commitAxisSize'
| 'setHorizontalPadding'
| 'commitHorizontalPadding'
| 'setVerticalPadding'
| 'commitVerticalPadding'
| 'setAlignment'
| 'setGapAuto'
| 'setLayoutDirection'
| 'updateGridTrack'
| 'addTrack'
| 'removeTrack'
| 'toggleIndividualPadding'
export type LayoutControlsRootSlotProps = Omit<LayoutContext, LayoutActionKey> & {
actions: Pick<LayoutContext, LayoutActionKey>
}
export interface LayoutControlsRootSlots {
/** Current layout state and mutation actions for the active selection. */
default(props: LayoutControlsRootSlotProps): VNode[]
}

View file

@ -6,7 +6,7 @@ import ClipContentControl from '@/components/properties/LayoutSection/ClipConten
import FlexControls from '@/components/properties/LayoutSection/FlexControls.vue'
import GridControls from '@/components/properties/LayoutSection/GridControls.vue'
import PaddingControls from '@/components/properties/LayoutSection/PaddingControls.vue'
import SizeControls from '@/components/properties/LayoutSection/SizeControls.vue'
import SizeControls from '@/components/properties/LayoutSection/size/SizeControls.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
const { panels } = useI18n()
@ -17,7 +17,7 @@ const CONTAINER_TYPES = ['FRAME', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE']
<template>
<LayoutControlsRoot v-slot="ctx">
<template v-if="ctx.node">
<PanelSection :label="panels.layout" data-test-id="layout-section">
<PanelSection :label="panels.layout">
<SizeControls />
</PanelSection>

View file

@ -1,438 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useTemplateRefsList } from '@vueuse/core'
import {
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectViewport
} from 'reka-ui'
import NumberField from '@/components/inputs/NumberField.vue'
import VariableNumberField from '@/components/properties/VariableNumberField.vue'
import BoundVariableButton from '@/components/properties/BoundVariableButton.vue'
import VariablePickerPopover from '@/components/properties/VariablePickerPopover.vue'
import { useSelectUI } from '@/components/ui/select'
import {
vTestId,
useI18n,
useLayoutControlsContext,
useNumberVariableBinding
} from '@open-pencil/vue'
import type { LayoutSizing } from '@open-pencil/scene-graph'
import type { SizeLimitProp, TestId } from '@open-pencil/vue'
type SizeSelectValue = LayoutSizing | `add-${SizeLimitProp}` | `remove-${SizeLimitProp}`
type ActiveSizeLimit = {
prop: SizeLimitProp
testHook: TestId
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 = useTemplateRefsList<HTMLElement>()
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',
testHook: 'layout-min-width-input',
icon: () => panels.value.minWidthShort,
value: () => ctx.node.minWidth,
setLabel: () => panels.value.setToCurrentWidth,
removeLabel: () => panels.value.removeMinWidth
},
{
prop: 'maxWidth',
testHook: 'layout-max-width-input',
icon: () => panels.value.maxWidthShort,
value: () => ctx.node.maxWidth,
setLabel: () => panels.value.setToCurrentWidth,
removeLabel: () => panels.value.removeMaxWidth
},
{
prop: 'minHeight',
testHook: 'layout-min-height-input',
icon: () => panels.value.minHeightShort,
value: () => ctx.node.minHeight,
setLabel: () => panels.value.setToCurrentHeight,
removeLabel: () => panels.value.removeMinHeight
},
{
prop: 'maxHeight',
testHook: 'layout-max-height-input',
icon: () => panels.value.maxHeightShort,
value: () => ctx.node.maxHeight,
setLabel: () => panels.value.setToCurrentHeight,
removeLabel: () => panels.value.removeMaxHeight
}
]
const visibleSizeLimits = computed(() => activeSizeLimits.filter((item) => item.value() != null))
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 anchorRef(element: HTMLElement | null): HTMLElement | undefined {
return element ?? undefined
}
function limitFieldAnchor(index: number): HTMLElement | undefined {
return anchorRef(limitFieldRefs.value[index] ?? null)
}
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">
<Tip :label="panels.width">
<NumberField
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)"
data-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"
data-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"
@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 justify-center self-stretch border-none bg-transparent px-1.5 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
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>
</NumberField>
</Tip>
</div>
<div ref="heightFieldRef" class="min-w-0 flex-1">
<Tip :label="panels.height">
<NumberField
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)"
data-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"
data-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"
@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 justify-center self-stretch border-none bg-transparent px-1.5 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
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>
</NumberField>
</Tip>
</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, index) in visibleSizeLimits" :key="item.prop">
<div :ref="limitFieldRefs.set" class="min-w-0">
<VariableNumberField
v-if="ctx.node"
v-test-id="item.testHook"
:icon="item.icon()"
:model-value="Math.round(item.value() ?? 0)"
:min="0"
:node-id="ctx.node.id"
:binding-path="item.prop"
@update:model-value="ctx.updateSizeLimit(item.prop, $event)"
@commit="(v: number, p: number) => ctx.commitSizeLimit(item.prop, v, p)"
>
<template #after-variable>
<SelectRoot
:model-value="'VALUE'"
@update:model-value="(value) => handleLimitSelect(item.prop, value as string)"
>
<SelectTrigger
v-test-id="`${item.testHook}-menu`"
:reference="limitFieldAnchor(index)"
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>
</VariableNumberField>
<NumberField
v-else
v-test-id="item.testHook"
: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
v-test-id="`${item.testHook}-menu`"
:reference="limitFieldAnchor(index)"
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>
</NumberField>
</div>
</template>
</div>
</template>

View file

@ -0,0 +1,140 @@
<script setup lang="ts">
import {
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectViewport
} from 'reka-ui'
import { useI18n, useLayoutControlsContext } from '@open-pencil/vue'
import VariableNumberField from '@/components/properties/VariableNumberField.vue'
import { useSelectUI } from '@/components/ui/select'
import Tip from '@/components/ui/Tip.vue'
import type { LayoutSizing } from '@open-pencil/scene-graph'
import type { SizeAxisFieldProps } from '@/components/properties/LayoutSection/size/types'
import type { SizeLimitProp } from '@open-pencil/vue'
type SizeSelectValue = LayoutSizing | `add-${SizeLimitProp}` | `remove-${SizeLimitProp}`
const { axis, icon, label } = defineProps<SizeAxisFieldProps>()
const ctx = useLayoutControlsContext()
const { panels } = useI18n()
const selectUI = useSelectUI({ item: 'rounded py-1.5 pr-2 pl-6 text-xs' })
const sizing = () => (axis === 'width' ? ctx.widthSizing : ctx.heightSizing)
const sizingOptions = () => (axis === 'width' ? ctx.widthSizingOptions : ctx.heightSizingOptions)
const sizingLabel = () => {
if (sizing() === 'HUG') return panels.value.sizingHugShort
if (sizing() === 'FILL') return panels.value.sizingFillShort
return ''
}
const limitItems = () =>
axis === 'width'
? [
{
prop: 'minWidth' as const,
addLabel: panels.value.addMinWidth,
removeLabel: panels.value.removeMinWidth
},
{
prop: 'maxWidth' as const,
addLabel: panels.value.addMaxWidth,
removeLabel: panels.value.removeMaxWidth
}
]
: [
{
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 handleSelect(value: SizeSelectValue) {
if (value === 'FIXED' || value === 'HUG' || value === 'FILL') {
ctx.setAxisSizing(axis, value)
return
}
const [action, prop] = value.split('-') as ['add' | 'remove', SizeLimitProp]
if (action === 'add') ctx.addSizeLimit(prop)
else ctx.removeSizeLimit(prop)
}
</script>
<template>
<Tip :label="label">
<VariableNumberField
:icon="icon"
:aria-label="label"
:model-value="Math.round(ctx.node[axis])"
:min="0"
:node-id="ctx.node.id"
:binding-path="axis"
@update:model-value="ctx.updateAxisSize(axis, $event)"
@commit="(value: number, previous: number) => ctx.commitAxisSize(axis, value, previous)"
>
<template #after-variable>
<SelectRoot
:model-value="sizing()"
@update:model-value="handleSelect($event as SizeSelectValue)"
>
<SelectTrigger
data-slot="sizing-trigger"
:aria-label="label"
class="flex shrink-0 cursor-pointer items-center gap-0.5 self-stretch border-none bg-transparent px-1.5 text-[10px] text-muted outline-none data-[state=open]:text-foreground"
@pointerdown.stop
>
<span v-if="sizingLabel()">{{ sizingLabel() }}</span>
<icon-lucide-chevron-down class="size-3" />
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
align="start"
:side-offset="4"
:class="selectUI.content"
>
<SelectViewport class="p-0.5">
<SelectItem
v-for="option in sizingOptions()"
:key="option.value"
:value="option.value"
:class="selectUI.item"
>
<SelectItemIndicator
class="absolute left-1.5 inline-flex items-center justify-center"
>
<icon-lucide-check class="size-3 text-accent" />
</SelectItemIndicator>
<SelectItemText>{{ option.label }}</SelectItemText>
</SelectItem>
<SelectItem
v-for="item in limitItems()"
:key="item.prop"
:value="`${ctx.node[item.prop] == null ? 'add' : 'remove'}-${item.prop}`"
:class="selectUI.item"
>
<SelectItemText>
{{ ctx.node[item.prop] == null ? item.addLabel : item.removeLabel }}
</SelectItemText>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
</VariableNumberField>
</Tip>
</template>

View file

@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n, useLayoutControlsContext } from '@open-pencil/vue'
import SizeAxisField from '@/components/properties/LayoutSection/size/SizeAxisField.vue'
import SizeLimitField from '@/components/properties/LayoutSection/size/SizeLimitField.vue'
import PanelGrid from '@/components/ui/panel/PanelGrid.vue'
import type { SizeLimitItem } from '@/components/properties/LayoutSection/size/types'
const ctx = useLayoutControlsContext()
const { panels } = useI18n()
const sizeLimits = computed<SizeLimitItem[]>(() => [
{
prop: 'minWidth',
icon: panels.value.minWidthShort,
label: panels.value.minWidthShort,
setLabel: panels.value.setToCurrentWidth,
removeLabel: panels.value.removeMinWidth
},
{
prop: 'maxWidth',
icon: panels.value.maxWidthShort,
label: panels.value.maxWidthShort,
setLabel: panels.value.setToCurrentWidth,
removeLabel: panels.value.removeMaxWidth
},
{
prop: 'minHeight',
icon: panels.value.minHeightShort,
label: panels.value.minHeightShort,
setLabel: panels.value.setToCurrentHeight,
removeLabel: panels.value.removeMinHeight
},
{
prop: 'maxHeight',
icon: panels.value.maxHeightShort,
label: panels.value.maxHeightShort,
setLabel: panels.value.setToCurrentHeight,
removeLabel: panels.value.removeMaxHeight
}
])
const visibleSizeLimits = computed(() =>
sizeLimits.value.filter((item) => ctx.node[item.prop] != null)
)
</script>
<template>
<PanelGrid columns="two">
<SizeAxisField axis="width" icon="W" :label="panels.width" />
<SizeAxisField axis="height" icon="H" :label="panels.height" />
</PanelGrid>
<PanelGrid v-if="visibleSizeLimits.length" columns="two" class="mt-panel">
<SizeLimitField v-for="item in visibleSizeLimits" :key="item.prop" :item="item" />
</PanelGrid>
</template>

View file

@ -0,0 +1,73 @@
<script setup lang="ts">
import {
SelectContent,
SelectItem,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectViewport
} from 'reka-ui'
import { useLayoutControlsContext } from '@open-pencil/vue'
import VariableNumberField from '@/components/properties/VariableNumberField.vue'
import { useSelectUI } from '@/components/ui/select'
import Tip from '@/components/ui/Tip.vue'
import type { SizeLimitFieldProps } from '@/components/properties/LayoutSection/size/types'
const { item } = defineProps<SizeLimitFieldProps>()
const ctx = useLayoutControlsContext()
const selectUI = useSelectUI({ item: 'rounded py-1.5 px-2 text-xs' })
function handleSelect(value: string) {
if (value === 'CURRENT') ctx.setSizeLimitToCurrent(item.prop)
else if (value === 'REMOVE') ctx.removeSizeLimit(item.prop)
}
</script>
<template>
<Tip :label="item.label">
<VariableNumberField
:icon="item.icon"
:aria-label="item.label"
:model-value="Math.round(ctx.node[item.prop] ?? 0)"
:min="0"
:node-id="ctx.node.id"
:binding-path="item.prop"
@update:model-value="ctx.updateSizeLimit(item.prop, $event)"
@commit="(value: number, previous: number) => ctx.commitSizeLimit(item.prop, value, previous)"
>
<template #after-variable>
<SelectRoot :model-value="'VALUE'" @update:model-value="handleSelect($event as string)">
<SelectTrigger
data-slot="limit-trigger"
:aria-label="item.label"
class="flex shrink-0 cursor-pointer items-center self-stretch border-none bg-transparent px-1 text-muted outline-none data-[state=open]:text-foreground"
@pointerdown.stop
>
<icon-lucide-chevron-down class="size-3" />
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
align="start"
:side-offset="4"
:class="selectUI.content"
>
<SelectViewport class="p-0.5">
<SelectItem value="CURRENT" :class="selectUI.item">
<SelectItemText>{{ item.setLabel }}</SelectItemText>
</SelectItem>
<SelectItem value="REMOVE" :class="selectUI.item">
<SelectItemText>{{ item.removeLabel }}</SelectItemText>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
</VariableNumberField>
</Tip>
</template>

View file

@ -0,0 +1,19 @@
import type { LayoutAxis, SizeLimitProp } from '@open-pencil/vue'
export interface SizeAxisFieldProps {
axis: LayoutAxis
icon: string
label: string
}
export interface SizeLimitItem {
prop: SizeLimitProp
icon: string
label: string
setLabel: string
removeLabel: string
}
export interface SizeLimitFieldProps {
item: SizeLimitItem
}

View file

@ -2,6 +2,7 @@ import { test, expect, type Page } from '@playwright/test'
import { expectDefined } from '#tests/helpers/assert'
import { CanvasHelper } from '#tests/helpers/canvas'
import { propertyField, propertySection } from '#tests/helpers/properties'
import { getSelectedNode, getNodeById } from '#tests/helpers/store'
let page: Page
@ -49,6 +50,7 @@ test('Shift+A wraps selection in auto-layout frame', async () => {
expect(expectDefined(node, 'node').childIds.length).toBe(2)
frameId = expectDefined(node, 'node').id
await expect(propertySection(page, 'Layout')).toHaveScreenshot('layout-size-controls.png')
canvas.assertNoErrors()
})
@ -143,23 +145,95 @@ test('padding controls set horizontal and vertical padding pairs', async () => {
test('size dropdown adds and removes min width', async () => {
await selectFrame()
await page.getByTestId('layout-width-sizing-menu').click()
await page.getByText('Add min width').click()
const layout = propertySection(page, 'Layout')
await propertyField(page, 'width').getByRole('combobox', { name: 'Width' }).click()
await page.getByRole('option', { name: 'Add min width' }).click()
await canvas.waitForRender()
let frame = await getNodeById(page, frameId)
expect(expectDefined(frame, 'frame').minWidth).toBe(
Math.round(expectDefined(frame, 'frame').width)
)
await expect(page.getByTestId('layout-min-width-input')).toBeVisible()
await expect(layout.locator('[data-property="minWidth"]')).toBeVisible()
await page.getByTestId('layout-width-sizing-menu').click()
await page.getByText('Remove min width').click()
await propertyField(page, 'width').getByRole('combobox', { name: 'Width' }).click()
await page.getByRole('option', { name: 'Remove min width' }).click()
await canvas.waitForRender()
frame = await getNodeById(page, frameId)
expect(expectDefined(frame, 'frame').minWidth).toBeNull()
await expect(page.getByTestId('layout-min-width-input')).toHaveCount(0)
await expect(layout.locator('[data-property="minWidth"]')).toHaveCount(0)
canvas.assertNoErrors()
})
test('editing a Hug width switches to Fixed in one reversible interaction', async () => {
await selectFrame()
const widthField = propertyField(page, 'width')
await widthField.getByRole('combobox', { name: 'Width' }).click()
await page.getByRole('option', { name: 'Hug' }).click()
await canvas.waitForRender()
const before = expectDefined(await getNodeById(page, frameId), 'before')
expect(before.counterAxisSizing).toBe('HUG')
await widthField.focus()
await widthField.getByRole('spinbutton').press('Escape')
const afterFocus = expectDefined(await getNodeById(page, frameId), 'after focus')
expect(afterFocus.counterAxisSizing).toBe('HUG')
await widthField.focus()
const input = widthField.getByRole('spinbutton')
await input.fill(String(Math.round(before.width + 40)))
await input.press('Escape')
await canvas.waitForRender()
const cancelled = expectDefined(await getNodeById(page, frameId), 'cancelled')
expect(cancelled.counterAxisSizing).toBe('HUG')
expect(cancelled.width).toBeCloseTo(before.width)
await widthField.focus()
await widthField.getByRole('spinbutton').fill(String(Math.round(before.width + 40)))
await widthField.getByRole('spinbutton').press('Enter')
await canvas.waitForRender()
const changed = expectDefined(await getNodeById(page, frameId), 'changed')
expect(changed.counterAxisSizing).toBe('FIXED')
expect(changed.width).toBe(Math.round(before.width + 40))
await canvas.pressKey('Meta+z')
await canvas.waitForRender()
const undone = expectDefined(await getNodeById(page, frameId), 'undone')
expect(undone.counterAxisSizing).toBe('HUG')
expect(undone.width).toBeCloseTo(before.width)
canvas.assertNoErrors()
})
test('editing a Fill height switches to Fixed in one undo step', async () => {
await selectFrame()
const heightField = propertyField(page, 'height')
await heightField.getByRole('combobox', { name: 'Height' }).click()
await page.getByRole('option', { name: 'Fill' }).click()
await canvas.waitForRender()
const before = expectDefined(await getNodeById(page, frameId), 'before')
expect(before.primaryAxisSizing).toBe('FILL')
await heightField.focus()
await heightField.getByRole('spinbutton').fill(String(Math.round(before.height + 30)))
await heightField.getByRole('spinbutton').press('Enter')
await canvas.waitForRender()
const changed = expectDefined(await getNodeById(page, frameId), 'changed')
expect(changed.primaryAxisSizing).toBe('FIXED')
expect(changed.height).toBe(Math.round(before.height + 30))
await canvas.pressKey('Meta+z')
await canvas.waitForRender()
const undone = expectDefined(await getNodeById(page, frameId), 'undone')
expect(undone.primaryAxisSizing).toBe('FILL')
expect(undone.height).toBeCloseTo(before.height)
canvas.assertNoErrors()
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -180,16 +180,15 @@ test('fill color can create and bind a variable', async () => {
test('width can create, bind, and detach a number variable', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
await editor.page.getByTestId('layout-height-input').click()
const widthField = propertyField(editor.page, 'width')
await editor.page.getByTestId('layout-width-apply-variable').click()
await expect(editor.page.getByText('Create number variable from 80')).toBeVisible()
await editor.page.getByTestId('layout-width-apply-variable-create').click()
await widthField.getByLabel('Apply variable').click()
await editor.page.getByText('Create number variable from 80').click()
await editor.page.getByPlaceholder('Variable name').fill('Card/width')
await editor.page.getByTestId('layout-width-apply-variable-create').click()
await editor.page.getByRole('button', { name: 'Create', exact: true }).click()
await editor.canvas.waitForRender()
await expect(editor.page.getByTestId('layout-width-unbind-variable')).toBeVisible()
await expect(widthField.getByText('Card/width')).toBeVisible()
const boundVariable = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
@ -201,14 +200,13 @@ test('width can create, bind, and detach a number variable', async () => {
})
expect(boundVariable).toBe('Card/width')
const widthField = editor.page.getByTestId('layout-width-input')
await widthField.click()
await widthField.focus()
const widthInput = widthField.getByRole('spinbutton')
await widthInput.fill('120')
await widthInput.press('Enter')
await editor.canvas.waitForRender()
await expect(editor.page.getByTestId('layout-width-unbind-variable')).toBeHidden()
await expect(widthField.getByText('Card/width')).toHaveCount(0)
const directWidth = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')

View file

@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test'
import type { SceneNode } from '@open-pencil/scene-graph'
import {
axisSizingPatchForNode,
widthSizingForNode,
heightSizingForNode,
sizingOptionsForNode
@ -52,4 +53,36 @@ describe('layout sizing controls', () => {
expect(sizingOptionsForNode(frame, false).map((option) => option.value)).not.toContain('HUG')
})
test('editing derived flex dimensions can switch only that axis to fixed', () => {
const frame = node({
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FILL',
counterAxisSizing: 'HUG'
})
expect(axisSizingPatchForNode(frame, 'width', 'FIXED', false)).toEqual({
counterAxisSizing: 'FIXED'
})
expect(axisSizingPatchForNode(frame, 'height', 'FIXED', false)).toEqual({
primaryAxisSizing: 'FIXED'
})
})
test('switching an auto-layout child from fill to hug clears fill mechanics', () => {
const frame = node({
childIds: ['child'],
layoutGrow: 1,
layoutAlignSelf: 'STRETCH'
})
expect(axisSizingPatchForNode(frame, 'width', 'HUG', true)).toEqual({
counterAxisSizing: 'HUG',
layoutGrow: 0
})
expect(axisSizingPatchForNode(frame, 'height', 'HUG', true)).toEqual({
primaryAxisSizing: 'HUG',
layoutAlignSelf: 'AUTO'
})
})
})

View file

@ -1,7 +1,7 @@
import type { Locator, Page } from '@playwright/test'
export function propertySection(page: Page, name: string): Locator {
return page.getByRole('region', { name })
return page.getByRole('region', { name, exact: true })
}
export function propertyField(page: Page, property: string): Locator {

View file

@ -101,6 +101,10 @@ export function getNodeById(page: Page, id: string) {
height: n.height,
rotation: n.rotation,
layoutMode: n.layoutMode,
primaryAxisSizing: n.primaryAxisSizing,
counterAxisSizing: n.counterAxisSizing,
layoutGrow: n.layoutGrow,
layoutAlignSelf: n.layoutAlignSelf,
primaryAxisAlign: n.primaryAxisAlign,
counterAxisAlign: n.counterAxisAlign,
itemSpacing: n.itemSpacing,