fix(demo): make auto-layout text responsive

This commit is contained in:
Danila Poyarkov 2026-05-13 06:23:08 +03:00
parent 9ab6bfa3bf
commit c00356bc29
9 changed files with 193 additions and 38 deletions

View file

@ -237,6 +237,7 @@ Release commits are the exception: keep using `Release v0.x.y`.
## Code conventions
- Do not place code or tests ad hoc. Before adding or moving files, inspect the existing folder structure and nearby patterns, then put changes in the established domain-specific location. If no proper location exists, create one deliberately and update docs/conventions as needed.
- Test placement is strict: E2E tests live under `tests/e2e/**` and use `*.spec.ts`; engine/unit tests live under `tests/engine/**` and use `*.test.ts`. Do not put store-only/internal-state assertions in E2E. If a test drives the UI like a user and verifies visible behavior, it can be E2E; if it creates nodes through internals and asserts graph state, it belongs in engine/unit coverage.
### File and folder naming

View file

@ -49,6 +49,7 @@ export class SceneGraph {
documentColorSpace: DocumentColorSpace = 'display-p3'
readonly emitter: Emitter<SceneGraphEvents> = createNanoEvents()
private absPosCache = new Map<string, Vector>()
private previewMutationDepth = 0
positionPreviewVersion = 0
instanceIndex = new Map<string, Set<string>>()
@ -297,7 +298,6 @@ export class SceneGraph {
* These names MUST match the actual SceneNode field names (not Figma API proxy names).
*/
static LAYOUT_AFFECTING_KEYS: ReadonlySet<string> = new Set([
// Direct transform properties (used by getNodeLocalMatrix)
'x',
'y',
'width',
@ -305,7 +305,6 @@ export class SceneGraph {
'rotation',
'flipX',
'flipY',
// Auto-layout properties (affect children's absolute positions)
'layoutMode',
'layoutDirection',
'itemSpacing',
@ -324,16 +323,13 @@ export class SceneGraph {
'layoutGrow',
'layoutAlignSelf',
'strokesIncludedInLayout',
// Constraints
'horizontalConstraint',
'verticalConstraint',
// Grid layout
'gridTemplateColumns',
'gridTemplateRows',
'gridColumnGap',
'gridRowGap',
'gridPosition',
// Sizing constraints
'minWidth',
'maxWidth',
'minHeight',
@ -359,15 +355,26 @@ export class SceneGraph {
'height'
])
runPreviewUpdates(fn: () => void): void {
this.previewMutationDepth++
try {
fn()
} finally {
this.previewMutationDepth--
}
}
updateNodePositionPreview(id: string, x: number, y: number): void {
this.updateNodePreview(id, { x, y })
}
updateNodePreview(id: string, changes: Partial<SceneNode>): void {
updateNodePreview(this, id, changes)
}
updateNode(id: string, changes: Partial<SceneNode>): void {
if (this.previewMutationDepth > 0) {
this.updateNodePreview(id, changes)
return
}
const node = this.nodes.get(id)
if (!node) return

View file

@ -229,22 +229,30 @@ export function createLayoutActions({
}
function setWidthSizing(sizing: LayoutSizing) {
if (!node.value) return
const n = node.value
if (!n) return
if (isFlex.value) {
const key = node.value.layoutMode === 'HORIZONTAL' ? 'primaryAxisSizing' : 'counterAxisSizing'
const key = n.layoutMode === 'HORIZONTAL' ? 'primaryAxisSizing' : 'counterAxisSizing'
updateProp(key, sizing)
} else if (isInAutoLayout.value) {
updateProp('layoutGrow', sizing === 'FILL' ? 1 : 0)
} 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)
}
}
function setHeightSizing(sizing: LayoutSizing) {
if (!node.value) return
const n = node.value
if (!n) return
if (isFlex.value) {
const key = node.value.layoutMode === 'VERTICAL' ? 'primaryAxisSizing' : 'counterAxisSizing'
const key = n.layoutMode === 'VERTICAL' ? 'primaryAxisSizing' : 'counterAxisSizing'
updateProp(key, sizing)
} else if (isInAutoLayout.value) {
updateProp('layoutAlignSelf', sizing === 'FILL' ? 'STRETCH' : 'AUTO')
} 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')
}
}
@ -292,6 +300,42 @@ export function createLayoutActions({
}
}
export function canNodeHugContents(node: SceneNode | null): boolean {
return !!node && node.childIds.length > 0
}
export function widthSizingForNode(node: SceneNode | null, isInAutoLayout: boolean): LayoutSizing {
if (!node) return 'FIXED'
if (node.layoutMode === 'HORIZONTAL') return node.primaryAxisSizing
if (node.layoutMode === 'VERTICAL') return node.counterAxisSizing
if (canNodeHugContents(node) && node.counterAxisSizing === 'HUG') return 'HUG'
if (isInAutoLayout && node.layoutGrow > 0) return 'FILL'
return 'FIXED'
}
export function heightSizingForNode(node: SceneNode | null, isInAutoLayout: boolean): LayoutSizing {
if (!node) return 'FIXED'
if (node.layoutMode === 'VERTICAL') return node.primaryAxisSizing
if (node.layoutMode === 'HORIZONTAL') return node.counterAxisSizing
if (canNodeHugContents(node) && node.primaryAxisSizing === 'HUG') return 'HUG'
if (isInAutoLayout && node.layoutAlignSelf === 'STRETCH') return 'FILL'
return 'FIXED'
}
export function sizingOptionsForNode(
node: SceneNode | null,
isInAutoLayout: boolean,
labels: Partial<Record<LayoutSizing, string>> = {}
): { value: LayoutSizing; label: string }[] {
const isFlex = node?.layoutMode === 'HORIZONTAL' || node?.layoutMode === 'VERTICAL'
const options: { value: LayoutSizing; label: string }[] = [
{ value: 'FIXED', label: labels.FIXED ?? 'Fixed' }
]
if (isFlex || canNodeHugContents(node)) options.push({ value: 'HUG', label: labels.HUG ?? 'Hug' })
if (isInAutoLayout || isFlex) options.push({ value: 'FILL', label: labels.FILL ?? 'Fill' })
return options
}
export function createLayoutSizingState(
editor: Editor,
node: ComputedRef<SceneNode | null>,
@ -308,32 +352,20 @@ export function createLayoutSizingState(
const isFlex = computed(
() => node.value?.layoutMode === 'HORIZONTAL' || node.value?.layoutMode === 'VERTICAL'
)
const widthSizing = computed<LayoutSizing>(() =>
widthSizingForNode(node.value, isInAutoLayout.value)
)
const widthSizing = computed<LayoutSizing>(() => {
const n = node.value
if (!n) return 'FIXED'
if (isFlex.value)
return n.layoutMode === 'HORIZONTAL' ? n.primaryAxisSizing : n.counterAxisSizing
if (isInAutoLayout.value && n.layoutGrow > 0) return 'FILL'
return 'FIXED'
})
const heightSizing = computed<LayoutSizing>(() => {
const n = node.value
if (!n) return 'FIXED'
if (isFlex.value) return n.layoutMode === 'VERTICAL' ? n.primaryAxisSizing : n.counterAxisSizing
if (isInAutoLayout.value && n.layoutAlignSelf === 'STRETCH') return 'FILL'
return 'FIXED'
})
const heightSizing = computed<LayoutSizing>(() =>
heightSizingForNode(node.value, isInAutoLayout.value)
)
function sizingOptions() {
const options: { value: LayoutSizing; label: string }[] = [
{ value: 'FIXED', label: panels.value.sizingFixed }
]
if (isFlex.value) options.push({ value: 'HUG', label: panels.value.sizingHug })
if (isInAutoLayout.value || isFlex.value)
options.push({ value: 'FILL', label: panels.value.sizingFill })
return options
return sizingOptionsForNode(node.value, isInAutoLayout.value, {
FIXED: panels.value.sizingFixed,
HUG: panels.value.sizingHug,
FILL: panels.value.sizingFill
})
}
const widthSizingOptions = computed(sizingOptions)

View file

@ -1,6 +1,7 @@
export { constrainToAspectRatio } from '#vue/shared/input/resize/rect'
export { tryStartResize } from '#vue/shared/input/resize/start'
import type { Editor } from '@open-pencil/core/editor'
import { computeLayout } from '@open-pencil/core/layout'
import type { SceneNode } from '@open-pencil/core/scene-graph'
import { calculateResizeRect } from '#vue/shared/input/resize/rect'
@ -32,6 +33,10 @@ export function applyResize(
editor: Editor
) {
editor.graph.updateNodePreview(d.nodeId, resizeChanges(d, cx, cy, constrain))
const node = editor.graph.getNode(d.nodeId)
if (node?.layoutMode !== 'NONE') {
editor.graph.runPreviewUpdates(() => computeLayout(editor.graph, d.nodeId))
}
editor.requestRepaint()
}

View file

@ -98,6 +98,8 @@ export function createAppPreviewSection(
text: s.title,
fontSize: 11,
fontWeight: 500,
textAutoResize: 'HEIGHT',
layoutAlignSelf: 'STRETCH',
fills: [solid(GRAY_500)]
})
const valId = store.createShape('TEXT', 0, 0, 108, 24, cId)
@ -106,6 +108,8 @@ export function createAppPreviewSection(
text: s.value,
fontSize: 22,
fontWeight: 700,
textAutoResize: 'HEIGHT',
layoutAlignSelf: 'STRETCH',
fills: [solid(BLACK)]
})
const bId = store.createShape('TEXT', 0, 0, 108, 14, cId)
@ -114,6 +118,8 @@ export function createAppPreviewSection(
text: s.badge,
fontSize: 11,
fontWeight: 600,
textAutoResize: 'HEIGHT',
layoutAlignSelf: 'STRETCH',
fills: [solid(s.color)]
})
}
@ -181,6 +187,7 @@ export function createAppPreviewSection(
text: col,
fontSize: 12,
fontWeight: 600,
textAutoResize: 'WIDTH_AND_HEIGHT',
fills: [solid(GRAY_500)]
})
}

View file

@ -52,6 +52,7 @@ export function createComponentsSection(store: EditorStore) {
text: 'Get Started',
fontSize: 14,
fontWeight: 600,
textAutoResize: 'WIDTH_AND_HEIGHT',
fills: [solid(WHITE)]
})
const btnCompId = makeComponent(store, [btnId])
@ -85,6 +86,7 @@ export function createComponentsSection(store: EditorStore) {
text: 'Cancel',
fontSize: 14,
fontWeight: 500,
textAutoResize: 'WIDTH_AND_HEIGHT',
fills: [solid(BLACK)]
})
const btn2CompId = makeComponent(store, [btn2Id])
@ -117,6 +119,7 @@ export function createComponentsSection(store: EditorStore) {
text: 'Design',
fontSize: 12,
fontWeight: 500,
textAutoResize: 'WIDTH_AND_HEIGHT',
fills: [solid(INDIGO)]
})
makeComponent(store, [chipId])
@ -155,6 +158,8 @@ export function createComponentsSection(store: EditorStore) {
text: 'Analytics Overview',
fontSize: 16,
fontWeight: 600,
textAutoResize: 'HEIGHT',
layoutAlignSelf: 'STRETCH',
fills: [solid(BLACK)]
})
const cardDescId = store.createShape('TEXT', 0, 0, 240, 36, cardId)
@ -163,6 +168,8 @@ export function createComponentsSection(store: EditorStore) {
text: 'Track your key metrics and performance indicators in real time.',
fontSize: 13,
fontWeight: 400,
textAutoResize: 'HEIGHT',
layoutAlignSelf: 'STRETCH',
fills: [solid(GRAY_500)]
})
const cardBarBg = store.createShape('RECTANGLE', 0, 0, 240, 8, cardId)
@ -206,6 +213,8 @@ export function createComponentsSection(store: EditorStore) {
text: 'Search...',
fontSize: 14,
fontWeight: 400,
textAutoResize: 'HEIGHT',
layoutAlignSelf: 'STRETCH',
fills: [solid(GRAY_500)]
})
makeComponent(store, [inputId])
@ -237,6 +246,7 @@ export function createComponentsSection(store: EditorStore) {
text: 'Live',
fontSize: 11,
fontWeight: 600,
textAutoResize: 'WIDTH_AND_HEIGHT',
fills: [solid(GREEN)]
})
const badgeCompId = makeComponent(store, [badgeId])

View file

@ -106,6 +106,8 @@ export function createStandaloneShapes(store: EditorStore) {
text: t.text,
fontSize: t.size,
fontWeight: t.weight,
textAutoResize: 'HEIGHT',
layoutAlignSelf: 'STRETCH',
fills: [solid(BLACK)]
})
}

View file

@ -253,6 +253,47 @@ describe('text measurement', () => {
expect(updatedText.height).toBeGreaterThan(22)
})
test('resizing vertical typography frames reflows fill-width auto-height text', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 300,
height: 200,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
paddingLeft: 20,
paddingRight: 20
})
const text = graph.createNode('TEXT', frame.id, {
width: 260,
height: 20,
text: 'Body text — The quick brown fox jumps and wraps.',
fontSize: 14,
textAutoResize: 'HEIGHT' as const,
layoutAlignSelf: 'STRETCH' as const
})
setTextMeasurer((_node, maxWidth) => {
const w = maxWidth ?? 260
return { width: w, height: w <= 160 ? 60 : 20 }
})
computeAllLayouts(graph)
expect(getNodeOrThrow(graph, text.id).width).toBe(260)
expect(getNodeOrThrow(graph, text.id).height).toBe(20)
graph.updateNode(frame.id, { width: 200 })
computeAllLayouts(graph)
setTextMeasurer(null)
const updatedText = getNodeOrThrow(graph, text.id)
expect(updatedText.width).toBe(160)
expect(updatedText.height).toBe(60)
})
test('text with w="fill" in flex="col" stretches to parent width', () => {
const graph = new SceneGraph()
const pid = pageId(graph)

View file

@ -0,0 +1,50 @@
import { describe, expect, test } from 'bun:test'
import { widthSizingForNode, heightSizingForNode, sizingOptionsForNode } from '#vue/controls/layout/helpers'
import type { SceneNode } from '@open-pencil/core/scene-graph'
function node(overrides: Partial<SceneNode>): SceneNode {
return {
id: 'node',
type: 'FRAME',
name: 'Frame',
parentId: 'page',
childIds: [],
x: 0,
y: 0,
width: 100,
height: 100,
rotation: 0,
layoutMode: 'NONE',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
layoutGrow: 0,
layoutAlignSelf: 'AUTO',
...overrides
} as SceneNode
}
describe('layout sizing controls', () => {
test('plain containers with children expose hug contents', () => {
const frame = node({ childIds: ['child'] })
expect(sizingOptionsForNode(frame, false).map((option) => option.value)).toContain('HUG')
})
test('plain container width and height reflect hug sizing fields', () => {
const frame = node({
childIds: ['child'],
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG'
})
expect(widthSizingForNode(frame, false)).toBe('HUG')
expect(heightSizingForNode(frame, false)).toBe('HUG')
})
test('leaf frames do not expose hug contents', () => {
const frame = node({ childIds: [] })
expect(sizingOptionsForNode(frame, false).map((option) => option.value)).not.toContain('HUG')
})
})