diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e621457..4e42262cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,7 @@ ### Fixed -- Match Figma auto-layout spacing, padding, min/max constraints, scalar variable bindings, imported text bounds, and nested instance geometry more closely. +- Match Figma auto-layout spacing, padding, min/max constraints, scalar variable bindings, CanvasKit-shaped generated text, imported text bounds, and nested instance geometry more closely. - Match Figma Plugin API vector path and network editing, including bounds, winding rules, region fills, validation, and handle mirroring. (#444) - Let AI and MCP tools create arbitrary vectors from SVG path data, validating input without leaving blank layers behind. (#440) - Improve AI design accuracy by exposing every supported shape, including visible stroke colors and weights in visual descriptions, and accepting supported inline SVG attributes without false warnings. (#445, #447, #448) diff --git a/packages/core/src/layout.ts b/packages/core/src/layout.ts index 23c522d4a..45c4a1718 100644 --- a/packages/core/src/layout.ts +++ b/packages/core/src/layout.ts @@ -13,6 +13,7 @@ import { import { applyYogaLayout } from './layout/apply' import { usesDetachedDerivedLayout } from './layout/derived' +import { applyEffectiveGeneratedTextLayout } from './layout/effective-generated-text' import { buildGridTree, createGridChildNode } from './layout/grid' import { resolveNodeLayoutDirection } from './text/direction' export { @@ -40,19 +41,15 @@ export function computeLayout(graph: SceneGraph, frameId: string): void { if (!frame || frame.layoutMode === 'NONE') return const rootDirection = resolveComputedLayoutDirection(graph, frame) + const yogaDirection = rootDirection === 'RTL' ? Direction.RTL : Direction.LTR const yogaRoot = frame.layoutMode === 'GRID' ? buildGridTree(graph, frame, rootDirection) : buildYogaTree(graph, frame, rootDirection) - yogaRoot.calculateLayout( - undefined, - undefined, - rootDirection === 'RTL' ? Direction.RTL : Direction.LTR - ) + yogaRoot.calculateLayout(undefined, undefined, yogaDirection) applyYogaLayout(graph, frame, yogaRoot, computeLayout) freeYogaTree(yogaRoot) } - function resolveComputedLayoutDirection( graph: SceneGraph, node: Pick @@ -63,8 +60,10 @@ function resolveComputedLayoutDirection( } export function computeAllLayouts(graph: SceneGraph, scopeId?: string): void { + const rootId = scopeId ?? graph.rootId const visited = new Set() - computeLayoutsBottomUp(graph, scopeId ?? graph.rootId, visited) + computeLayoutsBottomUp(graph, rootId, visited) + applyEffectiveGeneratedTextLayout(graph, rootId) } function computeLayoutsBottomUp(graph: SceneGraph, nodeId: string, visited: Set): void { diff --git a/packages/core/src/layout/effective-generated-text.ts b/packages/core/src/layout/effective-generated-text.ts new file mode 100644 index 000000000..e280a2945 --- /dev/null +++ b/packages/core/src/layout/effective-generated-text.ts @@ -0,0 +1,275 @@ +import type { SceneGraph, SceneNode, Size } from '@open-pencil/scene-graph' + +import { getTextMeasurer } from './text-measurement' + +type LayoutAxis = 'width' | 'height' + +const MIN_EFFECTIVE_TEXT_WIDTH_CHANGE = 1.5 +const MAX_STRETCHED_TEXT_WIDTH_CHANGE = 8 +const MAX_COMPONENT_LINEAGE_DEPTH = 20 + +function axisSizing(node: SceneNode, axis: LayoutAxis): SceneNode['primaryAxisSizing'] { + const isPrimary = + (node.layoutMode === 'HORIZONTAL' && axis === 'width') || + (node.layoutMode === 'VERTICAL' && axis === 'height') + return isPrimary ? node.primaryAxisSizing : node.counterAxisSizing +} + +function canResizeIntrinsicAxis(node: SceneNode, axis: LayoutAxis): boolean { + return ( + axisSizing(node, axis) === 'HUG' || + (node.source.format === 'fig' && node.figmaDerivedLayout?.[axis] === undefined) + ) +} + +function terminalTextSource(graph: SceneGraph, node: SceneNode): SceneNode | undefined { + let current = node + for (let depth = 0; current.componentId && depth < MAX_COMPONENT_LINEAGE_DEPTH; depth++) { + const source = graph.getNode(current.componentId) + if (!source) break + current = source + } + return current.type === 'TEXT' ? current : undefined +} + +function parentHugsWidth(graph: SceneGraph, node: SceneNode): boolean { + const parent = node.parentId ? graph.getNode(node.parentId) : undefined + return parent !== undefined && axisSizing(parent, 'width') === 'HUG' +} + +function hasFixedWidthTextAncestor(graph: SceneGraph, node: SceneNode): boolean { + let current = node + for (let depth = 0; current.componentId && depth < MAX_COMPONENT_LINEAGE_DEPTH; depth++) { + const source = graph.getNode(current.componentId) + if (!source) break + if (source.type === 'TEXT' && source.textAutoResize === 'HEIGHT') return true + current = source + } + return false +} + +function canShapeGeneratedText(graph: SceneGraph, node: SceneNode): boolean { + if ( + node.type !== 'TEXT' || + node.source.format === 'fig' || + !node.componentId || + !node.figmaDerivedLayout || + node.figmaDerivedLayout.width !== node.width || + node.figmaDerivedLayout.height !== node.height + ) { + return false + } + + const sourceText = terminalTextSource(graph, node) + if (sourceText?.source.format !== 'fig') return false + if (node.textAutoResize === 'WIDTH_AND_HEIGHT') { + return !hasFixedWidthTextAncestor(graph, node) + } + return ( + node.textAutoResize === 'HEIGHT' && + node.layoutAlignSelf === 'STRETCH' && + parentHugsWidth(graph, node) && + sourceText.text === node.text + ) +} + +function intrinsicSize( + graph: SceneGraph, + node: SceneNode, + sizes: ReadonlyMap +): Size | null { + if (node.layoutMode !== 'HORIZONTAL' && node.layoutMode !== 'VERTICAL') return null + const children = graph + .getChildren(node.id) + .filter((child) => child.visible && child.layoutPositioning !== 'ABSOLUTE') + if (children.length === 0) return null + + const childSizes = children.map((child) => sizes.get(child.id) ?? child) + const gap = + node.primaryAxisAlign === 'SPACE_BETWEEN' + ? 0 + : node.itemSpacing * Math.max(0, children.length - 1) + if (node.layoutMode === 'HORIZONTAL') { + return { + width: + node.paddingLeft + + node.paddingRight + + childSizes.reduce((sum, child) => sum + child.width, gap), + height: + node.paddingTop + node.paddingBottom + Math.max(...childSizes.map((child) => child.height)) + } + } + return { + width: + node.paddingLeft + node.paddingRight + Math.max(...childSizes.map((child) => child.width)), + height: + node.paddingTop + + node.paddingBottom + + childSizes.reduce((sum, child) => sum + child.height, gap) + } +} + +function intrinsicSizeWithEffectiveStretch( + graph: SceneGraph, + node: SceneNode, + sizes: ReadonlyMap, + affected: ReadonlySet +): Size | null { + const intrinsic = intrinsicSize(graph, node, sizes) + if (!intrinsic || node.layoutMode !== 'VERTICAL' || axisSizing(node, 'width') !== 'HUG') { + return intrinsic + } + const children = graph + .getChildren(node.id) + .filter((child) => child.visible && child.layoutPositioning !== 'ABSOLUTE') + if (!children.some((child) => affected.has(child.id))) return intrinsic + const widthCandidates = children.filter( + (child) => affected.has(child.id) || child.layoutAlignSelf !== 'STRETCH' + ) + if (widthCandidates.length === 0) return intrinsic + return { + ...intrinsic, + width: + node.paddingLeft + + node.paddingRight + + Math.max(...widthCandidates.map((child) => (sizes.get(child.id) ?? child).width)) + } +} + +function stretchChildrenToEffectiveWidth( + graph: SceneGraph, + node: SceneNode, + oldIntrinsicWidth: number, + nextWidth: number, + currentSizes: Map, + affected: Set +): void { + const oldContentWidth = oldIntrinsicWidth - node.paddingLeft - node.paddingRight + const nextContentWidth = nextWidth - node.paddingLeft - node.paddingRight + for (const child of graph.getChildren(node.id)) { + if (child.layoutAlignSelf !== 'STRETCH' || Math.abs(child.width - oldContentWidth) >= 0.001) { + continue + } + const updates: Partial = { width: nextContentWidth } + if (child.figmaDerivedLayout) { + updates.figmaDerivedLayout = { ...child.figmaDerivedLayout, width: nextContentWidth } + } + graph.updateNode(child.id, updates) + currentSizes.set(child.id, { width: nextContentWidth, height: child.height }) + affected.add(child.id) + } +} + +function collectPostorder(graph: SceneGraph, rootId: string): SceneNode[] { + const result: SceneNode[] = [] + const visit = (nodeId: string): void => { + const node = graph.getNode(nodeId) + if (!node) return + for (const childId of node.childIds) visit(childId) + result.push(node) + } + visit(rootId) + return result +} + +function updateGeneratedTextWidths( + graph: SceneGraph, + nodes: SceneNode[], + currentSizes: Map, + affected: Set +): void { + const measure = getTextMeasurer() + if (!measure) return + + for (const node of nodes) { + if (!canShapeGeneratedText(graph, node)) continue + const measured = measure(node) + if (!measured || measured.width <= 0) continue + const widthChange = node.width - measured.width + if ( + widthChange < MIN_EFFECTIVE_TEXT_WIDTH_CHANGE || + (node.textAutoResize === 'HEIGHT' && widthChange > MAX_STRETCHED_TEXT_WIDTH_CHANGE) + ) { + continue + } + + graph.updateNode(node.id, { + width: measured.width, + figmaDerivedLayout: { ...node.figmaDerivedLayout, width: measured.width } + }) + currentSizes.set(node.id, { width: measured.width, height: node.height }) + affected.add(node.id) + } +} + +function propagateIntrinsicSizes( + graph: SceneGraph, + nodes: SceneNode[], + originalSizes: ReadonlyMap, + currentSizes: Map, + affected: Set +): void { + for (const node of nodes) { + if (node.type === 'TEXT') continue + const children = graph.getChildren(node.id) + if (!children.some((child) => affected.has(child.id))) continue + + const oldIntrinsic = intrinsicSize(graph, node, originalSizes) + const nextIntrinsic = intrinsicSizeWithEffectiveStretch(graph, node, currentSizes, affected) + const oldSize = originalSizes.get(node.id) + if (!oldIntrinsic || !nextIntrinsic || !oldSize) continue + + const updates: Partial = {} + let nextWidth = oldSize.width + let nextHeight = oldSize.height + if ( + canResizeIntrinsicAxis(node, 'width') && + Math.abs(oldSize.width - oldIntrinsic.width) < 0.001 + ) { + nextWidth = nextIntrinsic.width + updates.width = nextWidth + } + if ( + canResizeIntrinsicAxis(node, 'height') && + Math.abs(oldSize.height - oldIntrinsic.height) < 0.001 + ) { + nextHeight = nextIntrinsic.height + updates.height = nextHeight + } + if (Object.keys(updates).length === 0) continue + + if (node.figmaDerivedLayout) { + updates.figmaDerivedLayout = { + ...node.figmaDerivedLayout, + ...(updates.width === undefined ? {} : { width: nextWidth }), + ...(updates.height === undefined ? {} : { height: nextHeight }) + } + } + if (updates.width !== undefined) { + stretchChildrenToEffectiveWidth( + graph, + node, + oldIntrinsic.width, + nextWidth, + currentSizes, + affected + ) + } + graph.updateNode(node.id, updates) + currentSizes.set(node.id, { width: nextWidth, height: nextHeight }) + affected.add(node.id) + } +} + +export function applyEffectiveGeneratedTextLayout(graph: SceneGraph, rootId: string): void { + const nodes = collectPostorder(graph, rootId) + const originalSizes = new Map( + nodes.map((node) => [node.id, { width: node.width, height: node.height }]) + ) + const currentSizes = new Map(originalSizes) + const affected = new Set() + + updateGeneratedTextWidths(graph, nodes, currentSizes, affected) + if (affected.size === 0) return + propagateIntrinsicSizes(graph, nodes, originalSizes, currentSizes, affected) +} diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 694fd5d6f..9995e3723 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -28,7 +28,7 @@ import { TEXT_PICTURE_KEYS } from './text-picture' import * as Variables from './variables' import { normalizeVectorNetwork } from './vector-network' -export type { GUID, Color, Vector } from './primitives' +export type { GUID, Color, Vector, Size } from './primitives' export * from './types' import type { Emitter } from 'nanoevents' diff --git a/tests/engine/layout/auto-layout/imported-derived-layout/effective-generated-text.test.ts b/tests/engine/layout/auto-layout/imported-derived-layout/effective-generated-text.test.ts new file mode 100644 index 000000000..d79330008 --- /dev/null +++ b/tests/engine/layout/auto-layout/imported-derived-layout/effective-generated-text.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +import { computeAllLayouts, SceneGraph, setTextMeasurer } from '@open-pencil/core' + +function importedText(graph: SceneGraph, text: string, width: number, height: number, id: string) { + const page = graph.getPages()[0] + const node = graph.createNode('TEXT', page.id, { + width, + height, + text, + textAutoResize: 'WIDTH_AND_HEIGHT' + }) + graph.updateNode(node.id, { source: { ...node.source, format: 'fig', id } }) + return node +} + +afterEach(() => setTextMeasurer(null)) + +describe('effective generated FIG text layout', () => { + test('shapes generated text and recomputes its HUG width lineage', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const source = importedText(graph, 'Effective instance text', 342, 20, '1:1') + const checkbox = graph.createNode('INSTANCE', page.id, { + width: 366, + height: 40, + layoutMode: 'HORIZONTAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'HUG', + componentId: 'component' + }) + graph.updateNode(checkbox.id, { + source: { ...checkbox.source, format: 'fig', id: '1:5' } + }) + graph.createNode('INSTANCE', checkbox.id, { width: 16, height: 16 }) + const textWrapper = graph.createNode('FRAME', checkbox.id, { + width: 350, + height: 40, + layoutMode: 'VERTICAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'HUG', + paddingLeft: 8, + componentId: 'text-wrapper', + figmaDerivedLayout: { width: 350, height: 40 } + }) + const generatedText = graph.createNode('TEXT', textWrapper.id, { + width: 342, + height: 20, + text: source.text, + textAutoResize: 'WIDTH_AND_HEIGHT', + componentId: source.id, + figmaDerivedLayout: { width: 342, height: 20 } + }) + setTextMeasurer(() => ({ width: 336, height: 20 })) + + computeAllLayouts(graph) + + expect(graph.getNode(generatedText.id)).toMatchObject({ + width: 336, + height: 20, + figmaDerivedLayout: { width: 336, height: 20 } + }) + expect(graph.getNode(textWrapper.id)).toMatchObject({ + width: 344, + figmaDerivedLayout: { width: 344, height: 40 } + }) + expect(graph.getNode(checkbox.id)).toMatchObject({ width: 360, height: 40 }) + expect(graph.getNode(source.id)).toMatchObject({ width: 342, height: 20 }) + }) + + test('preserves fixed generated ancestors after shaping their text', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const source = importedText(graph, 'Choose file', 76, 20, '1:2') + const input = graph.createNode('FRAME', page.id, { + width: 108, + height: 40, + layoutMode: 'HORIZONTAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED', + paddingLeft: 16, + paddingRight: 16, + componentId: 'input', + figmaDerivedLayout: { width: 108, height: 40 } + }) + const generatedText = graph.createNode('TEXT', input.id, { + width: 76, + height: 20, + text: source.text, + textAutoResize: 'WIDTH_AND_HEIGHT', + componentId: source.id, + figmaDerivedLayout: { width: 76, height: 20 } + }) + setTextMeasurer(() => ({ width: 74, height: 20 })) + + computeAllLayouts(graph) + + expect(graph.getNode(generatedText.id)).toMatchObject({ width: 74, height: 20 }) + expect(graph.getNode(input.id)).toMatchObject({ + width: 108, + height: 40, + figmaDerivedLayout: { width: 108, height: 40 } + }) + }) + + test('reshapes stretched generated source text only inside HUG-width parents', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const source = importedText(graph, 'Bar Chart - Interactive', 333, 30, '1:3') + graph.updateNode(source.id, { textAutoResize: 'HEIGHT' }) + const cardHeader = graph.createNode('FRAME', page.id, { + width: 381, + height: 102, + layoutMode: 'VERTICAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'HUG', + paddingLeft: 24, + paddingRight: 24, + componentId: 'card-header', + figmaDerivedLayout: { width: 381, height: 102 } + }) + const generatedText = graph.createNode('TEXT', cardHeader.id, { + width: 333, + height: 30, + text: source.text, + textAutoResize: 'HEIGHT', + layoutAlignSelf: 'STRETCH', + componentId: source.id, + figmaDerivedLayout: { width: 333, height: 30 } + }) + setTextMeasurer(() => ({ width: 329, height: 37 })) + + computeAllLayouts(graph) + + expect(graph.getNode(generatedText.id)).toMatchObject({ width: 329, height: 30 }) + expect(graph.getNode(cardHeader.id)).toMatchObject({ + width: 377, + height: 102, + figmaDerivedLayout: { width: 377, height: 102 } + }) + }) + + test('preserves fixed-width text inherited through the component lineage', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const source = importedText(graph, 'Label', 38, 14, '1:6') + graph.updateNode(source.id, { textAutoResize: 'WIDTH_AND_HEIGHT' }) + const fixedIntermediate = graph.createNode('TEXT', page.id, { + width: 280, + height: 14, + text: 'Email', + textAutoResize: 'HEIGHT', + componentId: source.id, + figmaDerivedLayout: { width: 280, height: 14 } + }) + const generatedText = graph.createNode('TEXT', page.id, { + width: 302, + height: 14, + text: 'Name', + textAutoResize: 'WIDTH_AND_HEIGHT', + componentId: fixedIntermediate.id, + figmaDerivedLayout: { width: 302, height: 14 } + }) + setTextMeasurer(() => ({ width: 36, height: 17 })) + + computeAllLayouts(graph) + + expect(graph.getNode(generatedText.id)).toMatchObject({ width: 302, height: 14 }) + }) + + test('preserves stretched override text and direct imported text bounds', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const source = importedText(graph, 'Source copy', 333, 20, '1:4') + graph.updateNode(source.id, { textAutoResize: 'HEIGHT' }) + const parent = graph.createNode('FRAME', page.id, { + width: 381, + height: 68, + layoutMode: 'VERTICAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'HUG', + paddingLeft: 24, + paddingRight: 24, + componentId: 'parent', + figmaDerivedLayout: { width: 381, height: 68 } + }) + const overrideText = graph.createNode('TEXT', parent.id, { + width: 333, + height: 20, + text: 'A different instance override', + textAutoResize: 'HEIGHT', + layoutAlignSelf: 'STRETCH', + componentId: source.id, + figmaDerivedLayout: { width: 333, height: 20 } + }) + setTextMeasurer(() => ({ width: 329, height: 20 })) + + computeAllLayouts(graph) + + expect(graph.getNode(overrideText.id)).toMatchObject({ width: 333, height: 20 }) + expect(graph.getNode(source.id)).toMatchObject({ width: 333, height: 20 }) + }) +})