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:
parent
12031b76c9
commit
fcdf7f1d4c
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ const {
|
|||
isFlex,
|
||||
widthSizing,
|
||||
heightSizing,
|
||||
setWidthSizing,
|
||||
setHeightSizing,
|
||||
setAxisSizing,
|
||||
updateAxisSize,
|
||||
commitAxisSize,
|
||||
} = useLayout()
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ const {
|
|||
isFlex,
|
||||
widthSizing,
|
||||
heightSizing,
|
||||
setWidthSizing,
|
||||
setHeightSizing,
|
||||
setAxisSizing,
|
||||
updateAxisSize,
|
||||
commitAxisSize,
|
||||
} = useLayout()
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ const {
|
|||
isFlex,
|
||||
widthSizing,
|
||||
heightSizing,
|
||||
setWidthSizing,
|
||||
setHeightSizing,
|
||||
setAxisSizing,
|
||||
updateAxisSize,
|
||||
commitAxisSize,
|
||||
} = useLayout()
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ const {
|
|||
isFlex,
|
||||
widthSizing,
|
||||
heightSizing,
|
||||
setWidthSizing,
|
||||
setHeightSizing,
|
||||
setAxisSizing,
|
||||
updateAxisSize,
|
||||
commitAxisSize,
|
||||
} = useLayout()
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ const {
|
|||
isFlex,
|
||||
widthSizing,
|
||||
heightSizing,
|
||||
setWidthSizing,
|
||||
setHeightSizing,
|
||||
setAxisSizing,
|
||||
updateAxisSize,
|
||||
commitAxisSize,
|
||||
} = useLayout()
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
|
||||
|
||||
export default defineComponentMetaLoader([
|
||||
'packages/vue/src/primitives/LayoutControls/LayoutControlsRoot.vue'
|
||||
])
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ const {
|
|||
isFlex,
|
||||
widthSizing,
|
||||
heightSizing,
|
||||
setWidthSizing,
|
||||
setHeightSizing,
|
||||
setAxisSizing,
|
||||
updateAxisSize,
|
||||
commitAxisSize,
|
||||
} = useLayout()
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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()`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
36
packages/vue/src/primitives/LayoutControls/types.ts
Normal file
36
packages/vue/src/primitives/LayoutControls/types.ts
Normal 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[]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
140
src/components/properties/LayoutSection/size/SizeAxisField.vue
Normal file
140
src/components/properties/LayoutSection/size/SizeAxisField.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
19
src/components/properties/LayoutSection/size/types.ts
Normal file
19
src/components/properties/LayoutSection/size/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue