From fcdf7f1d4cc8f8d2acb10fd768be3cde84478f72 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 15:34:25 +0300 Subject: [PATCH] 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 --- AGENTS.md | 1 + CHANGELOG.md | 1 + .../sdk/api/composables/use-layout.md | 5 +- .../sdk/api/composables/use-layout.md | 5 +- .../sdk/api/composables/use-layout.md | 5 +- .../sdk/api/composables/use-layout.md | 5 +- .../sdk/api/composables/use-layout.md | 5 +- .../components/layout-controls-root.data.ts | 5 + .../api/components/layout-controls-root.md | 21 +- .../sdk/api/composables/use-layout.md | 40 +- .../sdk/api/composables/use-layout.md | 5 +- packages/vue/README.md | 7 +- packages/vue/src/controls/layout/helpers.ts | 76 ++- packages/vue/src/controls/layout/use.ts | 7 +- packages/vue/src/index.ts | 8 +- .../BindableValue/BindableValueRoot.vue | 9 +- .../src/primitives/BindableValue/context.ts | 2 +- .../LayoutControls/LayoutControlsRoot.vue | 12 +- .../src/primitives/LayoutControls/index.ts | 4 + .../src/primitives/LayoutControls/types.ts | 36 ++ .../LayoutSection/LayoutSection.vue | 4 +- .../properties/LayoutSection/SizeControls.vue | 438 ------------------ .../LayoutSection/size/SizeAxisField.vue | 140 ++++++ .../LayoutSection/size/SizeControls.vue | 59 +++ .../LayoutSection/size/SizeLimitField.vue | 73 +++ .../properties/LayoutSection/size/types.ts | 19 + tests/e2e/editor/auto-layout/basic.spec.ts | 86 +++- ...layout-size-controls-openpencil-darwin.png | Bin 0 -> 2646 bytes tests/e2e/properties/panel.spec.ts | 16 +- tests/engine/vue/layout/hug-contents.test.ts | 33 ++ tests/helpers/properties.ts | 2 +- tests/helpers/store.ts | 4 + 32 files changed, 617 insertions(+), 516 deletions(-) create mode 100644 packages/docs/programmable/sdk/api/components/layout-controls-root.data.ts create mode 100644 packages/vue/src/primitives/LayoutControls/types.ts delete mode 100644 src/components/properties/LayoutSection/SizeControls.vue create mode 100644 src/components/properties/LayoutSection/size/SizeAxisField.vue create mode 100644 src/components/properties/LayoutSection/size/SizeControls.vue create mode 100644 src/components/properties/LayoutSection/size/SizeLimitField.vue create mode 100644 src/components/properties/LayoutSection/size/types.ts create mode 100644 tests/e2e/editor/auto-layout/basic.spec.ts-snapshots/layout-size-controls-openpencil-darwin.png diff --git a/AGENTS.md b/AGENTS.md index e05e7e99c..01f407bd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 953ff17e1..50c6f95c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/docs/de/programmable/sdk/api/composables/use-layout.md b/packages/docs/de/programmable/sdk/api/composables/use-layout.md index 2980dbd89..acbc136f6 100644 --- a/packages/docs/de/programmable/sdk/api/composables/use-layout.md +++ b/packages/docs/de/programmable/sdk/api/composables/use-layout.md @@ -31,8 +31,9 @@ const { isFlex, widthSizing, heightSizing, - setWidthSizing, - setHeightSizing, + setAxisSizing, + updateAxisSize, + commitAxisSize, } = useLayout() ``` diff --git a/packages/docs/es/programmable/sdk/api/composables/use-layout.md b/packages/docs/es/programmable/sdk/api/composables/use-layout.md index 5f2fbb0a4..a8cac5a4f 100644 --- a/packages/docs/es/programmable/sdk/api/composables/use-layout.md +++ b/packages/docs/es/programmable/sdk/api/composables/use-layout.md @@ -31,8 +31,9 @@ const { isFlex, widthSizing, heightSizing, - setWidthSizing, - setHeightSizing, + setAxisSizing, + updateAxisSize, + commitAxisSize, } = useLayout() ``` diff --git a/packages/docs/fr/programmable/sdk/api/composables/use-layout.md b/packages/docs/fr/programmable/sdk/api/composables/use-layout.md index 544926407..188221e97 100644 --- a/packages/docs/fr/programmable/sdk/api/composables/use-layout.md +++ b/packages/docs/fr/programmable/sdk/api/composables/use-layout.md @@ -31,8 +31,9 @@ const { isFlex, widthSizing, heightSizing, - setWidthSizing, - setHeightSizing, + setAxisSizing, + updateAxisSize, + commitAxisSize, } = useLayout() ``` diff --git a/packages/docs/it/programmable/sdk/api/composables/use-layout.md b/packages/docs/it/programmable/sdk/api/composables/use-layout.md index 201846c1f..fc29a2cc2 100644 --- a/packages/docs/it/programmable/sdk/api/composables/use-layout.md +++ b/packages/docs/it/programmable/sdk/api/composables/use-layout.md @@ -31,8 +31,9 @@ const { isFlex, widthSizing, heightSizing, - setWidthSizing, - setHeightSizing, + setAxisSizing, + updateAxisSize, + commitAxisSize, } = useLayout() ``` diff --git a/packages/docs/pl/programmable/sdk/api/composables/use-layout.md b/packages/docs/pl/programmable/sdk/api/composables/use-layout.md index a5c9fe1ac..b9c2c9508 100644 --- a/packages/docs/pl/programmable/sdk/api/composables/use-layout.md +++ b/packages/docs/pl/programmable/sdk/api/composables/use-layout.md @@ -31,8 +31,9 @@ const { isFlex, widthSizing, heightSizing, - setWidthSizing, - setHeightSizing, + setAxisSizing, + updateAxisSize, + commitAxisSize, } = useLayout() ``` diff --git a/packages/docs/programmable/sdk/api/components/layout-controls-root.data.ts b/packages/docs/programmable/sdk/api/components/layout-controls-root.data.ts new file mode 100644 index 000000000..49ebe9980 --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/layout-controls-root.data.ts @@ -0,0 +1,5 @@ +import { defineComponentMetaLoader } from '#docs/sdk/component-meta' + +export default defineComponentMetaLoader([ + 'packages/vue/src/primitives/LayoutControls/LayoutControlsRoot.vue' +]) diff --git a/packages/docs/programmable/sdk/api/components/layout-controls-root.md b/packages/docs/programmable/sdk/api/components/layout-controls-root.md index 5e35fa000..c39ac0135 100644 --- a/packages/docs/programmable/sdk/api/components/layout-controls-root.md +++ b/packages/docs/programmable/sdk/api/components/layout-controls-root.md @@ -3,13 +3,32 @@ title: LayoutControlsRoot description: Headless root primitive for auto-layout and sizing controls. --- + + # 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. + + + ## Related APIs - [useLayout](../composables/use-layout) +- [BindableValue](./bindable-value) - [Property Panels guide](../../guides/property-panels) diff --git a/packages/docs/programmable/sdk/api/composables/use-layout.md b/packages/docs/programmable/sdk/api/composables/use-layout.md index 218cfa205..fc7b5c638 100644 --- a/packages/docs/programmable/sdk/api/composables/use-layout.md +++ b/packages/docs/programmable/sdk/api/composables/use-layout.md @@ -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 + +``` + +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) diff --git a/packages/docs/ru/programmable/sdk/api/composables/use-layout.md b/packages/docs/ru/programmable/sdk/api/composables/use-layout.md index e2f78916d..2b6e1b388 100644 --- a/packages/docs/ru/programmable/sdk/api/composables/use-layout.md +++ b/packages/docs/ru/programmable/sdk/api/composables/use-layout.md @@ -31,8 +31,9 @@ const { isFlex, widthSizing, heightSizing, - setWidthSizing, - setHeightSizing, + setAxisSizing, + updateAxisSize, + commitAxisSize, } = useLayout() ``` diff --git a/packages/vue/README.md b/packages/vue/README.md index 3f14923b8..7729acb99 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -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()`. diff --git a/packages/vue/src/controls/layout/helpers.ts b/packages/vue/src/controls/layout/helpers.ts index 27f598a95..8a9bb6f41 100644 --- a/packages/vue/src/controls/layout/helpers.ts +++ b/packages/vue/src/controls/layout/helpers.ts @@ -23,6 +23,7 @@ type LayoutPanelStrings = { sizingFill: string } +export type LayoutAxis = 'width' | 'height' export type SizeLimitProp = 'minWidth' | 'maxWidth' | 'minHeight' | 'maxHeight' type ValueRef = { readonly value: T } @@ -174,15 +175,42 @@ export function createPaddingActions(editor: Editor, node: ComputedRef { + const patch: Partial = {} + 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 - isFlex: ComputedRef isInAutoLayout: ComputedRef }) { 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 diff --git a/packages/vue/src/controls/layout/use.ts b/packages/vue/src/controls/layout/use.ts index 003db3294..e71fcadd1 100644 --- a/packages/vue/src/controls/layout/use.ts +++ b/packages/vue/src/controls/layout/use.ts @@ -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, diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 7df4233f5..00a7475fa 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -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, diff --git a/packages/vue/src/primitives/BindableValue/BindableValueRoot.vue b/packages/vue/src/primitives/BindableValue/BindableValueRoot.vue index 3589e088d..8c7d2593e 100644 --- a/packages/vue/src/primitives/BindableValue/BindableValueRoot.vue +++ b/packages/vue/src/primitives/BindableValue/BindableValueRoot.vue @@ -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) } diff --git a/packages/vue/src/primitives/BindableValue/context.ts b/packages/vue/src/primitives/BindableValue/context.ts index 54fbbd218..4bda47fec 100644 --- a/packages/vue/src/primitives/BindableValue/context.ts +++ b/packages/vue/src/primitives/BindableValue/context.ts @@ -17,5 +17,5 @@ export function useBindableValue(): BindableValueContext { } export function useOptionalBindableValue(): BindableValueContext | undefined { - return inject(BINDABLE_VALUE_KEY) as BindableValueContext | undefined + return inject(BINDABLE_VALUE_KEY, undefined) as BindableValueContext | undefined } diff --git a/packages/vue/src/primitives/LayoutControls/LayoutControlsRoot.vue b/packages/vue/src/primitives/LayoutControls/LayoutControlsRoot.vue index 6a68c273e..f7f25e2a8 100644 --- a/packages/vue/src/primitives/LayoutControls/LayoutControlsRoot.vue +++ b/packages/vue/src/primitives/LayoutControls/LayoutControlsRoot.vue @@ -1,9 +1,12 @@ - - diff --git a/src/components/properties/LayoutSection/size/SizeAxisField.vue b/src/components/properties/LayoutSection/size/SizeAxisField.vue new file mode 100644 index 000000000..903b3891f --- /dev/null +++ b/src/components/properties/LayoutSection/size/SizeAxisField.vue @@ -0,0 +1,140 @@ + + + diff --git a/src/components/properties/LayoutSection/size/SizeControls.vue b/src/components/properties/LayoutSection/size/SizeControls.vue new file mode 100644 index 000000000..9bb3c12bc --- /dev/null +++ b/src/components/properties/LayoutSection/size/SizeControls.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/components/properties/LayoutSection/size/SizeLimitField.vue b/src/components/properties/LayoutSection/size/SizeLimitField.vue new file mode 100644 index 000000000..3f0f9a5f6 --- /dev/null +++ b/src/components/properties/LayoutSection/size/SizeLimitField.vue @@ -0,0 +1,73 @@ + + + diff --git a/src/components/properties/LayoutSection/size/types.ts b/src/components/properties/LayoutSection/size/types.ts new file mode 100644 index 000000000..d5dd9e862 --- /dev/null +++ b/src/components/properties/LayoutSection/size/types.ts @@ -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 +} diff --git a/tests/e2e/editor/auto-layout/basic.spec.ts b/tests/e2e/editor/auto-layout/basic.spec.ts index 694ae518a..69dd2dcdc 100644 --- a/tests/e2e/editor/auto-layout/basic.spec.ts +++ b/tests/e2e/editor/auto-layout/basic.spec.ts @@ -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() }) diff --git a/tests/e2e/editor/auto-layout/basic.spec.ts-snapshots/layout-size-controls-openpencil-darwin.png b/tests/e2e/editor/auto-layout/basic.spec.ts-snapshots/layout-size-controls-openpencil-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..47a43e9611a71a5eb52e783855a6e63de48db6b8 GIT binary patch literal 2646 zcmZuzcR1U77pI7breX)T8WF41sMQv!*n5@QqgGlo<@PE{5z=U=RU~3XtoFM02&J`3 zRS`GFtqwZSs;a%q`@8S^-22!2eV*@l&L8J|Kj%5;b3UndHl|RBIE0Oj4Qg&?8lC`qyBX z`dvv$sk5__0F@X3`|4+%b@le1{duqx8yl;B+9xTpk%2X?V?KCd61;S_TJy%gf7KSO{F6pPTau z3Gr}pN;y9>#~L43la~h{H=v2c)z#olb{Bk8duvg}rQ&nP`$<8zg%=VFMyT3`FPVCd zq&&OhM^~xfR7RT;c zNzFzsj;0U@4~)1@zN~e@!^}*!u7ZMs zsmV!UQBlJhNl8g!%*Oiq^73-&i^9UfnVFd?+Y&8J&Gzhw7oQX(3KLaC{Z zvsfFzXXoeVpWQtU3lAR~88P;iLLyI4s|Tw-KJ}GpCVi3*HH1B8BJwoPpO1)&GW`pZ z)f3f=kKbFD(bR0A(>Z$B1e;x4T!k&A&?tRIqVnzSl}W>i)V%+8+aQ7+?h?A6u* zr=8Xj;qG}#^j7fHH^XoY83Z*nG<146I3$6Ehi00vW9#C{O}j?-*|@OSMeJBHW(q91 z^6p97wLU_+g!cJs`%ZRt%&936Ny*~8JXuLG3g^UNe?KCZz}SA%7M_N1^g*oghgS=3 z#H@fnSbwm{s%Yd-&;VdPh?ywu+28v1^YAb`I~z7rCZI{Krnv?O|4!~Um>C`#;z{H@ z!NCYM>bM~LR%;>j0yy9#SQEba$(@Jn7W%r83u9nnVsiDWh(ZRF3$%%ZN~wHlfExs6 z`twjQ*)skh^Sn$kl&eADb%g6@yY%#QC?|aF*`bJj{Qh*Ew}nLJmgAZ!Ys+9s9mJ4M zaIdS^arIC*jOHPmeGSDn7K=|%ZTO+6VK7g1wvzNwvXP=W^yIGkO#d1CAo<2(A(Wf> zWB(gQIYbA;tgUjyxWz*?#Q$RAfOiZjZerst`@oJ5#n%a%vU`uQ3k>O## z4!N^umw>eI8q02$y>7zJGH&9bzo-s3_zmjn>e_{cguHzc6&@OT?&XzAtJkfeZ&p9_ zWsP^n9c(`*iBeX0`1n*575nBtQ5BtQlmFN}G<&-3C4#*iI-}vWy}j+#5|rE3r8nzQ z?}NeN>OU%H7LiE2&V6^sx|ynI^wH$+&OlL2*LsrD(mv-80v&zn(j_@L?7{Z;t*tGc z-(1m?Nb=m9awU^ke6p=KK&Rr(8UqLuEr)!^6{)vRa2@N8dfFVGQTU+4}nWy1Bc@ zUhT~h77?kesL<5V@DB{^np2@ zT2SDltfim!%f;+B$vgycHD68*n1~4IlaHVtYhZw|b!LVe1e@Po`$R*Xv}$c_^?nwA zDtq0hQv>R$_%KhWyzEQ4- zeTMw}-p1k=GhDotB94MTHlC~>GRUfv^{uTU57Ma_`4b7UV6ZzS)`-tFJUDat)a8!IMVkhJ_2*g9Vm4ef_c5B$Sa-$Ra9qvm&b|-ryRj7Cm0y{_b88dR9>}+VUw+RrhmGH#dmfh_ssQ1wS$g3RC5(MJU4x zMKfS98DIZ|{GO`Ea@?geVeILfFZbnL;X-mj0Rhi7AzX%y_2G3qdBiTZBc3BEh%QG0 zbM^C3&I|vF@^XDTIG7P>9K^#t*a^S+CXs_1A>My7Bq5<3;cE5jdBWm{zI#L>5{dkE zAE~wvBG_{_eu0xCq=fsdi%Lo|Bp)EyDfj+xZ|ltl=nfl$8ISW#NXhVS4G{}0)Ai{2 z+S1mh;=zE$@w~H|q*+*4pzrK#Y>U#lj(JExaHH#-V~j+zb5G+saLuYI z1>O4m3fUEdzRleBHIJ){^fODPKm9X82oosLa~?|kTfgfI24!BCd6Yf*5wtv;`d`Xt zj;W-uUQz}A9S1>5q`nUWpmUNm$ksR0%&m#iuYXF2TRp?|7u)jji({s~smS~3{~w9< zkLKDnZPc5Epoo~!yMQP>J_gmcGJpr@Z&}E}cB6^H { 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') diff --git a/tests/engine/vue/layout/hug-contents.test.ts b/tests/engine/vue/layout/hug-contents.test.ts index 059833209..03091bace 100644 --- a/tests/engine/vue/layout/hug-contents.test.ts +++ b/tests/engine/vue/layout/hug-contents.test.ts @@ -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' + }) + }) }) diff --git a/tests/helpers/properties.ts b/tests/helpers/properties.ts index 94936864b..d50082826 100644 --- a/tests/helpers/properties.ts +++ b/tests/helpers/properties.ts @@ -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 { diff --git a/tests/helpers/store.ts b/tests/helpers/store.ts index 48af3f89a..f7139a2d3 100644 --- a/tests/helpers/store.ts +++ b/tests/helpers/store.ts @@ -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,