fix(demo): make auto-layout text responsive
This commit is contained in:
parent
9ab6bfa3bf
commit
c00356bc29
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
50
tests/engine/vue/layout/hug-contents.test.ts
Normal file
50
tests/engine/vue/layout/hug-contents.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue