Merge pull request #467 from open-pencil/fig-effective-text-shaping
fix(fig): shape generated text and preserve transforms
This commit is contained in:
commit
9f8ae33d84
|
|
@ -49,8 +49,8 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- Match Figma auto-layout spacing, padding, min/max constraints, scalar variable bindings, 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)
|
||||
- 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, transforms, 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)
|
||||
- Restore Anthropic AI connections in the web app instead of failing with a browser endpoint error. (#438)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
import { getNodeLocalMatrix, getWorldMatrix, type SceneNode } from '@open-pencil/scene-graph'
|
||||
import type { Rect } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
import {
|
||||
|
|
@ -9,6 +9,30 @@ import {
|
|||
type ProxyThis
|
||||
} from '#core/figma-api/accessor-utils'
|
||||
import type { NodeProxyHost } from '#core/figma-api/proxy'
|
||||
import type { FigmaTransform } from '#core/figma-api/types'
|
||||
|
||||
const TRANSFORM_FIELDS = new Set(['x', 'y', 'rotation', 'flipX', 'flipY'])
|
||||
|
||||
function preservesRawTransform(node: SceneNode): boolean {
|
||||
return !node.source.editedFields.some((field) => TRANSFORM_FIELDS.has(field))
|
||||
}
|
||||
|
||||
function cleanTransformValue(value: number): number {
|
||||
if (Math.abs(value) < 1e-12) return 0
|
||||
const nearestInteger = Math.round(value)
|
||||
return Math.abs(value - nearestInteger) < 1e-12 ? nearestInteger : value
|
||||
}
|
||||
|
||||
function figmaTransform(matrix: number[]): FigmaTransform {
|
||||
return [
|
||||
[
|
||||
cleanTransformValue(matrix[0]),
|
||||
cleanTransformValue(matrix[1]),
|
||||
cleanTransformValue(matrix[2])
|
||||
],
|
||||
[cleanTransformValue(matrix[3]), cleanTransformValue(matrix[4]), cleanTransformValue(matrix[5])]
|
||||
]
|
||||
}
|
||||
|
||||
export function installBasicNodeProxyAccessors(
|
||||
prototype: object,
|
||||
|
|
@ -66,19 +90,37 @@ export function installBasicNodeProxyAccessors(
|
|||
},
|
||||
rotation: {
|
||||
get(this: ProxyThis): number {
|
||||
return raw(this, internals).rotation
|
||||
const node = raw(this, internals)
|
||||
const sourceTransform = node.source.fig.rawTransform
|
||||
if (sourceTransform && preservesRawTransform(node)) {
|
||||
return Math.atan2(-sourceTransform.m10, sourceTransform.m00) * (180 / Math.PI)
|
||||
}
|
||||
return node.rotation
|
||||
},
|
||||
set(this: ProxyThis, value: number) {
|
||||
graph(this, internals).updateNode(nodeId(this, internals), { rotation: value })
|
||||
}
|
||||
},
|
||||
relativeTransform: {
|
||||
get(this: ProxyThis): FigmaTransform {
|
||||
const node = raw(this, internals)
|
||||
const sourceTransform = node.source.fig.rawTransform
|
||||
if (sourceTransform && preservesRawTransform(node)) {
|
||||
return figmaTransform([
|
||||
sourceTransform.m00,
|
||||
sourceTransform.m01,
|
||||
sourceTransform.m02,
|
||||
sourceTransform.m10,
|
||||
sourceTransform.m11,
|
||||
sourceTransform.m12
|
||||
])
|
||||
}
|
||||
return figmaTransform(getNodeLocalMatrix(node))
|
||||
}
|
||||
},
|
||||
absoluteTransform: {
|
||||
get(this: ProxyThis): [[number, number, number], [number, number, number]] {
|
||||
const pos = graph(this, internals).getAbsolutePosition(nodeId(this, internals))
|
||||
return [
|
||||
[1, 0, pos.x],
|
||||
[0, 1, pos.y]
|
||||
]
|
||||
get(this: ProxyThis): FigmaTransform {
|
||||
return figmaTransform(getWorldMatrix(raw(this, internals), graph(this, internals)))
|
||||
}
|
||||
},
|
||||
absoluteBoundingBox: {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { nodeProxyToJSON } from './serialization'
|
|||
import { setFirstStrokeAlign, setFirstStrokeWeight, setIndependentStrokeWeight } from './strokes'
|
||||
import * as TextProxy from './text'
|
||||
import * as Traversal from './traversal'
|
||||
import type { FigmaTransform } from './types'
|
||||
|
||||
const MIXED = Symbol('mixed')
|
||||
|
||||
|
|
@ -62,9 +63,10 @@ export class FigmaNodeProxy {
|
|||
declare readonly width: number
|
||||
declare readonly height: number
|
||||
declare rotation: number
|
||||
declare readonly relativeTransform: FigmaTransform
|
||||
declare resize: (width: number, height: number) => void
|
||||
declare resizeWithoutConstraints: (width: number, height: number) => void
|
||||
declare readonly absoluteTransform: [[number, number, number], [number, number, number]]
|
||||
declare readonly absoluteTransform: FigmaTransform
|
||||
declare readonly absoluteBoundingBox: Rect
|
||||
declare readonly absoluteRenderBounds: Rect
|
||||
|
||||
|
|
|
|||
1
packages/core/src/figma-api/types.ts
Normal file
1
packages/core/src/figma-api/types.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type FigmaTransform = [[number, number, number], [number, number, number]]
|
||||
|
|
@ -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<SceneNode, 'layoutDirection' | 'parentId'>
|
||||
|
|
@ -63,8 +60,12 @@ function resolveComputedLayoutDirection(
|
|||
}
|
||||
|
||||
export function computeAllLayouts(graph: SceneGraph, scopeId?: string): void {
|
||||
const rootId = scopeId ?? graph.rootId
|
||||
const visited = new Set<string>()
|
||||
computeLayoutsBottomUp(graph, scopeId ?? graph.rootId, visited)
|
||||
computeLayoutsBottomUp(graph, rootId, visited)
|
||||
if (applyEffectiveGeneratedTextLayout(graph, rootId)) {
|
||||
computeLayoutsBottomUp(graph, rootId, new Set())
|
||||
}
|
||||
}
|
||||
|
||||
function computeLayoutsBottomUp(graph: SceneGraph, nodeId: string, visited: Set<string>): void {
|
||||
|
|
|
|||
287
packages/core/src/layout/effective-generated-text.ts
Normal file
287
packages/core/src/layout/effective-generated-text.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
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 stretchesCrossAxis(child: SceneNode, parent: SceneNode): boolean {
|
||||
return (
|
||||
child.layoutAlignSelf === 'STRETCH' ||
|
||||
(child.layoutAlignSelf === 'AUTO' && parent.counterAxisAlign === 'STRETCH')
|
||||
)
|
||||
}
|
||||
|
||||
function participatesInIntrinsicSize(node: SceneNode): boolean {
|
||||
return node.visible && node.layoutPositioning !== 'ABSOLUTE'
|
||||
}
|
||||
|
||||
function intrinsicSize(
|
||||
graph: SceneGraph,
|
||||
node: SceneNode,
|
||||
sizes: ReadonlyMap<string, Size>
|
||||
): Size | null {
|
||||
if (node.layoutMode !== 'HORIZONTAL' && node.layoutMode !== 'VERTICAL') return null
|
||||
const children = graph.getChildren(node.id).filter(participatesInIntrinsicSize)
|
||||
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<string, Size>,
|
||||
affected: ReadonlySet<string>
|
||||
): 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(participatesInIntrinsicSize)
|
||||
if (!children.some((child) => affected.has(child.id))) return intrinsic
|
||||
const widthCandidates = children.filter(
|
||||
(child) => affected.has(child.id) || !stretchesCrossAxis(child, node)
|
||||
)
|
||||
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<string, Size>,
|
||||
affected: Set<string>
|
||||
): void {
|
||||
const oldContentWidth = oldIntrinsicWidth - node.paddingLeft - node.paddingRight
|
||||
const nextContentWidth = nextWidth - node.paddingLeft - node.paddingRight
|
||||
for (const child of graph.getChildren(node.id)) {
|
||||
if (
|
||||
!participatesInIntrinsicSize(child) ||
|
||||
!stretchesCrossAxis(child, node) ||
|
||||
Math.abs(child.width - oldContentWidth) >= 0.001
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const updates: Partial<SceneNode> = { 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<string, Size>,
|
||||
affected: Set<string>
|
||||
): 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<string, Size>,
|
||||
currentSizes: Map<string, Size>,
|
||||
affected: Set<string>
|
||||
): 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<SceneNode> = {}
|
||||
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): boolean {
|
||||
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<string>()
|
||||
|
||||
updateGeneratedTextWidths(graph, nodes, currentSizes, affected)
|
||||
if (affected.size === 0) return false
|
||||
propagateIntrinsicSizes(graph, nodes, originalSizes, currentSizes, affected)
|
||||
return true
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -3,6 +3,65 @@ import { describe, expect, test } from 'bun:test'
|
|||
import { createAPI } from '../helpers'
|
||||
|
||||
describe('absolute position', () => {
|
||||
test('relative and absolute transforms include rotation and reflection', () => {
|
||||
const api = createAPI()
|
||||
const parent = api.createFrame()
|
||||
parent.x = 100
|
||||
parent.y = 200
|
||||
const child = api.createRectangle()
|
||||
parent.appendChild(child)
|
||||
child.x = 10
|
||||
child.y = 20
|
||||
child.resize(50, 30)
|
||||
child.rotation = 90
|
||||
|
||||
expect(child.relativeTransform).toEqual([
|
||||
[0, -1, 50],
|
||||
[1, 0, 10]
|
||||
])
|
||||
expect(child.absoluteTransform).toEqual([
|
||||
[0, -1, 150],
|
||||
[1, 0, 210]
|
||||
])
|
||||
})
|
||||
|
||||
test('exposes preserved FIG reflection transforms and Figma rotation', () => {
|
||||
const api = createAPI()
|
||||
const node = api.createVector()
|
||||
const raw = api.graph.getNode(node.id)
|
||||
expect(raw).toBeDefined()
|
||||
if (!raw) return
|
||||
api.graph.updateNode(node.id, {
|
||||
x: 5,
|
||||
y: 8,
|
||||
width: 14,
|
||||
height: 8,
|
||||
rotation: -180,
|
||||
flipX: true,
|
||||
source: {
|
||||
...raw.source,
|
||||
format: 'fig',
|
||||
id: '94:5463',
|
||||
fig: {
|
||||
...raw.source.fig,
|
||||
rawTransform: { m00: 1, m01: 0, m02: 5, m10: 0, m11: -1, m12: 16 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(node.x).toBe(5)
|
||||
expect(node.y).toBe(8)
|
||||
expect(node.rotation).toBe(-0)
|
||||
expect(node.relativeTransform).toEqual([
|
||||
[1, 0, 5],
|
||||
[0, -1, 16]
|
||||
])
|
||||
expect(node.absoluteTransform).toEqual([
|
||||
[1, 0, 5],
|
||||
[0, -1, 16]
|
||||
])
|
||||
})
|
||||
|
||||
test('absoluteBoundingBox accounts for nesting', () => {
|
||||
const api = createAPI()
|
||||
const parent = api.createFrame()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,284 @@
|
|||
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('repositions centered generated text after effective shaping', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const source = importedText(graph, 'Centered label', 100, 20, '1:7')
|
||||
const parent = graph.createNode('FRAME', page.id, {
|
||||
width: 200,
|
||||
height: 40,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
primaryAxisAlign: 'CENTER'
|
||||
})
|
||||
const generatedText = graph.createNode('TEXT', parent.id, {
|
||||
width: 100,
|
||||
height: 20,
|
||||
text: source.text,
|
||||
textAutoResize: 'WIDTH_AND_HEIGHT',
|
||||
componentId: source.id,
|
||||
figmaDerivedLayout: { width: 100, height: 20 }
|
||||
})
|
||||
setTextMeasurer(() => ({ width: 80, height: 20 }))
|
||||
|
||||
computeAllLayouts(graph)
|
||||
|
||||
expect(graph.getNode(generatedText.id)).toMatchObject({ x: 60, width: 80 })
|
||||
})
|
||||
|
||||
test('resizes visible inherited-stretch children but preserves excluded children', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const source = importedText(graph, 'Chart heading', 100, 20, '1:8')
|
||||
const parent = graph.createNode('FRAME', page.id, {
|
||||
width: 120,
|
||||
height: 80,
|
||||
layoutMode: 'VERTICAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'HUG',
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
counterAxisAlign: 'STRETCH',
|
||||
componentId: 'parent',
|
||||
figmaDerivedLayout: { width: 120, height: 80 }
|
||||
})
|
||||
const generatedText = graph.createNode('TEXT', parent.id, {
|
||||
width: 100,
|
||||
height: 20,
|
||||
text: source.text,
|
||||
textAutoResize: 'WIDTH_AND_HEIGHT',
|
||||
componentId: source.id,
|
||||
figmaDerivedLayout: { width: 100, height: 20 }
|
||||
})
|
||||
const inheritedStretch = graph.createNode('RECTANGLE', parent.id, {
|
||||
width: 100,
|
||||
height: 10,
|
||||
layoutAlignSelf: 'AUTO',
|
||||
figmaDerivedLayout: { width: 100, height: 10 }
|
||||
})
|
||||
const hiddenStretch = graph.createNode('RECTANGLE', parent.id, {
|
||||
width: 100,
|
||||
height: 10,
|
||||
visible: false,
|
||||
layoutAlignSelf: 'AUTO',
|
||||
figmaDerivedLayout: { width: 100, height: 10 }
|
||||
})
|
||||
const absoluteStretch = graph.createNode('RECTANGLE', parent.id, {
|
||||
width: 100,
|
||||
height: 10,
|
||||
layoutPositioning: 'ABSOLUTE',
|
||||
layoutAlignSelf: 'AUTO',
|
||||
figmaDerivedLayout: { width: 100, height: 10 }
|
||||
})
|
||||
setTextMeasurer(() => ({ width: 80, height: 20 }))
|
||||
|
||||
computeAllLayouts(graph)
|
||||
|
||||
expect(graph.getNode(parent.id)).toMatchObject({ width: 100 })
|
||||
expect(graph.getNode(inheritedStretch.id)).toMatchObject({ width: 80 })
|
||||
expect(graph.getNode(hiddenStretch.id)).toMatchObject({ width: 100 })
|
||||
expect(graph.getNode(absoluteStretch.id)).toMatchObject({ width: 100 })
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue