diff --git a/AGENTS.md b/AGENTS.md index 9aa18dc85..8a2800462 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/packages/core/src/scene-graph/index.ts b/packages/core/src/scene-graph/index.ts index e4a0c8b52..1054233a0 100644 --- a/packages/core/src/scene-graph/index.ts +++ b/packages/core/src/scene-graph/index.ts @@ -49,6 +49,7 @@ export class SceneGraph { documentColorSpace: DocumentColorSpace = 'display-p3' readonly emitter: Emitter = createNanoEvents() private absPosCache = new Map() + private previewMutationDepth = 0 positionPreviewVersion = 0 instanceIndex = new Map>() @@ -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 = 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): void { updateNodePreview(this, id, changes) } - updateNode(id: string, changes: Partial): void { + if (this.previewMutationDepth > 0) { + this.updateNodePreview(id, changes) + return + } + const node = this.nodes.get(id) if (!node) return diff --git a/packages/vue/src/controls/layout/helpers.ts b/packages/vue/src/controls/layout/helpers.ts index c7f11b95a..0ae268732 100644 --- a/packages/vue/src/controls/layout/helpers.ts +++ b/packages/vue/src/controls/layout/helpers.ts @@ -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> = {} +): { 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, @@ -308,32 +352,20 @@ export function createLayoutSizingState( const isFlex = computed( () => node.value?.layoutMode === 'HORIZONTAL' || node.value?.layoutMode === 'VERTICAL' ) + const widthSizing = computed(() => + widthSizingForNode(node.value, isInAutoLayout.value) + ) - const widthSizing = computed(() => { - 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(() => { - 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(() => + 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) diff --git a/packages/vue/src/shared/input/resize.ts b/packages/vue/src/shared/input/resize.ts index 88bf0f328..b4382851e 100644 --- a/packages/vue/src/shared/input/resize.ts +++ b/packages/vue/src/shared/input/resize.ts @@ -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() } diff --git a/src/app/demo/sections/app-preview.ts b/src/app/demo/sections/app-preview.ts index 24f7a878a..0ee470ad1 100644 --- a/src/app/demo/sections/app-preview.ts +++ b/src/app/demo/sections/app-preview.ts @@ -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)] }) } diff --git a/src/app/demo/sections/components.ts b/src/app/demo/sections/components.ts index 1aaf9aaf6..12c72cc28 100644 --- a/src/app/demo/sections/components.ts +++ b/src/app/demo/sections/components.ts @@ -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]) diff --git a/src/app/demo/sections/standalone.ts b/src/app/demo/sections/standalone.ts index 2b5736e4b..82096f626 100644 --- a/src/app/demo/sections/standalone.ts +++ b/src/app/demo/sections/standalone.ts @@ -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)] }) } diff --git a/tests/engine/layout/auto-layout/text/measurement.test.ts b/tests/engine/layout/auto-layout/text/measurement.test.ts index abd05651c..aeccea62d 100644 --- a/tests/engine/layout/auto-layout/text/measurement.test.ts +++ b/tests/engine/layout/auto-layout/text/measurement.test.ts @@ -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) diff --git a/tests/engine/vue/layout/hug-contents.test.ts b/tests/engine/vue/layout/hug-contents.test.ts new file mode 100644 index 000000000..92f1bba95 --- /dev/null +++ b/tests/engine/vue/layout/hug-contents.test.ts @@ -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 { + 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') + }) +})