Merge branch 'master' into polish-pr-437
This commit is contained in:
commit
a63de8caaf
|
|
@ -49,7 +49,8 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- 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)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@
|
|||
"label": "Open…",
|
||||
"accelerator": "CmdOrCtrl+O"
|
||||
},
|
||||
{
|
||||
"id": "open-storage-workspace",
|
||||
"label": "Open Storage Workspace…"
|
||||
},
|
||||
{
|
||||
"type": "separator"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
@ -8,8 +8,8 @@ import {
|
|||
nodeChangeToProps,
|
||||
shouldImportTextAsAutoSize,
|
||||
sortChildren,
|
||||
setVariableColorResolver,
|
||||
VARIABLE_BINDING_FIELDS_INVERSE
|
||||
resolveVariableConsumptionEntry,
|
||||
setVariableColorResolver
|
||||
} from '@open-pencil/fig/node-change'
|
||||
import type { NodeChange, VariableDataValuesEntry, Color, GUID } from '@open-pencil/kiwi/fig/codec'
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
|
@ -351,10 +351,8 @@ function importVariableBindings(
|
|||
const nodeId = guidToNodeId.get(ncId)
|
||||
if (!nodeId) continue
|
||||
for (const entry of nc.variableConsumptionMap.entries) {
|
||||
const varGuid = entry.variableData?.value?.alias?.guid
|
||||
if (!varGuid) continue
|
||||
const field = VARIABLE_BINDING_FIELDS_INVERSE[entry.variableField ?? '']
|
||||
if (field) graph.bindVariable(nodeId, field, guidToString(varGuid))
|
||||
const binding = resolveVariableConsumptionEntry(entry)
|
||||
if (binding) graph.bindVariable(nodeId, binding.field, binding.variableId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
} from 'yoga-layout'
|
||||
|
||||
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 {
|
||||
|
|
@ -39,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'>
|
||||
|
|
@ -62,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 {
|
||||
|
|
@ -84,8 +86,6 @@ function preservesImportedInstanceLayout(node: SceneNode): boolean {
|
|||
return node.type === 'INSTANCE' && node.source.format === 'fig'
|
||||
}
|
||||
|
||||
// --- Flex layout ---
|
||||
|
||||
function buildYogaTree(
|
||||
graph: SceneGraph,
|
||||
frame: SceneNode,
|
||||
|
|
@ -118,7 +118,7 @@ function buildYogaTree(
|
|||
} else if (child.layoutMode !== 'NONE') {
|
||||
configureChildAsAutoLayout(yogaChild, child, frame, graph, direction)
|
||||
} else {
|
||||
configureChildAsLeaf(yogaChild, child, frame)
|
||||
configureChildAsLeaf(yogaChild, child, frame, graph)
|
||||
}
|
||||
|
||||
root.insertChild(yogaChild, root.getChildCount())
|
||||
|
|
@ -150,13 +150,14 @@ function configureFlexContainer(
|
|||
yogaNode.setPadding(Edge.Bottom, node.paddingBottom)
|
||||
yogaNode.setPadding(Edge.Left, node.paddingLeft)
|
||||
|
||||
const primaryGap = node.primaryAxisAlign === 'SPACE_BETWEEN' ? 0 : node.itemSpacing
|
||||
yogaNode.setGap(
|
||||
Gutter.Column,
|
||||
node.layoutMode === 'HORIZONTAL' ? node.itemSpacing : node.counterAxisSpacing
|
||||
node.layoutMode === 'HORIZONTAL' ? primaryGap : node.counterAxisSpacing
|
||||
)
|
||||
yogaNode.setGap(
|
||||
Gutter.Row,
|
||||
node.layoutMode === 'HORIZONTAL' ? node.counterAxisSpacing : node.itemSpacing
|
||||
node.layoutMode === 'HORIZONTAL' ? node.counterAxisSpacing : primaryGap
|
||||
)
|
||||
|
||||
applyMinMaxConstraints(yogaNode, node)
|
||||
|
|
@ -229,6 +230,91 @@ function configureChildAsGrid(
|
|||
}
|
||||
}
|
||||
|
||||
type AxisSizing = SceneNode['primaryAxisSizing']
|
||||
function sizesFitParent(
|
||||
parent: SceneNode,
|
||||
childCount: number,
|
||||
sizes: Array<number | undefined>,
|
||||
axis: 'width' | 'height'
|
||||
): boolean {
|
||||
if (sizes.some((size) => size === undefined)) return false
|
||||
const padding =
|
||||
axis === 'width'
|
||||
? parent.paddingLeft + parent.paddingRight
|
||||
: parent.paddingTop + parent.paddingBottom
|
||||
const gap =
|
||||
parent.primaryAxisAlign === 'SPACE_BETWEEN'
|
||||
? 0
|
||||
: parent.itemSpacing * Math.max(0, childCount - 1)
|
||||
const available = axis === 'width' ? parent.width : parent.height
|
||||
const total = sizes.reduce<number>((sum, size) => sum + (size ?? 0), padding + gap)
|
||||
return Math.abs(total - available) < 0.001
|
||||
}
|
||||
|
||||
function derivedMainAxisFitsParent(
|
||||
graph: SceneGraph,
|
||||
parent: SceneNode,
|
||||
child: SceneNode,
|
||||
axis: 'width' | 'height'
|
||||
): boolean {
|
||||
const children = graph
|
||||
.getChildren(parent.id)
|
||||
.filter((candidate) => candidate.visible && candidate.layoutPositioning !== 'ABSOLUTE')
|
||||
if (children.length === 0) return false
|
||||
|
||||
const sizes = children.map((candidate) => candidate.figmaDerivedLayout?.[axis])
|
||||
return (
|
||||
sizesFitParent(parent, children.length, sizes, axis) &&
|
||||
child.figmaDerivedLayout?.[axis] !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
function usesAuthoritativeGeneratedStretch(parent: SceneNode, child: SceneNode): boolean {
|
||||
if (
|
||||
child.layoutAlignSelf !== 'STRETCH' ||
|
||||
parent.source.format === 'fig' ||
|
||||
!parent.figmaDerivedLayout
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const derivedCrossSize =
|
||||
parent.layoutMode === 'HORIZONTAL'
|
||||
? parent.figmaDerivedLayout.height
|
||||
: parent.figmaDerivedLayout.width
|
||||
const parentCrossSize = parent.layoutMode === 'HORIZONTAL' ? parent.height : parent.width
|
||||
return derivedCrossSize !== undefined && Math.abs(derivedCrossSize - parentCrossSize) < 0.001
|
||||
}
|
||||
|
||||
function configureAutoLayoutChildSizing(
|
||||
yogaChild: YogaNode,
|
||||
child: SceneNode,
|
||||
parent: SceneNode,
|
||||
graph: SceneGraph,
|
||||
widthSizing: AxisSizing,
|
||||
heightSizing: AxisSizing
|
||||
): void {
|
||||
const isParentRow = parent.layoutMode === 'HORIZONTAL'
|
||||
const fixedDerivedMainAxis = isParentRow
|
||||
? derivedMainAxisFitsParent(graph, parent, child, 'width')
|
||||
: derivedMainAxisFitsParent(graph, parent, child, 'height')
|
||||
const stretchesAuthoritativeCrossAxis = usesAuthoritativeGeneratedStretch(parent, child)
|
||||
|
||||
if (isParentRow) {
|
||||
if (fixedDerivedMainAxis) yogaChild.setWidth(child.figmaDerivedLayout?.width ?? child.width)
|
||||
else setMainAxisSizing(yogaChild, 'width', widthSizing, child.width, child.layoutGrow)
|
||||
if (!stretchesAuthoritativeCrossAxis) {
|
||||
setCrossAxisSizing(yogaChild, 'height', heightSizing, child.height)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!stretchesAuthoritativeCrossAxis) {
|
||||
setCrossAxisSizing(yogaChild, 'width', widthSizing, child.width)
|
||||
}
|
||||
if (fixedDerivedMainAxis) yogaChild.setHeight(child.figmaDerivedLayout?.height ?? child.height)
|
||||
else setMainAxisSizing(yogaChild, 'height', heightSizing, child.height, child.layoutGrow)
|
||||
}
|
||||
|
||||
function configureChildAsAutoLayout(
|
||||
yogaChild: YogaNode,
|
||||
child: SceneNode,
|
||||
|
|
@ -237,25 +323,23 @@ function configureChildAsAutoLayout(
|
|||
inheritedDirection: 'LTR' | 'RTL'
|
||||
): void {
|
||||
const direction = resolveNodeLayoutDirection(child, inheritedDirection)
|
||||
const isParentRow = parent.layoutMode === 'HORIZONTAL'
|
||||
const isChildRow = child.layoutMode === 'HORIZONTAL'
|
||||
|
||||
const widthSizing = isChildRow ? child.primaryAxisSizing : child.counterAxisSizing
|
||||
const heightSizing = isChildRow ? child.counterAxisSizing : child.primaryAxisSizing
|
||||
|
||||
// Main axis: width for row parent, height for col parent — use grow for FILL
|
||||
// Cross axis: height for row parent, width for col parent — use stretch for FILL
|
||||
if (isParentRow) {
|
||||
setMainAxisSizing(yogaChild, 'width', widthSizing, child.width, child.layoutGrow)
|
||||
setCrossAxisSizing(yogaChild, 'height', heightSizing, child.height)
|
||||
} else {
|
||||
setCrossAxisSizing(yogaChild, 'width', widthSizing, child.width)
|
||||
setMainAxisSizing(yogaChild, 'height', heightSizing, child.height, child.layoutGrow)
|
||||
}
|
||||
configureAutoLayoutChildSizing(yogaChild, child, parent, graph, widthSizing, heightSizing)
|
||||
|
||||
const selfAlign = mapAlignSelf(child.layoutAlignSelf)
|
||||
if (selfAlign != null) yogaChild.setAlignSelf(selfAlign)
|
||||
|
||||
if (usesDetachedDerivedLayout(child)) {
|
||||
const derived = child.figmaDerivedLayout
|
||||
if (widthSizing === 'HUG') yogaChild.setWidth(derived?.width ?? child.width)
|
||||
if (heightSizing === 'HUG') yogaChild.setHeight(derived?.height ?? child.height)
|
||||
applyMinMaxConstraints(yogaChild, child)
|
||||
return
|
||||
}
|
||||
|
||||
configureFlexContainer(yogaChild, child, direction)
|
||||
|
||||
const grandchildren = graph.getChildren(child.id)
|
||||
|
|
@ -270,13 +354,75 @@ function configureChildAsAutoLayout(
|
|||
} else if (gc.layoutMode !== 'NONE') {
|
||||
configureChildAsAutoLayout(yogaGC, gc, child, graph, direction)
|
||||
} else {
|
||||
configureChildAsLeaf(yogaGC, gc, child)
|
||||
configureChildAsLeaf(yogaGC, gc, child, graph)
|
||||
}
|
||||
yogaChild.insertChild(yogaGC, yogaChild.getChildCount())
|
||||
}
|
||||
}
|
||||
|
||||
function configureChildAsLeaf(yogaChild: YogaNode, child: SceneNode, parent: SceneNode): void {
|
||||
function derivedGrowingLeafFitsParent(
|
||||
graph: SceneGraph,
|
||||
parent: SceneNode,
|
||||
child: SceneNode,
|
||||
axis: 'width' | 'height'
|
||||
): boolean {
|
||||
if (
|
||||
child.type !== 'TEXT' ||
|
||||
child.layoutGrow <= 0 ||
|
||||
child.figmaDerivedLayout?.[axis] === undefined
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const children = graph
|
||||
.getChildren(parent.id)
|
||||
.filter((candidate) => candidate.visible && candidate.layoutPositioning !== 'ABSOLUTE')
|
||||
const sizes = children.map((candidate) => {
|
||||
if (candidate.layoutGrow > 0) return candidate.figmaDerivedLayout?.[axis]
|
||||
return axis === 'width' ? candidate.width : candidate.height
|
||||
})
|
||||
return sizesFitParent(parent, children.length, sizes, axis)
|
||||
}
|
||||
|
||||
function configureTextLeafWithoutMeasurer(
|
||||
yogaChild: YogaNode,
|
||||
child: SceneNode,
|
||||
parent: SceneNode,
|
||||
fixedDerivedMainAxis: boolean
|
||||
): void {
|
||||
const hasStoredSize =
|
||||
child.width > 0 && child.height > 0 && !(child.width === 100 && child.height === 100)
|
||||
|
||||
if (child.textAutoResize === 'WIDTH_AND_HEIGHT') {
|
||||
if (hasStoredSize) {
|
||||
yogaChild.setWidth(child.width)
|
||||
yogaChild.setHeight(child.height)
|
||||
} else {
|
||||
const estimated = estimateTextSize(child)
|
||||
yogaChild.setWidth(estimated.width)
|
||||
yogaChild.setHeight(estimated.height)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (child.textAutoResize !== 'HEIGHT') return
|
||||
|
||||
const isRow = parent.layoutMode === 'HORIZONTAL'
|
||||
const measurementWidth = fixedDerivedMainAxis
|
||||
? (child.figmaDerivedLayout?.width ?? child.width)
|
||||
: child.width
|
||||
const stretches =
|
||||
child.layoutAlignSelf === 'STRETCH' ||
|
||||
(child.layoutAlignSelf === 'AUTO' && parent.counterAxisAlign === 'STRETCH')
|
||||
if (!(!isRow && stretches) && !fixedDerivedMainAxis) yogaChild.setWidth(child.width)
|
||||
if (hasStoredSize) yogaChild.setHeight(child.height)
|
||||
else yogaChild.setHeight(estimateTextSize(child, measurementWidth).height)
|
||||
}
|
||||
|
||||
function configureChildAsLeaf(
|
||||
yogaChild: YogaNode,
|
||||
child: SceneNode,
|
||||
parent: SceneNode,
|
||||
graph: SceneGraph
|
||||
): void {
|
||||
const isRow = parent.layoutMode === 'HORIZONTAL'
|
||||
const selfOverride = child.layoutAlignSelf !== 'AUTO'
|
||||
const stretchCross = selfOverride
|
||||
|
|
@ -287,39 +433,19 @@ function configureChildAsLeaf(yogaChild: YogaNode, child: SceneNode, parent: Sce
|
|||
const textMeasurer = getTextMeasurer()
|
||||
const needsMeasureFunc = isText && textMeasurer && child.textAutoResize !== 'NONE'
|
||||
|
||||
if (needsMeasureFunc) {
|
||||
configureTextLeaf(yogaChild, child, parent)
|
||||
} else if (isText && !textMeasurer && child.textAutoResize !== 'NONE') {
|
||||
// No CanvasKit — prefer stored dimensions from .fig import (Figma's
|
||||
// ground truth) over the rough character-count estimate. Only fall back
|
||||
// to estimateTextSize for newly-created nodes that still carry the
|
||||
// 100×100 default SceneNode size.
|
||||
const hasStoredSize =
|
||||
child.width > 0 && child.height > 0 && !(child.width === 100 && child.height === 100)
|
||||
const fixedDerivedMainAxis = isRow
|
||||
? derivedGrowingLeafFitsParent(graph, parent, child, 'width')
|
||||
: derivedGrowingLeafFitsParent(graph, parent, child, 'height')
|
||||
|
||||
if (child.textAutoResize === 'WIDTH_AND_HEIGHT') {
|
||||
if (hasStoredSize) {
|
||||
yogaChild.setWidth(child.width)
|
||||
yogaChild.setHeight(child.height)
|
||||
} else {
|
||||
const est = estimateTextSize(child)
|
||||
yogaChild.setWidth(est.width)
|
||||
yogaChild.setHeight(est.height)
|
||||
}
|
||||
} else if (child.textAutoResize === 'HEIGHT') {
|
||||
const stretches =
|
||||
child.layoutAlignSelf === 'STRETCH' ||
|
||||
(child.layoutAlignSelf === 'AUTO' && parent.counterAxisAlign === 'STRETCH')
|
||||
if (!(!isRow && stretches)) {
|
||||
yogaChild.setWidth(child.width)
|
||||
}
|
||||
if (hasStoredSize) {
|
||||
yogaChild.setHeight(child.height)
|
||||
} else {
|
||||
const est = estimateTextSize(child, child.width)
|
||||
yogaChild.setHeight(est.height)
|
||||
}
|
||||
}
|
||||
if (fixedDerivedMainAxis) {
|
||||
if (isRow) yogaChild.setWidth(child.figmaDerivedLayout?.width ?? child.width)
|
||||
else yogaChild.setHeight(child.figmaDerivedLayout?.height ?? child.height)
|
||||
}
|
||||
|
||||
if (needsMeasureFunc) {
|
||||
configureTextLeaf(yogaChild, child, parent, fixedDerivedMainAxis)
|
||||
} else if (isText && !textMeasurer && child.textAutoResize !== 'NONE') {
|
||||
configureTextLeafWithoutMeasurer(yogaChild, child, parent, fixedDerivedMainAxis)
|
||||
} else {
|
||||
configureNonTextLeaf(yogaChild, child, isRow, stretchCross)
|
||||
}
|
||||
|
|
@ -330,11 +456,16 @@ function configureChildAsLeaf(yogaChild: YogaNode, child: SceneNode, parent: Sce
|
|||
applyMinMaxConstraints(yogaChild, child)
|
||||
}
|
||||
|
||||
function configureTextLeaf(yogaChild: YogaNode, child: SceneNode, parent: SceneNode): void {
|
||||
function configureTextLeaf(
|
||||
yogaChild: YogaNode,
|
||||
child: SceneNode,
|
||||
parent: SceneNode,
|
||||
fixedDerivedMainAxis = false
|
||||
): void {
|
||||
const autoResize = child.textAutoResize
|
||||
const isRow = parent.layoutMode === 'HORIZONTAL'
|
||||
|
||||
if (child.layoutGrow > 0) {
|
||||
if (child.layoutGrow > 0 && !fixedDerivedMainAxis) {
|
||||
yogaChild.setFlexGrow(child.layoutGrow)
|
||||
}
|
||||
|
||||
|
|
@ -364,11 +495,11 @@ function configureTextLeaf(yogaChild: YogaNode, child: SceneNode, parent: SceneN
|
|||
const stretchesCross =
|
||||
child.layoutAlignSelf === 'STRETCH' ||
|
||||
(child.layoutAlignSelf === 'AUTO' && parent.counterAxisAlign === 'STRETCH')
|
||||
// Don't set fixed width when text stretches on cross axis (w="fill" in
|
||||
// flex="col" parent) — setWidth blocks Yoga's alignSelf:stretch, leaving
|
||||
// text at 100px default instead of filling the parent.
|
||||
// Let Yoga stretch fill-width text instead of fixing its stored width.
|
||||
const fillsWidth = !isRow && stretchesCross
|
||||
const fixedWidth = child.width
|
||||
const fixedWidth = fixedDerivedMainAxis
|
||||
? (child.figmaDerivedLayout?.width ?? child.width)
|
||||
: child.width
|
||||
if (child.layoutGrow <= 0 && !fillsWidth) {
|
||||
yogaChild.setWidth(fixedWidth)
|
||||
}
|
||||
|
|
@ -423,7 +554,7 @@ function configureNonTextLeaf(
|
|||
function setMainAxisSizing(
|
||||
yogaNode: YogaNode,
|
||||
axis: 'width' | 'height',
|
||||
sizing: string,
|
||||
sizing: AxisSizing,
|
||||
fixedValue: number,
|
||||
grow: number
|
||||
): void {
|
||||
|
|
@ -452,7 +583,7 @@ function setMainAxisSizing(
|
|||
function setCrossAxisSizing(
|
||||
yogaNode: YogaNode,
|
||||
axis: 'width' | 'height',
|
||||
sizing: string,
|
||||
sizing: AxisSizing,
|
||||
fixedValue: number
|
||||
): void {
|
||||
switch (sizing) {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,26 @@ import type { Node as YogaNode } from 'yoga-layout'
|
|||
|
||||
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
import { usesDetachedDerivedLayout } from './derived'
|
||||
|
||||
export type ComputeLayoutFn = (graph: SceneGraph, frameId: string) => void
|
||||
|
||||
function preservesImportedHugCrossSize(
|
||||
graph: SceneGraph,
|
||||
frame: SceneNode,
|
||||
axis: 'width' | 'height'
|
||||
): boolean {
|
||||
if (frame.source.format !== 'fig' || frame.counterAxisSizing !== 'HUG') return false
|
||||
const expectedMode = axis === 'width' ? 'VERTICAL' : 'HORIZONTAL'
|
||||
if (frame.layoutMode !== expectedMode) return false
|
||||
return graph
|
||||
.getChildren(frame.id)
|
||||
.some(
|
||||
(child) =>
|
||||
child.layoutAlignSelf === 'STRETCH' && child.figmaDerivedLayout?.[axis] !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
function applyFrameSize(graph: SceneGraph, frame: SceneNode, yogaNode: YogaNode): void {
|
||||
if (frame.layoutMode === 'GRID') {
|
||||
if (frame.gridTemplateRows.length === 0) {
|
||||
|
|
@ -24,28 +42,77 @@ function applyFrameSize(graph: SceneGraph, frame: SceneNode, yogaNode: YogaNode)
|
|||
else updates.height = derived?.height ?? computedH
|
||||
}
|
||||
if (frame.counterAxisSizing === 'HUG') {
|
||||
if (frame.layoutMode === 'HORIZONTAL') updates.height = derived?.height ?? computedH
|
||||
else updates.width = derived?.width ?? computedW
|
||||
if (frame.layoutMode === 'HORIZONTAL') {
|
||||
updates.height = preservesImportedHugCrossSize(graph, frame, 'height')
|
||||
? frame.height
|
||||
: (derived?.height ?? computedH)
|
||||
} else {
|
||||
updates.width = preservesImportedHugCrossSize(graph, frame, 'width')
|
||||
? frame.width
|
||||
: (derived?.width ?? computedW)
|
||||
}
|
||||
}
|
||||
|
||||
graph.updateNode(frame.id, updates)
|
||||
}
|
||||
|
||||
function frameSourceIsFig(graph: SceneGraph, parentId: string | null): boolean {
|
||||
return parentId ? graph.getNode(parentId)?.source.format === 'fig' : false
|
||||
}
|
||||
|
||||
function computedChildPosition(
|
||||
child: SceneNode,
|
||||
yogaChild: YogaNode,
|
||||
axis: 'x' | 'y',
|
||||
preservesImportedGeometry: boolean
|
||||
): number {
|
||||
if (preservesImportedGeometry) return child[axis]
|
||||
const computed = axis === 'x' ? yogaChild.getComputedLeft() : yogaChild.getComputedTop()
|
||||
if (child.type === 'INSTANCE') return computed
|
||||
return child.figmaDerivedLayout?.[axis] ?? computed
|
||||
}
|
||||
|
||||
function preservesStaleImportedTextSize(child: SceneNode, axis: 'width' | 'height'): boolean {
|
||||
const derivedSize = child.figmaDerivedLayout?.[axis]
|
||||
return (
|
||||
child.type === 'TEXT' &&
|
||||
child.source.format === 'fig' &&
|
||||
derivedSize !== undefined &&
|
||||
Math.abs(child[axis] - derivedSize) > 0.001
|
||||
)
|
||||
}
|
||||
|
||||
function computedChildSize(
|
||||
child: SceneNode,
|
||||
yogaChild: YogaNode,
|
||||
axis: 'width' | 'height',
|
||||
preservesImportedFrameGeometry: boolean
|
||||
): number {
|
||||
if (preservesImportedFrameGeometry || preservesStaleImportedTextSize(child, axis)) {
|
||||
return child[axis]
|
||||
}
|
||||
const computed = axis === 'width' ? yogaChild.getComputedWidth() : yogaChild.getComputedHeight()
|
||||
if (child.type === 'TEXT' && child.source.format === 'fig') {
|
||||
return computed > 0 ? computed : child[axis]
|
||||
}
|
||||
return child.figmaDerivedLayout?.[axis] ?? computed
|
||||
}
|
||||
|
||||
function updateChildFromYoga(graph: SceneGraph, child: SceneNode, yogaChild: YogaNode): void {
|
||||
if (!child.visible || child.layoutPositioning === 'ABSOLUTE') return
|
||||
|
||||
const derived = child.figmaDerivedLayout
|
||||
const preservesImportedFrameGeometry =
|
||||
child.type === 'FRAME' &&
|
||||
child.source.format === 'fig' &&
|
||||
frameSourceIsFig(graph, child.parentId)
|
||||
const preservesImportedPosition =
|
||||
preservesImportedFrameGeometry ||
|
||||
(child.source.format === 'fig' && Math.abs(child.rotation) > 0.001)
|
||||
graph.updateNode(child.id, {
|
||||
x:
|
||||
child.type === 'INSTANCE'
|
||||
? yogaChild.getComputedLeft()
|
||||
: (derived?.x ?? yogaChild.getComputedLeft()),
|
||||
y:
|
||||
child.type === 'INSTANCE'
|
||||
? yogaChild.getComputedTop()
|
||||
: (derived?.y ?? yogaChild.getComputedTop()),
|
||||
width: derived?.width ?? yogaChild.getComputedWidth(),
|
||||
height: derived?.height ?? yogaChild.getComputedHeight()
|
||||
x: computedChildPosition(child, yogaChild, 'x', preservesImportedPosition),
|
||||
y: computedChildPosition(child, yogaChild, 'y', preservesImportedPosition),
|
||||
width: computedChildSize(child, yogaChild, 'width', preservesImportedFrameGeometry),
|
||||
height: computedChildSize(child, yogaChild, 'height', preservesImportedFrameGeometry)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -94,16 +161,18 @@ export function applyYogaLayout(
|
|||
|
||||
updateChildFromYoga(graph, child, yogaChild)
|
||||
|
||||
if (!child.visible) continue
|
||||
if (preservesImportedInstanceInternals(child)) continue
|
||||
|
||||
if (usesDetachedDerivedLayout(child)) {
|
||||
computeLayout(graph, child.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (child.layoutMode !== 'NONE') {
|
||||
if (child.layoutMode === 'GRID' && child.visible && child.layoutPositioning !== 'ABSOLUTE') {
|
||||
if (child.layoutMode === 'GRID' && child.layoutPositioning !== 'ABSOLUTE') {
|
||||
computeLayout(graph, child.id)
|
||||
} else if (
|
||||
frame.layoutMode === 'GRID' &&
|
||||
child.visible &&
|
||||
child.layoutPositioning !== 'ABSOLUTE'
|
||||
) {
|
||||
} else if (frame.layoutMode === 'GRID' && child.layoutPositioning !== 'ABSOLUTE') {
|
||||
recomputeGridChild(graph, child, computeLayout)
|
||||
} else {
|
||||
applyYogaLayout(graph, child, yogaChild, computeLayout)
|
||||
|
|
|
|||
13
packages/core/src/layout/derived.ts
Normal file
13
packages/core/src/layout/derived.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
export function usesDetachedDerivedLayout(child: SceneNode): boolean {
|
||||
const derived = child.figmaDerivedLayout
|
||||
if (!derived || child.layoutMode === 'NONE' || child.layoutGrow > 0) return false
|
||||
const isRow = child.layoutMode === 'HORIZONTAL'
|
||||
const widthSizing = isRow ? child.primaryAxisSizing : child.counterAxisSizing
|
||||
const heightSizing = isRow ? child.counterAxisSizing : child.primaryAxisSizing
|
||||
return (
|
||||
(widthSizing === 'HUG' && derived.width !== undefined) ||
|
||||
(heightSizing === 'HUG' && derived.height !== undefined)
|
||||
)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import type { SceneGraph, SceneNode, VectorNetwork } from '@open-pencil/scene-graph'
|
||||
import { copyGeometryPaths, scaleGeometryPaths } from '@open-pencil/scene-graph/copy'
|
||||
import { constrainedChildRect } from '@open-pencil/scene-graph/resize'
|
||||
|
||||
import { isFieldProtected } from './patches'
|
||||
import { buildClonesMap } from './sync'
|
||||
import type { OverrideContext } from './types'
|
||||
import { overrideCandidates } from './utils'
|
||||
|
||||
const MAX_CLONE_CHAIN_DEPTH = 10
|
||||
|
||||
/**
|
||||
* Apply SCALE constraint resizing to children of instances whose size
|
||||
* differs from their component's original size, then propagate the
|
||||
|
|
@ -21,7 +25,7 @@ export function applyConstraintScaling(ctx: OverrideContext): void {
|
|||
const basis = resolveScaleBasis(graph, node, comp)
|
||||
if (!basis) continue
|
||||
|
||||
// Skip if instance uses auto-layout — layout engine handles child sizing
|
||||
positionPinnedAbsoluteChildren(ctx, node, basis)
|
||||
if (node.layoutMode !== 'NONE') continue
|
||||
|
||||
const sx = node.width / basis.width
|
||||
|
|
@ -44,18 +48,118 @@ export function applyConstraintScaling(ctx: OverrideContext): void {
|
|||
}
|
||||
|
||||
if (scaled.size > 0) propagateScaling(ctx, scaled)
|
||||
normalizeOutOfBoundsSingleChildren(ctx)
|
||||
}
|
||||
|
||||
function isCloneOfSource(graph: SceneGraph, child: SceneNode, sourceId: string): boolean {
|
||||
let current: SceneNode | undefined = child
|
||||
for (let depth = 0; depth < MAX_CLONE_CHAIN_DEPTH && current?.componentId; depth++) {
|
||||
if (current.componentId === sourceId) return true
|
||||
current = graph.getNode(current.componentId)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function pinnedPositionUpdates(
|
||||
ctx: OverrideContext,
|
||||
child: SceneNode,
|
||||
resized: ReturnType<typeof constrainedChildRect>
|
||||
): Partial<SceneNode> {
|
||||
const updates: Partial<SceneNode> = {}
|
||||
const horizontalPinned =
|
||||
child.horizontalConstraint === 'MAX' || child.horizontalConstraint === 'CENTER'
|
||||
const verticalPinned = child.verticalConstraint === 'MAX' || child.verticalConstraint === 'CENTER'
|
||||
if (
|
||||
horizontalPinned &&
|
||||
child.figmaDerivedLayout?.x === undefined &&
|
||||
!isFieldProtected(ctx.protectedFields, child.id, 'x') &&
|
||||
child.x !== resized.x
|
||||
) {
|
||||
updates.x = resized.x
|
||||
}
|
||||
if (
|
||||
verticalPinned &&
|
||||
child.figmaDerivedLayout?.y === undefined &&
|
||||
!isFieldProtected(ctx.protectedFields, child.id, 'y') &&
|
||||
child.y !== resized.y
|
||||
) {
|
||||
updates.y = resized.y
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
function stretchedChildSizeUpdates(
|
||||
ctx: OverrideContext,
|
||||
child: SceneNode,
|
||||
resized: ReturnType<typeof constrainedChildRect>
|
||||
): Partial<SceneNode> {
|
||||
const updates: Partial<SceneNode> = {}
|
||||
if (
|
||||
child.horizontalConstraint === 'STRETCH' &&
|
||||
child.figmaDerivedLayout?.width === undefined &&
|
||||
!isFieldProtected(ctx.protectedFields, child.id, 'width') &&
|
||||
child.width !== resized.width
|
||||
) {
|
||||
updates.width = resized.width
|
||||
}
|
||||
if (
|
||||
child.verticalConstraint === 'STRETCH' &&
|
||||
child.figmaDerivedLayout?.height === undefined &&
|
||||
!isFieldProtected(ctx.protectedFields, child.id, 'height') &&
|
||||
child.height !== resized.height
|
||||
) {
|
||||
updates.height = resized.height
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
function pinnedChildUpdates(
|
||||
ctx: OverrideContext,
|
||||
child: SceneNode,
|
||||
resized: ReturnType<typeof constrainedChildRect>
|
||||
): Partial<SceneNode> {
|
||||
return {
|
||||
...pinnedPositionUpdates(ctx, child, resized),
|
||||
...stretchedChildSizeUpdates(ctx, child, resized)
|
||||
}
|
||||
}
|
||||
|
||||
function positionPinnedAbsoluteChildren(
|
||||
ctx: OverrideContext,
|
||||
instance: SceneNode,
|
||||
source: SceneNode
|
||||
): void {
|
||||
const count = Math.min(instance.childIds.length, source.childIds.length)
|
||||
for (let index = 0; index < count; index++) {
|
||||
const child = ctx.graph.getNode(instance.childIds[index])
|
||||
const sourceChild = ctx.graph.getNode(source.childIds[index])
|
||||
if (!child || !sourceChild || child.layoutPositioning !== 'ABSOLUTE') continue
|
||||
if (child.componentId && !isCloneOfSource(ctx.graph, child, sourceChild.id)) continue
|
||||
|
||||
const resized = constrainedChildRect(
|
||||
sourceChild,
|
||||
source,
|
||||
instance,
|
||||
child.horizontalConstraint,
|
||||
child.verticalConstraint
|
||||
)
|
||||
const updates = pinnedChildUpdates(ctx, child, resized)
|
||||
if (Object.keys(updates).length > 0) ctx.graph.updateNode(child.id, updates)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveScaleBasis(
|
||||
graph: SceneGraph,
|
||||
instance: SceneNode,
|
||||
component: SceneNode
|
||||
): { width: number; height: number } | null {
|
||||
): SceneNode | null {
|
||||
if (instance.width !== component.width || instance.height !== component.height) return component
|
||||
|
||||
let source: SceneNode = component
|
||||
for (let depth = 0; depth < 10 && source.type === 'INSTANCE' && source.componentId; depth++) {
|
||||
for (
|
||||
let depth = 0;
|
||||
depth < MAX_CLONE_CHAIN_DEPTH && source.type === 'INSTANCE' && source.componentId;
|
||||
depth++
|
||||
) {
|
||||
const next = graph.getNode(source.componentId)
|
||||
if (!next || next.width <= 0 || next.height <= 0) break
|
||||
if (instance.width !== next.width || instance.height !== next.height) return next
|
||||
|
|
@ -174,29 +278,6 @@ function scaleChildren(
|
|||
}
|
||||
}
|
||||
|
||||
function normalizeOutOfBoundsSingleChildren(ctx: OverrideContext): void {
|
||||
const { graph } = ctx
|
||||
for (const parent of overrideCandidates(graph, ctx.activeNodeIds)) {
|
||||
if (parent.childIds.length !== 1) continue
|
||||
const child = graph.getNode(parent.childIds[0])
|
||||
if (!child?.visible || !child.componentId) continue
|
||||
if (ctx.geometryOverrideNodes.has(child.id) || child.figmaDerivedLayout?.x !== undefined)
|
||||
continue
|
||||
const outsideParent =
|
||||
child.x < -0.01 ||
|
||||
child.y < -0.01 ||
|
||||
child.x + child.width > parent.width + 0.01 ||
|
||||
child.y + child.height > parent.height + 0.01
|
||||
if (outsideParent) {
|
||||
graph.updateNode(child.id, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
figmaDerivedLayout: { ...child.figmaDerivedLayout, x: 0, y: 0 }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function propagateScaling(ctx: OverrideContext, scaled: Set<string>): void {
|
||||
const { graph } = ctx
|
||||
const clonesOf = buildClonesMap(graph, ctx.activeNodeIds)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,22 @@ import { copyGeometryPaths } from '@open-pencil/scene-graph/copy'
|
|||
|
||||
import { buildClonesMap } from '../sync'
|
||||
import type { OverrideContext } from '../types'
|
||||
import { overrideCandidates } from '../utils'
|
||||
|
||||
function buildSizeOverriddenCloneUpdates(source: SceneNode, clone: SceneNode): Partial<SceneNode> {
|
||||
if (clone.type !== 'INSTANCE' || !source.figmaDerivedLayout) return {}
|
||||
const sourceLayout = source.figmaDerivedLayout
|
||||
return {
|
||||
...(sourceLayout.x === undefined ? {} : { x: sourceLayout.x }),
|
||||
...(sourceLayout.y === undefined ? {} : { y: sourceLayout.y }),
|
||||
figmaDerivedLayout: {
|
||||
...sourceLayout,
|
||||
...clone.figmaDerivedLayout,
|
||||
x: sourceLayout.x ?? clone.figmaDerivedLayout?.x,
|
||||
y: sourceLayout.y ?? clone.figmaDerivedLayout?.y
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildCloneUpdates(
|
||||
ctx: OverrideContext,
|
||||
|
|
@ -12,7 +28,7 @@ function buildCloneUpdates(
|
|||
sizeSet: Set<string>
|
||||
): Partial<SceneNode> {
|
||||
const updates: Partial<SceneNode> = {}
|
||||
if (sizeSet.has(cloneId)) return updates
|
||||
if (sizeSet.has(cloneId)) return buildSizeOverriddenCloneUpdates(source, clone)
|
||||
if (source.width !== clone.width) updates.width = source.width
|
||||
if (source.height !== clone.height) updates.height = source.height
|
||||
if (source.x !== clone.x) updates.x = source.x
|
||||
|
|
@ -32,6 +48,44 @@ function buildCloneUpdates(
|
|||
return updates
|
||||
}
|
||||
|
||||
export function applyGeneratedFreeformStretch(ctx: OverrideContext): void {
|
||||
for (const node of overrideCandidates(ctx.graph, ctx.activeNodeIds)) {
|
||||
if (
|
||||
node.source.format === 'fig' ||
|
||||
!node.figmaDerivedLayout ||
|
||||
!node.parentId ||
|
||||
node.layoutPositioning === 'ABSOLUTE'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const parent = ctx.graph.getNode(node.parentId)
|
||||
if (
|
||||
!parent ||
|
||||
parent.source.format === 'fig' ||
|
||||
parent.layoutMode !== 'NONE' ||
|
||||
!parent.figmaDerivedLayout
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const updates: Partial<SceneNode> = {}
|
||||
if (
|
||||
node.horizontalConstraint === 'STRETCH' &&
|
||||
node.figmaDerivedLayout.width !== undefined &&
|
||||
node.figmaDerivedLayout.width === parent.figmaDerivedLayout.width
|
||||
) {
|
||||
updates.width = node.figmaDerivedLayout.width
|
||||
}
|
||||
if (
|
||||
node.verticalConstraint === 'STRETCH' &&
|
||||
node.figmaDerivedLayout.height !== undefined &&
|
||||
node.figmaDerivedLayout.height === parent.figmaDerivedLayout.height
|
||||
) {
|
||||
updates.height = node.figmaDerivedLayout.height
|
||||
}
|
||||
if (Object.keys(updates).length > 0) ctx.graph.updateNode(node.id, updates)
|
||||
}
|
||||
}
|
||||
|
||||
export function propagateDsdChanges(
|
||||
ctx: OverrideContext,
|
||||
modified: Set<string>,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export { buildDsdLayoutUpdates } from './derived-symbol-data/layout'
|
||||
export { propagateDsdChanges } from './derived-symbol-data/propagate'
|
||||
export { applyGeneratedFreeformStretch, propagateDsdChanges } from './derived-symbol-data/propagate'
|
||||
export { protectField, type ProtectionMap } from './patches'
|
||||
export { syncChildrenDeep, syncNodeProps } from './sync'
|
||||
export type {
|
||||
|
|
@ -16,7 +16,7 @@ export type {
|
|||
|
||||
import { isEqual } from 'es-toolkit/predicate'
|
||||
|
||||
import { guidToString } from '@open-pencil/fig/node-change'
|
||||
import { guidToString, resolvedNumericBindingUpdate } from '@open-pencil/fig/node-change'
|
||||
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
|
||||
import {
|
||||
copyFills,
|
||||
|
|
@ -29,6 +29,7 @@ import type { JsonObject } from '@open-pencil/scene-graph/primitives'
|
|||
import { applyComponentProperties } from './component-props'
|
||||
import { applyConstraintScaling } from './constraints'
|
||||
import { applyDerivedSymbolData } from './derived-symbol-data'
|
||||
import { applyGeneratedFreeformStretch } from './derived-symbol-data/propagate'
|
||||
import { populateInstances } from './populate'
|
||||
import { preComputeRoots } from './resolve'
|
||||
import { applySymbolOverrides } from './symbol/overrides'
|
||||
|
|
@ -265,8 +266,20 @@ function buildOverrideContext(
|
|||
}
|
||||
}
|
||||
|
||||
function applyResolvedNumericBindings(graph: SceneGraph, activeNodeIds?: Set<string>): void {
|
||||
for (const node of overrideCandidates(graph, activeNodeIds)) {
|
||||
const updates: Partial<SceneNode> = {}
|
||||
for (const [field, variableId] of Object.entries(node.boundVariables)) {
|
||||
if (Array.isArray(variableId)) continue
|
||||
const value = graph.resolveNumberVariableForNode(node.id, variableId)
|
||||
if (value === undefined) continue
|
||||
Object.assign(updates, resolvedNumericBindingUpdate(field, value))
|
||||
}
|
||||
if (Object.keys(updates).length > 0) graph.updateNode(node.id, updates)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate empty instances from their components and apply symbol overrides.
|
||||
*
|
||||
* Shared between .fig file import and clipboard paste. Both paths produce
|
||||
* a SceneGraph with INSTANCE nodes whose componentId references have been
|
||||
|
|
@ -361,4 +374,6 @@ export function populateAndApplyOverrides(
|
|||
ctx.protectedFields,
|
||||
ctx.preComputedClones
|
||||
)
|
||||
applyResolvedNumericBindings(graph, ctx.activeNodeIds)
|
||||
applyGeneratedFreeformStretch(ctx)
|
||||
}
|
||||
|
|
|
|||
14
packages/fig/src/instance-overrides/sync/clone-update.ts
Normal file
14
packages/fig/src/instance-overrides/sync/clone-update.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { copyInstanceComponentProps, type SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
export function cloneInstanceUpdate(
|
||||
source: SceneNode,
|
||||
componentId: string | null,
|
||||
extra: Partial<SceneNode> = {}
|
||||
): Partial<SceneNode> {
|
||||
return {
|
||||
...copyInstanceComponentProps(source),
|
||||
componentId,
|
||||
figmaDerivedLayout: source.figmaDerivedLayout ? { ...source.figmaDerivedLayout } : null,
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
|
|||
|
||||
import type { ProtectionMap } from '../patches'
|
||||
import { overrideCandidates } from '../utils'
|
||||
import { cloneInstanceUpdate } from './clone-update'
|
||||
import { syncNodeProps } from './fields'
|
||||
import { indexCloneSubtree, remapRepopulatedChildSources, snapshotChildSources } from './sources'
|
||||
|
||||
|
|
@ -20,7 +21,10 @@ export function recloneChildren(
|
|||
|
||||
const previousSources = snapshotChildSources(graph, tgtNode.id)
|
||||
for (const childId of Array.from(tgtNode.childIds)) graph.deleteNode(childId)
|
||||
graph.updateNode(tgtNode.id, { name: srcChild.name, componentId: srcChild.componentId })
|
||||
graph.updateNode(
|
||||
tgtNode.id,
|
||||
cloneInstanceUpdate(srcChild, srcChild.componentId, { name: srcChild.name })
|
||||
)
|
||||
syncNodeProps(graph, srcChild, tgtNode, protections)
|
||||
if (srcChild.childIds.length > 0) {
|
||||
graph.populateInstanceChildren(tgtNode.id, srcChildId, 'fig-import')
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ type SyncFn = (
|
|||
) => void
|
||||
|
||||
type DirectSyncKey = 'text' | 'visible' | 'opacity' | 'locked' | 'layoutGrow' | 'textAutoResize'
|
||||
type ScalarBindingKey = 'opacity'
|
||||
type CopiedSyncKey = 'fills' | 'strokes' | 'effects' | 'styleRuns'
|
||||
|
||||
function assignDirectUpdate(
|
||||
|
|
@ -128,6 +129,20 @@ const COPIED_SYNCERS: SyncFn[] = [
|
|||
copiedSync('styleRuns', 'styleRuns')
|
||||
]
|
||||
|
||||
function syncScalarBinding(
|
||||
key: ScalarBindingKey,
|
||||
source: SceneNode,
|
||||
target: SceneNode,
|
||||
updates: Partial<SceneNode>
|
||||
): void {
|
||||
const sourceVariableId = source.boundVariables[key]
|
||||
const targetVariableId = target.boundVariables[key]
|
||||
if (targetVariableId === sourceVariableId) return
|
||||
const bindings = { ...(updates.boundVariables ?? target.boundVariables) }
|
||||
if (sourceVariableId) bindings[key] = sourceVariableId
|
||||
updates.boundVariables = sourceVariableId ? bindings : omit(bindings, [key])
|
||||
}
|
||||
|
||||
function syncFields(
|
||||
source: SceneNode,
|
||||
target: SceneNode,
|
||||
|
|
@ -136,6 +151,7 @@ function syncFields(
|
|||
): void {
|
||||
for (const sync of DIRECT_SYNCERS) sync(source, target, updates, protections)
|
||||
for (const sync of COPIED_SYNCERS) sync(source, target, updates, protections)
|
||||
syncScalarBinding('opacity', source, target, updates)
|
||||
}
|
||||
|
||||
export function syncNodeProps(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
import { overrideCandidates } from '../utils'
|
||||
import { cloneInstanceUpdate } from './clone-update'
|
||||
|
||||
interface ChildSourceSnapshot {
|
||||
id: string
|
||||
|
|
@ -112,6 +113,43 @@ function resolveChildPath(graph: SceneGraph, parentId: string, path: number[]):
|
|||
return node
|
||||
}
|
||||
|
||||
function cloneIdsForReplacement(
|
||||
graph: SceneGraph,
|
||||
previousId: string,
|
||||
replacementId: string,
|
||||
cloneSources?: Map<string, string[]>
|
||||
): Set<string> {
|
||||
return new Set([
|
||||
...(cloneSources?.get(previousId) ?? []),
|
||||
...(cloneSources?.get(replacementId) ?? []),
|
||||
...(graph.instanceIndex.get(previousId) ?? []),
|
||||
...(graph.instanceIndex.get(replacementId) ?? [])
|
||||
])
|
||||
}
|
||||
|
||||
function indexReplacementClone(
|
||||
cloneSources: Map<string, string[]> | undefined,
|
||||
replacementId: string,
|
||||
cloneId: string
|
||||
): void {
|
||||
if (!cloneSources) return
|
||||
const sourceIds = cloneSourceIds(cloneSources)
|
||||
let known = sourceIds.get(replacementId)
|
||||
if (!known) {
|
||||
known = new Set()
|
||||
sourceIds.set(replacementId, known)
|
||||
}
|
||||
if (known.has(cloneId)) return
|
||||
known.add(cloneId)
|
||||
|
||||
let replacements = cloneSources.get(replacementId)
|
||||
if (!replacements) {
|
||||
replacements = []
|
||||
cloneSources.set(replacementId, replacements)
|
||||
}
|
||||
replacements.push(cloneId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect descendants that cloned the removed branch to its structural
|
||||
* replacements. Without this, deep instances keep componentId references to
|
||||
|
|
@ -128,30 +166,14 @@ export function remapRepopulatedChildSources(
|
|||
for (const previous of previousSources) {
|
||||
const replacement = resolveChildPath(graph, parentId, previous.path)
|
||||
if (!replacement || replacement.type !== previous.type) continue
|
||||
const cloneIds = new Set([
|
||||
...(cloneSources?.get(previous.id) ?? []),
|
||||
...(graph.instanceIndex.get(previous.id) ?? [])
|
||||
])
|
||||
const cloneIds = cloneIdsForReplacement(graph, previous.id, replacement.id, cloneSources)
|
||||
for (const cloneId of cloneIds) {
|
||||
const clone = graph.getNode(cloneId)
|
||||
if (clone?.componentId !== previous.id) continue
|
||||
graph.updateNode(cloneId, { componentId: replacement.id })
|
||||
if (cloneSources) {
|
||||
let replacements = cloneSources.get(replacement.id)
|
||||
if (!replacements) {
|
||||
replacements = []
|
||||
cloneSources.set(replacement.id, replacements)
|
||||
}
|
||||
if (!replacements.includes(cloneId)) {
|
||||
replacements.push(cloneId)
|
||||
let known = cloneSourceIds(cloneSources).get(replacement.id)
|
||||
if (!known) {
|
||||
known = new Set()
|
||||
cloneSourceIds(cloneSources).set(replacement.id, known)
|
||||
}
|
||||
known.add(cloneId)
|
||||
}
|
||||
if (clone?.componentId !== previous.id && clone?.componentId !== replacement.id) {
|
||||
continue
|
||||
}
|
||||
graph.updateNode(cloneId, cloneInstanceUpdate(replacement, replacement.id))
|
||||
indexReplacementClone(cloneSources, replacement.id, cloneId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,54 +65,7 @@ import type {
|
|||
import type { GUID } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
export { guidToString, stringToGuid } from '@open-pencil/kiwi/fig/guid'
|
||||
|
||||
export const VARIABLE_BINDING_FIELDS: Record<string, string> = {
|
||||
// Corner radius
|
||||
cornerRadius: 'CORNER_RADIUS',
|
||||
topLeftRadius: 'RECTANGLE_TOP_LEFT_CORNER_RADIUS',
|
||||
topRightRadius: 'RECTANGLE_TOP_RIGHT_CORNER_RADIUS',
|
||||
bottomLeftRadius: 'RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS',
|
||||
bottomRightRadius: 'RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS',
|
||||
// Stroke
|
||||
strokeWeight: 'STROKE_WEIGHT',
|
||||
borderTopWeight: 'BORDER_TOP_WEIGHT',
|
||||
borderBottomWeight: 'BORDER_BOTTOM_WEIGHT',
|
||||
borderLeftWeight: 'BORDER_LEFT_WEIGHT',
|
||||
borderRightWeight: 'BORDER_RIGHT_WEIGHT',
|
||||
// Auto-layout spacing & padding
|
||||
itemSpacing: 'STACK_SPACING',
|
||||
paddingLeft: 'STACK_PADDING_LEFT',
|
||||
paddingTop: 'STACK_PADDING_TOP',
|
||||
paddingRight: 'STACK_PADDING_RIGHT',
|
||||
paddingBottom: 'STACK_PADDING_BOTTOM',
|
||||
counterAxisSpacing: 'STACK_COUNTER_SPACING',
|
||||
// Grid gaps
|
||||
gridRowGap: 'GRID_ROW_GAP',
|
||||
gridColumnGap: 'GRID_COLUMN_GAP',
|
||||
// Visibility & opacity
|
||||
visible: 'VISIBLE',
|
||||
opacity: 'OPACITY',
|
||||
// Dimensions
|
||||
width: 'WIDTH',
|
||||
height: 'HEIGHT',
|
||||
minWidth: 'MIN_WIDTH',
|
||||
maxWidth: 'MAX_WIDTH',
|
||||
minHeight: 'MIN_HEIGHT',
|
||||
maxHeight: 'MAX_HEIGHT',
|
||||
// Position & rotation
|
||||
x: 'X_POSITION',
|
||||
y: 'Y_POSITION',
|
||||
rotation: 'ROTATION',
|
||||
// Text
|
||||
fontSize: 'FONT_SIZE',
|
||||
letterSpacing: 'LETTER_SPACING',
|
||||
lineHeight: 'LINE_HEIGHT',
|
||||
fontFamily: 'FONT_FAMILY'
|
||||
}
|
||||
|
||||
export const VARIABLE_BINDING_FIELDS_INVERSE: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(VARIABLE_BINDING_FIELDS).map(([k, v]) => [v, k])
|
||||
)
|
||||
export { VARIABLE_BINDING_FIELDS, VARIABLE_BINDING_FIELDS_INVERSE } from './variable-bindings'
|
||||
|
||||
interface FigVariableModeMap {
|
||||
entries?: Array<{
|
||||
|
|
@ -428,25 +381,15 @@ function convertTextProps(nc: NodeChange, blobs: Uint8Array[]): TextProps {
|
|||
}
|
||||
}
|
||||
|
||||
function consumesVariableField(nc: NodeChange, field: string): boolean {
|
||||
return nc.variableConsumptionMap?.entries?.some((entry) => entry.variableField === field) ?? false
|
||||
}
|
||||
|
||||
function convertLayoutPadding(
|
||||
nc: NodeChange
|
||||
): Pick<SceneNode, 'paddingTop' | 'paddingBottom' | 'paddingLeft' | 'paddingRight'> {
|
||||
const basePadding = nc.stackPadding ?? 0
|
||||
const verticalPadding = nc.stackVerticalPadding ?? basePadding
|
||||
const horizontalPadding = nc.stackHorizontalPadding ?? basePadding
|
||||
return {
|
||||
paddingTop: verticalPadding,
|
||||
paddingBottom:
|
||||
nc.stackPaddingBottom ??
|
||||
(consumesVariableField(nc, 'STACK_PADDING_TOP') ? basePadding : verticalPadding),
|
||||
paddingLeft: horizontalPadding,
|
||||
paddingRight:
|
||||
nc.stackPaddingRight ??
|
||||
(consumesVariableField(nc, 'STACK_PADDING_LEFT') ? basePadding : horizontalPadding)
|
||||
paddingTop: nc.stackVerticalPadding ?? basePadding,
|
||||
paddingBottom: nc.stackPaddingBottom ?? basePadding,
|
||||
paddingLeft: nc.stackHorizontalPadding ?? basePadding,
|
||||
paddingRight: nc.stackPaddingRight ?? basePadding
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -470,6 +413,16 @@ function visibleContainerDerivedLayout(
|
|||
}
|
||||
}
|
||||
|
||||
function minimumSizeDimension(size: NodeChange['minSize'], axis: 'x' | 'y'): number | null {
|
||||
const value = size?.value?.[axis]
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
function maximumSizeDimension(size: NodeChange['maxSize'], axis: 'x' | 'y'): number | null {
|
||||
const value = size?.value?.[axis]
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
|
||||
}
|
||||
|
||||
function convertLayoutProps(
|
||||
nc: NodeChange
|
||||
): Pick<
|
||||
|
|
@ -663,10 +616,10 @@ export function nodeChangeToProps(
|
|||
verticalConstraint: mapConstraint(nc.verticalConstraint as string),
|
||||
...convertLayoutProps(nc),
|
||||
...vectorAndStrokeProps,
|
||||
minWidth: (nc.minWidth ?? null) as number | null,
|
||||
maxWidth: (nc.maxWidth ?? null) as number | null,
|
||||
minHeight: (nc.minHeight ?? null) as number | null,
|
||||
maxHeight: (nc.maxHeight ?? null) as number | null,
|
||||
minWidth: minimumSizeDimension(nc.minSize, 'x'),
|
||||
maxWidth: maximumSizeDimension(nc.maxSize, 'x'),
|
||||
minHeight: minimumSizeDimension(nc.minSize, 'y'),
|
||||
maxHeight: maximumSizeDimension(nc.maxSize, 'y'),
|
||||
isMask: nc.mask ?? false,
|
||||
maskType: (nc.maskType ?? 'ALPHA') as 'ALPHA' | 'VECTOR' | 'LUMINANCE',
|
||||
maskIsOutline: nc.maskIsOutline ?? false,
|
||||
|
|
|
|||
|
|
@ -362,6 +362,9 @@ const RAW_FIELDS_OVERRIDE_BLOCKLIST = new Set([
|
|||
'derivedSymbolData',
|
||||
'derivedSymbolDataLayoutVersion',
|
||||
'sourceLibraryKey',
|
||||
// Normalized constraints are authoritative, including when an edit clears them.
|
||||
'minSize',
|
||||
'maxSize',
|
||||
// Variable consumption maps: explicit serialization always sets these when
|
||||
// bindings exist, and our VARIABLE_BINDING_FIELDS mapping may produce different
|
||||
// kiwi field names than the original raw data for library variable references.
|
||||
|
|
|
|||
|
|
@ -15,5 +15,6 @@ export * from './style-refs'
|
|||
export * from './style-runs'
|
||||
export * from './text-data-export'
|
||||
export * from './text-values'
|
||||
export * from './variable-bindings'
|
||||
export * from './vector-geometry'
|
||||
export * from './vector-network'
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from '@open-pencil/scene-graph'
|
||||
|
||||
import { readEffectiveFigmaRawField } from '../source-metadata'
|
||||
import { resolveVariableConsumptionEntry } from './variable-bindings'
|
||||
|
||||
export const OPEN_PENCIL_PLUGIN_ID = 'open-pencil'
|
||||
export const TEXT_DIRECTION_PLUGIN_KEY = 'textDirection'
|
||||
|
|
@ -76,6 +77,10 @@ export function extractBoundVariables(nc: NodeChange): Record<string, string> {
|
|||
const bindings = parseBoundVariablesPluginValue(
|
||||
getOpenPencilPluginValue(nc, BOUND_VARIABLES_PLUGIN_KEY)
|
||||
)
|
||||
for (const entry of nc.variableConsumptionMap?.entries ?? []) {
|
||||
const binding = resolveVariableConsumptionEntry(entry)
|
||||
if (binding) bindings[binding.field] = binding.variableId
|
||||
}
|
||||
nc.fillPaints?.forEach((paint, i) => {
|
||||
const variableGuid =
|
||||
paint.colorVariableBinding?.variableID ?? paint.colorVar?.value?.alias?.guid
|
||||
|
|
|
|||
|
|
@ -310,8 +310,23 @@ function preserveTrailingPadding(
|
|||
return normalizedValue !== inheritedValue ? normalizedValue : undefined
|
||||
}
|
||||
|
||||
function serializeSizeConstraints(node: SceneNode, nc: KiwiNodeChange): void {
|
||||
if (node.minWidth != null || node.minHeight != null) {
|
||||
nc.minSize = { value: { x: node.minWidth ?? 0, y: node.minHeight ?? 0 } }
|
||||
}
|
||||
if (node.maxWidth != null || node.maxHeight != null) {
|
||||
nc.maxSize = {
|
||||
value: {
|
||||
x: node.maxWidth ?? Number.POSITIVE_INFINITY,
|
||||
y: node.maxHeight ?? Number.POSITIVE_INFINITY
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange, graph: SceneGraph): void {
|
||||
if (!node.source.id) upsertPluginData(node, LAYOUT_DIRECTION_PLUGIN_KEY, node.layoutDirection)
|
||||
serializeSizeConstraints(node, nc)
|
||||
const figLayout = node.source.fig.layout
|
||||
if (figLayout) {
|
||||
nc.stackMode = normalizeStackMode(figLayout.stackMode)
|
||||
|
|
|
|||
99
packages/fig/src/node-change/variable-bindings.ts
Normal file
99
packages/fig/src/node-change/variable-bindings.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { VariableConsumptionEntry } from '@open-pencil/kiwi/fig/codec'
|
||||
import { guidToString } from '@open-pencil/kiwi/fig/guid'
|
||||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
export const VARIABLE_BINDING_FIELDS: Record<string, string> = {
|
||||
cornerRadius: 'CORNER_RADIUS',
|
||||
topLeftRadius: 'RECTANGLE_TOP_LEFT_CORNER_RADIUS',
|
||||
topRightRadius: 'RECTANGLE_TOP_RIGHT_CORNER_RADIUS',
|
||||
bottomLeftRadius: 'RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS',
|
||||
bottomRightRadius: 'RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS',
|
||||
strokeWeight: 'STROKE_WEIGHT',
|
||||
borderTopWeight: 'BORDER_TOP_WEIGHT',
|
||||
borderBottomWeight: 'BORDER_BOTTOM_WEIGHT',
|
||||
borderLeftWeight: 'BORDER_LEFT_WEIGHT',
|
||||
borderRightWeight: 'BORDER_RIGHT_WEIGHT',
|
||||
itemSpacing: 'STACK_SPACING',
|
||||
paddingLeft: 'STACK_PADDING_LEFT',
|
||||
paddingTop: 'STACK_PADDING_TOP',
|
||||
paddingRight: 'STACK_PADDING_RIGHT',
|
||||
paddingBottom: 'STACK_PADDING_BOTTOM',
|
||||
counterAxisSpacing: 'STACK_COUNTER_SPACING',
|
||||
gridRowGap: 'GRID_ROW_GAP',
|
||||
gridColumnGap: 'GRID_COLUMN_GAP',
|
||||
visible: 'VISIBLE',
|
||||
opacity: 'OPACITY',
|
||||
width: 'WIDTH',
|
||||
height: 'HEIGHT',
|
||||
minWidth: 'MIN_WIDTH',
|
||||
maxWidth: 'MAX_WIDTH',
|
||||
minHeight: 'MIN_HEIGHT',
|
||||
maxHeight: 'MAX_HEIGHT',
|
||||
x: 'X_POSITION',
|
||||
y: 'Y_POSITION',
|
||||
rotation: 'ROTATION',
|
||||
fontSize: 'FONT_SIZE',
|
||||
letterSpacing: 'LETTER_SPACING',
|
||||
lineHeight: 'LINE_HEIGHT',
|
||||
fontFamily: 'FONT_FAMILY'
|
||||
}
|
||||
|
||||
export const VARIABLE_BINDING_FIELDS_INVERSE: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(VARIABLE_BINDING_FIELDS).map(([field, kiwiField]) => [kiwiField, field])
|
||||
)
|
||||
|
||||
export interface ResolvedVariableConsumption {
|
||||
field: string
|
||||
variableId: string
|
||||
}
|
||||
|
||||
export function resolveVariableConsumptionEntry(
|
||||
entry: VariableConsumptionEntry
|
||||
): ResolvedVariableConsumption | undefined {
|
||||
const field = entry.variableField
|
||||
? VARIABLE_BINDING_FIELDS_INVERSE[entry.variableField]
|
||||
: undefined
|
||||
const guid = entry.variableData?.value?.alias?.guid
|
||||
return field && guid ? { field, variableId: guidToString(guid) } : undefined
|
||||
}
|
||||
|
||||
const NUMERIC_BINDING_FIELDS = new Set([
|
||||
'cornerRadius',
|
||||
'topLeftRadius',
|
||||
'topRightRadius',
|
||||
'bottomLeftRadius',
|
||||
'bottomRightRadius',
|
||||
'strokeWeight',
|
||||
'borderTopWeight',
|
||||
'borderBottomWeight',
|
||||
'borderLeftWeight',
|
||||
'borderRightWeight',
|
||||
'itemSpacing',
|
||||
'paddingLeft',
|
||||
'paddingTop',
|
||||
'paddingRight',
|
||||
'paddingBottom',
|
||||
'counterAxisSpacing',
|
||||
'gridRowGap',
|
||||
'gridColumnGap',
|
||||
'width',
|
||||
'height',
|
||||
'minWidth',
|
||||
'maxWidth',
|
||||
'minHeight',
|
||||
'maxHeight',
|
||||
'x',
|
||||
'y',
|
||||
'rotation',
|
||||
'fontSize',
|
||||
'letterSpacing',
|
||||
'lineHeight'
|
||||
])
|
||||
|
||||
export function resolvedNumericBindingUpdate(
|
||||
field: string,
|
||||
value: number
|
||||
): Partial<SceneNode> | undefined {
|
||||
if (field === 'opacity') return { opacity: Math.max(0, Math.min(1, value / 100)) }
|
||||
return NUMERIC_BINDING_FIELDS.has(field) ? { [field]: value } : undefined
|
||||
}
|
||||
|
|
@ -27,6 +27,135 @@ describe('@open-pencil/fig instance interpretation', () => {
|
|||
expect(graph.getNode(populated?.childIds[0] ?? '')?.text).toBe('Label')
|
||||
})
|
||||
|
||||
test('repositions pinned children through nested resized instances', () => {
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
const component = graph.createNode('COMPONENT', pageId, {
|
||||
width: 442,
|
||||
height: 32,
|
||||
layoutMode: 'HORIZONTAL'
|
||||
})
|
||||
graph.createNode('TEXT', component.id, { x: 32, y: 6, width: 80, height: 20 })
|
||||
graph.createNode('TEXT', component.id, { x: 120, y: 6, width: 80, height: 20 })
|
||||
graph.createNode('INSTANCE', component.id, {
|
||||
x: 420,
|
||||
y: 9,
|
||||
width: 14,
|
||||
height: 14,
|
||||
layoutPositioning: 'ABSOLUTE',
|
||||
horizontalConstraint: 'MAX',
|
||||
verticalConstraint: 'CENTER'
|
||||
})
|
||||
const source = graph.createNode('INSTANCE', pageId, {
|
||||
width: 256,
|
||||
height: 32,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
componentId: component.id
|
||||
})
|
||||
const instance = graph.createNode('INSTANCE', pageId, {
|
||||
width: 256,
|
||||
height: 32,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
componentId: source.id
|
||||
})
|
||||
|
||||
populateAndApplyOverrides(graph, new Map(), new Map())
|
||||
|
||||
const pinned = graph.getChildren(instance.id)[2]
|
||||
expect(pinned).toMatchObject({ x: 234, y: 9, width: 14, height: 14 })
|
||||
})
|
||||
|
||||
test('resizes stretched absolute children with resized instances', () => {
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
const component = graph.createNode('COMPONENT', pageId, {
|
||||
width: 100,
|
||||
height: 80,
|
||||
layoutMode: 'HORIZONTAL'
|
||||
})
|
||||
graph.createNode('RECTANGLE', component.id, {
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 80,
|
||||
height: 60,
|
||||
layoutPositioning: 'ABSOLUTE',
|
||||
horizontalConstraint: 'STRETCH',
|
||||
verticalConstraint: 'STRETCH'
|
||||
})
|
||||
const instance = graph.createNode('INSTANCE', pageId, {
|
||||
width: 200,
|
||||
height: 120,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
componentId: component.id
|
||||
})
|
||||
|
||||
populateAndApplyOverrides(graph, new Map(), new Map())
|
||||
|
||||
expect(graph.getChildren(instance.id)[0]).toMatchObject({
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 180,
|
||||
height: 100
|
||||
})
|
||||
})
|
||||
|
||||
test('applies pinned constraints inside resized freeform instances', () => {
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
const component = graph.createNode('COMPONENT', pageId, { width: 100, height: 80 })
|
||||
graph.createNode('RECTANGLE', component.id, {
|
||||
x: 80,
|
||||
y: 10,
|
||||
width: 10,
|
||||
height: 60,
|
||||
layoutPositioning: 'ABSOLUTE',
|
||||
horizontalConstraint: 'MAX',
|
||||
verticalConstraint: 'STRETCH'
|
||||
})
|
||||
const instance = graph.createNode('INSTANCE', pageId, {
|
||||
width: 200,
|
||||
height: 120,
|
||||
componentId: component.id
|
||||
})
|
||||
|
||||
populateAndApplyOverrides(graph, new Map(), new Map())
|
||||
|
||||
expect(graph.getChildren(instance.id)[0]).toMatchObject({
|
||||
x: 180,
|
||||
y: 10,
|
||||
width: 10,
|
||||
height: 100
|
||||
})
|
||||
})
|
||||
|
||||
test('preserves an inset child when a nested instance becomes narrower', () => {
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
const field = graph.createNode('COMPONENT', pageId, { width: 280, height: 40 })
|
||||
graph.createNode('TEXT', field.id, {
|
||||
x: 16,
|
||||
y: 10,
|
||||
width: 248,
|
||||
height: 20,
|
||||
text: 'Placeholder'
|
||||
})
|
||||
const source = graph.createNode('INSTANCE', pageId, {
|
||||
width: 240,
|
||||
height: 40,
|
||||
componentId: field.id
|
||||
})
|
||||
const instance = graph.createNode('INSTANCE', pageId, {
|
||||
width: 180,
|
||||
height: 40,
|
||||
componentId: source.id
|
||||
})
|
||||
|
||||
populateAndApplyOverrides(graph, new Map(), new Map())
|
||||
|
||||
const placeholder = graph.getChildren(instance.id)[0]
|
||||
expect(placeholder).toMatchObject({ x: 16, y: 10, text: 'Placeholder' })
|
||||
})
|
||||
|
||||
test('limits lazy population to required global propagation scans', () => {
|
||||
const graph = new SceneGraph()
|
||||
const activePage = graph.getPages()[0]
|
||||
|
|
@ -83,6 +212,41 @@ describe('@open-pencil/fig instance interpretation', () => {
|
|||
expect(graph.getNode(leaf.id)).toMatchObject({ width: 80, fills: source.fills })
|
||||
})
|
||||
|
||||
test('synchronizes opacity bindings with their resolved value', () => {
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
const source = graph.createNode('INSTANCE', pageId, {
|
||||
opacity: 0.5,
|
||||
boundVariables: { opacity: 'opacity-var' }
|
||||
})
|
||||
const target = graph.createNode('INSTANCE', pageId, {
|
||||
opacity: 1,
|
||||
componentId: source.id
|
||||
})
|
||||
|
||||
syncNodeProps(graph, source, target)
|
||||
|
||||
expect(graph.getNode(target.id)).toMatchObject({
|
||||
opacity: 0.5,
|
||||
boundVariables: { opacity: 'opacity-var' }
|
||||
})
|
||||
})
|
||||
|
||||
test('clears opacity bindings removed from the source', () => {
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
const source = graph.createNode('INSTANCE', pageId, { opacity: 1 })
|
||||
const target = graph.createNode('INSTANCE', pageId, {
|
||||
opacity: 0.5,
|
||||
componentId: source.id,
|
||||
boundVariables: { opacity: 'stale-opacity-var', width: 'width-var' }
|
||||
})
|
||||
|
||||
syncNodeProps(graph, source, target)
|
||||
|
||||
expect(graph.getNode(target.id)?.boundVariables).toEqual({ width: 'width-var' })
|
||||
})
|
||||
|
||||
test('preserves protected text while synchronizing other fields', () => {
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
|
|
|
|||
|
|
@ -329,6 +329,8 @@ export interface NodeChange {
|
|||
stackChildPrimaryGrow?: number
|
||||
stackChildAlignSelf?: string
|
||||
stackCounterSpacing?: number
|
||||
minSize?: { value?: Vector }
|
||||
maxSize?: { value?: Vector }
|
||||
// Frame
|
||||
clipsContent?: boolean
|
||||
frameMaskDisabled?: boolean
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ export type { NodeCloneMode } from './copy'
|
|||
const INSTANCE_SYNC_PROPS: (keyof SceneNode)[] = [
|
||||
'width',
|
||||
'height',
|
||||
'minWidth',
|
||||
'maxWidth',
|
||||
'minHeight',
|
||||
'maxHeight',
|
||||
'fills',
|
||||
'strokes',
|
||||
'effects',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"text": "Text",
|
||||
"new": "Neu",
|
||||
"open": "Öffnen…",
|
||||
"openStorageWorkspace": "Speicher-Arbeitsbereich öffnen…",
|
||||
"save": "Speichern",
|
||||
"saveAs": "Speichern unter…",
|
||||
"exportSelection": "Auswahl exportieren…",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"text": "Texto",
|
||||
"new": "Nuevo",
|
||||
"open": "Abrir…",
|
||||
"openStorageWorkspace": "Abrir espacio de almacenamiento…",
|
||||
"save": "Guardar",
|
||||
"saveAs": "Guardar como…",
|
||||
"exportSelection": "Exportar selección…",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"text": "Texte",
|
||||
"new": "Nouveau",
|
||||
"open": "Ouvrir…",
|
||||
"openStorageWorkspace": "Ouvrir l’espace de stockage…",
|
||||
"save": "Enregistrer",
|
||||
"saveAs": "Enregistrer sous…",
|
||||
"exportSelection": "Exporter la sélection…",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"text": "Testo",
|
||||
"new": "Nuovo",
|
||||
"open": "Apri…",
|
||||
"openStorageWorkspace": "Apri area di archiviazione…",
|
||||
"save": "Salva",
|
||||
"saveAs": "Salva con nome…",
|
||||
"exportSelection": "Esporta selezione…",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"text": "テキスト",
|
||||
"new": "新規作成",
|
||||
"open": "開く…",
|
||||
"openStorageWorkspace": "ストレージワークスペースを開く…",
|
||||
"save": "保存",
|
||||
"saveAs": "名前を付けて保存…",
|
||||
"exportSelection": "選択範囲をエクスポート…",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"text": "Tekst",
|
||||
"new": "Nowy",
|
||||
"open": "Otwórz…",
|
||||
"openStorageWorkspace": "Otwórz obszar przechowywania…",
|
||||
"save": "Zapisz",
|
||||
"saveAs": "Zapisz jako…",
|
||||
"exportSelection": "Eksportuj zaznaczenie…",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"text": "Текст",
|
||||
"new": "Создать",
|
||||
"open": "Открыть…",
|
||||
"openStorageWorkspace": "Открыть рабочее пространство хранилища…",
|
||||
"save": "Сохранить",
|
||||
"saveAs": "Сохранить как…",
|
||||
"exportSelection": "Экспорт выделения…",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"text": "文本",
|
||||
"new": "新建",
|
||||
"open": "打开…",
|
||||
"openStorageWorkspace": "打开存储工作区…",
|
||||
"save": "保存",
|
||||
"saveAs": "另存为…",
|
||||
"exportSelection": "导出所选内容…",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const menuMessageDefaults = {
|
|||
|
||||
new: 'New',
|
||||
open: 'Open…',
|
||||
openStorageWorkspace: 'Open storage workspace…',
|
||||
save: 'Save',
|
||||
saveAs: 'Save as…',
|
||||
exportSelection: 'Export selection…',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import type { MenuEntry } from '@open-pencil/vue'
|
||||
import { useEditorCommands, useI18n } from '@open-pencil/vue'
|
||||
|
|
@ -6,6 +7,7 @@ import { useEditorCommands, useI18n } from '@open-pencil/vue'
|
|||
import { useEditorStore } from '@/app/editor/active-store'
|
||||
import { openSettingsDialog } from '@/app/settings/dialog'
|
||||
import { createSharedEditorMenuActions } from '@/app/shell/menu/editor-actions'
|
||||
import { openStorageWorkspace } from '@/app/shell/menu/navigation'
|
||||
import type { AppMenuActionItem, AppMenuEntry, AppMenuGroupSchema } from '@/app/shell/menu/schema'
|
||||
import { APP_MENU_SCHEMA } from '@/app/shell/menu/schema'
|
||||
import { createSelectionMenuActions } from '@/app/shell/menu/selection-actions'
|
||||
|
|
@ -29,6 +31,7 @@ function isSeparator(entry: AppMenuEntry): entry is Extract<AppMenuEntry, { type
|
|||
|
||||
export function useAppMenu() {
|
||||
const store = useEditorStore()
|
||||
const router = useRouter()
|
||||
const {
|
||||
commands,
|
||||
menuItem: commandMenuItem,
|
||||
|
|
@ -41,6 +44,7 @@ export function useAppMenu() {
|
|||
const translatedMenuItemLabels: Partial<Record<string, keyof typeof menu.value>> = {
|
||||
new: 'new',
|
||||
open: 'open',
|
||||
'open-storage-workspace': 'openStorageWorkspace',
|
||||
save: 'save',
|
||||
'save-as': 'saveAs',
|
||||
'export-selection': 'exportSelection',
|
||||
|
|
@ -94,6 +98,7 @@ export function useAppMenu() {
|
|||
void import('@/app/tabs').then((m) => m.createTab())
|
||||
},
|
||||
open: () => void openFileDialog(),
|
||||
'open-storage-workspace': () => openStorageWorkspace(router),
|
||||
save: () => void store.saveFigFile(),
|
||||
'save-as': () => void store.saveFigFileAs(),
|
||||
'export-selection': () => exportSelection('png'),
|
||||
|
|
|
|||
5
src/app/shell/menu/navigation.ts
Normal file
5
src/app/shell/menu/navigation.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import type { Router } from 'vue-router'
|
||||
|
||||
export function openStorageWorkspace(router: Router): void {
|
||||
void router.push('/storage')
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ export const APP_MENU_SCHEMA = [
|
|||
items: [
|
||||
{ id: 'new', label: 'New', shortcut: 'MOD+N' },
|
||||
{ id: 'open', label: 'Open…', shortcut: 'MOD+O' },
|
||||
{ id: 'open-storage-workspace', label: 'Open Storage Workspace…' },
|
||||
{ type: 'separator' },
|
||||
{ id: 'save', label: 'Save', shortcut: 'MOD+S' },
|
||||
{ id: 'save-as', label: 'Save As…', shortcut: 'MOD+SHIFT+S' },
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useEditorStore } from '@/app/editor/active-store'
|
|||
import { openSettingsDialog } from '@/app/settings/dialog'
|
||||
import { createSharedEditorMenuActions } from '@/app/shell/menu/editor-actions'
|
||||
import { importFileDialog, openFileDialog } from '@/app/shell/menu/files'
|
||||
import { openStorageWorkspace } from '@/app/shell/menu/navigation'
|
||||
import { APP_MENU_SCHEMA, type AppMenuEntry } from '@/app/shell/menu/schema'
|
||||
import { createSelectionMenuActions } from '@/app/shell/menu/selection-actions'
|
||||
import { useAppTheme } from '@/app/shell/theme'
|
||||
|
|
@ -40,6 +41,9 @@ export function useMenu() {
|
|||
const actions: Partial<Record<string, () => void>> = {
|
||||
new: () => createTab(),
|
||||
open: () => void openFileDialog(),
|
||||
'open-storage-workspace': () => {
|
||||
void import('@/router').then(({ default: router }) => openStorageWorkspace(router))
|
||||
},
|
||||
close: () => {
|
||||
if (activeTab.value) closeTab(activeTab.value.id)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ test('File menu opens and shows items', async () => {
|
|||
|
||||
const items = await menu.locator('[role="menuitem"]').allTextContents()
|
||||
expect(items.some((t) => t.includes('Open'))).toBe(true)
|
||||
expect(items.some((t) => t.includes('Open storage workspace'))).toBe(true)
|
||||
expect(items.some((t) => t.includes('Save'))).toBe(true)
|
||||
expect(items.some((t) => t.includes('Save as'))).toBe(true)
|
||||
|
||||
|
|
@ -155,3 +156,11 @@ test('Zoom to fit via View menu works', async () => {
|
|||
const zoomAfter = await getStoreStateNumber('zoom')
|
||||
expect(zoomAfter).not.toBe(zoomBefore)
|
||||
})
|
||||
|
||||
test('Open storage workspace navigates from the File menu', async () => {
|
||||
await editor.page.getByRole('menuitem', { name: 'File', exact: true }).click()
|
||||
await editor.page.getByRole('menuitem', { name: 'Open storage workspace…' }).click()
|
||||
|
||||
await expect(editor.page).toHaveURL(/\/storage$/)
|
||||
await expect(editor.page.getByRole('heading', { name: 'Storage workspace' })).toBeVisible()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -43,6 +43,15 @@ describe('APP_MENU_SCHEMA', () => {
|
|||
expect(commandIds).toContain('selection.moveToPage')
|
||||
})
|
||||
|
||||
test('includes storage workspace navigation in the shared File menu', () => {
|
||||
const fileMenu = APP_MENU_SCHEMA.find((group) => group.label === 'File')
|
||||
const entries = fileMenu ? actionItems(fileMenu.items) : []
|
||||
|
||||
expect(entries).toContainEqual(
|
||||
expect.objectContaining({ id: 'open-storage-workspace', label: 'Open Storage Workspace…' })
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps move-to-page destination selection in the browser menu', () => {
|
||||
const objectMenu = APP_MENU_SCHEMA.find((group) => group.label === 'Object')
|
||||
const entries = objectMenu ? actionItems(objectMenu.items) : []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -234,7 +234,10 @@ describe('Figma component property import', () => {
|
|||
parentIndex: { guid: pageGuid, position: '"' },
|
||||
type: 'SYMBOL',
|
||||
name: 'icon/user',
|
||||
size: { x: 16, y: 16 },
|
||||
size: { x: 16, y: 24 },
|
||||
minSize: { value: { x: 0, y: 24 } },
|
||||
stackMode: 'HORIZONTAL',
|
||||
stackHorizontalPadding: 3,
|
||||
transform: { m00: 1, m01: 0, m02: 40, m10: 0, m11: 1, m12: 0 }
|
||||
},
|
||||
{
|
||||
|
|
@ -345,7 +348,13 @@ describe('Figma component property import', () => {
|
|||
.map((id) => graph.getNode(id))
|
||||
.find((node) => node?.type === 'INSTANCE')
|
||||
const iconChild = icon?.childIds.map((id) => graph.getNode(id)).find(Boolean)
|
||||
expect(icon?.name).toBe('icon/user')
|
||||
expect(icon).toMatchObject({
|
||||
name: 'icon/user',
|
||||
layoutMode: 'HORIZONTAL',
|
||||
minHeight: 24,
|
||||
paddingLeft: 3,
|
||||
paddingRight: 0
|
||||
})
|
||||
expect(iconChild?.name).toBe('user-path')
|
||||
expect(iconChild?.strokes[0]?.color).toEqual({ r: 0.2, g: 0.25, b: 0.33, a: 1 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
applyGeneratedFreeformStretch,
|
||||
buildDsdLayoutUpdates,
|
||||
propagateDsdChanges,
|
||||
type OverrideContext
|
||||
|
|
@ -35,6 +36,89 @@ describe('fig import derived symbol data', () => {
|
|||
expect(clone.figmaDerivedLayout).toEqual(source.figmaDerivedLayout)
|
||||
})
|
||||
|
||||
test('inherits derived positions when a clone has an explicit derived size', () => {
|
||||
const graph = new SceneGraph()
|
||||
const source = graph.createNode('INSTANCE', pageId(graph), {
|
||||
x: 152,
|
||||
y: 0,
|
||||
width: 136,
|
||||
height: 40,
|
||||
figmaDerivedLayout: { x: 152, y: 0, width: 136, height: 40 }
|
||||
})
|
||||
const clone = graph.createNode('INSTANCE', pageId(graph), {
|
||||
x: 136,
|
||||
y: 0,
|
||||
width: 144,
|
||||
height: 48,
|
||||
componentId: source.id,
|
||||
figmaDerivedLayout: { x: 136, y: 4, width: 144, height: 48 }
|
||||
})
|
||||
const ctx = {
|
||||
graph,
|
||||
activeNodeIds: new Set([source.id, clone.id]),
|
||||
geometryOverrideNodes: new Set()
|
||||
} as OverrideContext
|
||||
|
||||
propagateDsdChanges(ctx, new Set([source.id]), new Set([clone.id]))
|
||||
|
||||
expect(graph.getNode(clone.id)).toMatchObject({
|
||||
x: 152,
|
||||
y: 0,
|
||||
figmaDerivedLayout: {
|
||||
x: 152,
|
||||
y: 0,
|
||||
width: 144,
|
||||
height: 48
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('applies generated stretch dimensions inside authoritative freeform parents', () => {
|
||||
const graph = new SceneGraph()
|
||||
const parent = graph.createNode('FRAME', pageId(graph), {
|
||||
width: 232,
|
||||
height: 24,
|
||||
layoutMode: 'NONE',
|
||||
figmaDerivedLayout: { width: 232, height: 24 }
|
||||
})
|
||||
const text = graph.createNode('TEXT', parent.id, {
|
||||
width: 256,
|
||||
height: 20,
|
||||
horizontalConstraint: 'STRETCH',
|
||||
figmaDerivedLayout: { width: 232, height: 20 }
|
||||
})
|
||||
const ctx = {
|
||||
graph,
|
||||
activeNodeIds: new Set([parent.id, text.id])
|
||||
} as OverrideContext
|
||||
|
||||
applyGeneratedFreeformStretch(ctx)
|
||||
|
||||
expect(graph.getNode(text.id)?.width).toBe(232)
|
||||
})
|
||||
|
||||
test('ignores stretch axes without derived dimensions', () => {
|
||||
const graph = new SceneGraph()
|
||||
const parent = graph.createNode('FRAME', pageId(graph), {
|
||||
width: 232,
|
||||
height: 24,
|
||||
layoutMode: 'NONE',
|
||||
figmaDerivedLayout: { width: 232 }
|
||||
})
|
||||
const child = graph.createNode('FRAME', parent.id, {
|
||||
width: 100,
|
||||
height: 20,
|
||||
horizontalConstraint: 'STRETCH',
|
||||
verticalConstraint: 'STRETCH',
|
||||
figmaDerivedLayout: { height: 20 }
|
||||
})
|
||||
const ctx = { graph, activeNodeIds: new Set([parent.id, child.id]) } as OverrideContext
|
||||
|
||||
applyGeneratedFreeformStretch(ctx)
|
||||
|
||||
expect(graph.getNode(child.id)).toMatchObject({ width: 100, height: 20 })
|
||||
})
|
||||
|
||||
test('keeps the existing position when derived data only changes size', () => {
|
||||
const graph = new SceneGraph()
|
||||
const component = graph.createNode('COMPONENT', pageId(graph), { x: 100, y: 100 })
|
||||
|
|
|
|||
|
|
@ -7,29 +7,13 @@ import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
|
|||
import { canvas, doc, node } from '../helpers'
|
||||
|
||||
describe('fig-import: auto-layout alignment', () => {
|
||||
test('keeps variable-bound leading padding independent', () => {
|
||||
test('keeps leading padding independent when trailing fields are omitted', () => {
|
||||
const props = nodeChangeToProps(
|
||||
{
|
||||
type: 'FRAME',
|
||||
stackMode: 'VERTICAL',
|
||||
stackVerticalPadding: 8,
|
||||
stackHorizontalPadding: 6,
|
||||
variableConsumptionMap: {
|
||||
entries: [
|
||||
{
|
||||
variableField: 'STACK_PADDING_TOP',
|
||||
variableData: {
|
||||
value: { alias: { guid: { sessionID: 2, localID: 1 } } }
|
||||
}
|
||||
},
|
||||
{
|
||||
variableField: 'STACK_PADDING_LEFT',
|
||||
variableData: {
|
||||
value: { alias: { guid: { sessionID: 2, localID: 2 } } }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
stackHorizontalPadding: 6
|
||||
} as NodeChange,
|
||||
[]
|
||||
)
|
||||
|
|
@ -39,6 +23,22 @@ describe('fig-import: auto-layout alignment', () => {
|
|||
expect(props.paddingRight).toBe(0)
|
||||
})
|
||||
|
||||
test('imports min and max size vectors as axis constraints', () => {
|
||||
const props = nodeChangeToProps(
|
||||
{
|
||||
type: 'FRAME',
|
||||
minSize: { value: { x: 192, y: 0 } },
|
||||
maxSize: { value: { x: 672, y: -1 } }
|
||||
} as NodeChange,
|
||||
[]
|
||||
)
|
||||
|
||||
expect(props.minWidth).toBe(192)
|
||||
expect(props.minHeight).toBeNull()
|
||||
expect(props.maxWidth).toBe(672)
|
||||
expect(props.maxHeight).toBeNull()
|
||||
})
|
||||
|
||||
test('maps SPACE_EVENLY kiwi primary alignment to Figma space-between', () => {
|
||||
const graph = importNodeChanges([
|
||||
doc(),
|
||||
|
|
|
|||
|
|
@ -83,6 +83,67 @@ describe('fig-import: variable asset refs', () => {
|
|||
})
|
||||
})
|
||||
|
||||
test('imports native scalar variable bindings', () => {
|
||||
const graph = importNodeChanges([
|
||||
doc(),
|
||||
canvas(),
|
||||
{
|
||||
...node('VARIABLE_SET', 20, 1),
|
||||
variableSetModes: [{ id: { sessionID: 10, localID: 1 }, name: 'Default' }]
|
||||
} as NodeChange,
|
||||
{
|
||||
...node('VARIABLE', 21, 1),
|
||||
variableSetID: { guid: { sessionID: 1, localID: 20 } },
|
||||
variableResolvedType: 'FLOAT',
|
||||
variableDataValues: {
|
||||
entries: [
|
||||
{
|
||||
modeID: { sessionID: 10, localID: 1 },
|
||||
variableData: {
|
||||
dataType: 'FLOAT',
|
||||
resolvedDataType: 'FLOAT',
|
||||
value: { floatValue: 50 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
} as NodeChange,
|
||||
node('FRAME', 30, 1, {
|
||||
opacity: 0.2,
|
||||
variableConsumptionMap: {
|
||||
entries: [
|
||||
{
|
||||
variableField: 'OPACITY',
|
||||
variableData: {
|
||||
dataType: 'ALIAS',
|
||||
resolvedDataType: 'FLOAT',
|
||||
value: { alias: { guid: { sessionID: 1, localID: 21 } } }
|
||||
}
|
||||
},
|
||||
{
|
||||
variableField: 'WIDTH',
|
||||
variableData: {
|
||||
dataType: 'ALIAS',
|
||||
resolvedDataType: 'FLOAT',
|
||||
value: { alias: { guid: { sessionID: 1, localID: 21 } } }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
const frame = expectDefined(
|
||||
[...graph.getAllNodes()].find((candidate) => candidate.name === 'FRAME_30'),
|
||||
'bound frame'
|
||||
)
|
||||
expect(frame.opacity).toBe(0.5)
|
||||
expect(frame.width).toBe(50)
|
||||
expect(frame.boundVariables.opacity).toBe('1:21')
|
||||
expect(frame.boundVariables.width).toBe('1:21')
|
||||
expect(graph.resolveNumberVariableForNode(frame.id, '1:21')).toBe(50)
|
||||
})
|
||||
|
||||
test('resolves color variables and aliases by assetRef', () => {
|
||||
const graph = importNodeChanges([
|
||||
doc(),
|
||||
|
|
|
|||
|
|
@ -373,10 +373,10 @@ describe('Figma Kiwi schema coverage', () => {
|
|||
expect(
|
||||
Object.fromEntries([...buckets].map(([bucket, items]) => [bucket, items.length]))
|
||||
).toEqual({
|
||||
modeled: 112,
|
||||
modeled: 114,
|
||||
schemaTag: 60,
|
||||
internalBookkeeping: 17,
|
||||
rawPreserved: 53,
|
||||
rawPreserved: 51,
|
||||
styleLibraryMetadata: 39,
|
||||
componentInstanceMetadata: 33,
|
||||
textMetadata: 23,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ describe('roundtrip: export → re-import', () => {
|
|||
paddingRight: 24,
|
||||
paddingBottom: 24,
|
||||
paddingLeft: 24,
|
||||
minWidth: 320,
|
||||
minHeight: 240,
|
||||
maxWidth: 500,
|
||||
maxHeight: null,
|
||||
cornerRadius: 12,
|
||||
fills: [
|
||||
{
|
||||
|
|
@ -255,6 +259,15 @@ describe('roundtrip: export → re-import', () => {
|
|||
expect(container.paddingLeft).toBe(24)
|
||||
})
|
||||
|
||||
test('preserves min and max size constraints', () => {
|
||||
const container = reImportedNodes.find((n) => n.name === 'Container')
|
||||
expect(container).toBeDefined()
|
||||
expect(expectDefined(container, 'container').minWidth).toBe(320)
|
||||
expect(container.minHeight).toBe(240)
|
||||
expect(container.maxWidth).toBe(500)
|
||||
expect(container.maxHeight).toBeNull()
|
||||
})
|
||||
|
||||
test('preserves corner radius', () => {
|
||||
const container = reImportedNodes.find((n) => n.name === 'Container')
|
||||
expect(container).toBeDefined()
|
||||
|
|
|
|||
|
|
@ -140,6 +140,29 @@ describe('fig roundtrip source metadata', () => {
|
|||
}
|
||||
})
|
||||
|
||||
test('does not restore cleared imported size constraints from raw metadata', async () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const frame = graph.createNode('FRAME', page.id, { name: 'Cleared constraints' })
|
||||
frame.source.format = 'fig'
|
||||
frame.source.id = '4:501'
|
||||
frame.source.fig.rawNodeFields.minSize = { value: { x: 120, y: 80 } }
|
||||
frame.source.fig.rawNodeFields.maxSize = {
|
||||
value: { x: 500, y: Number.POSITIVE_INFINITY }
|
||||
}
|
||||
|
||||
const decoded = decodeExport(await exportFigFile(graph))
|
||||
const exported = decoded.nodeChanges.find(
|
||||
(nodeChange) => nodeChange.guid && guidToString(nodeChange.guid) === '4:501'
|
||||
)
|
||||
|
||||
expect(exported).toBeDefined()
|
||||
expect(exported?.minSize).toBeUndefined()
|
||||
expect(exported?.maxSize).toBeUndefined()
|
||||
expect(frame.source.fig.rawNodeFields.minSize).toBeDefined()
|
||||
expect(frame.source.fig.rawNodeFields.maxSize).toBeDefined()
|
||||
})
|
||||
|
||||
test('preserves imported rich text schema metadata for round-trip', async () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ describe('alignment - primary axis', () => {
|
|||
const frame = autoFrame(graph, pageId(graph), {
|
||||
width: 400,
|
||||
height: 100,
|
||||
itemSpacing: 32,
|
||||
primaryAxisAlign: 'SPACE_BETWEEN'
|
||||
})
|
||||
rect(graph, frame.id, 50, 50)
|
||||
|
|
@ -54,6 +55,27 @@ describe('alignment - primary axis', () => {
|
|||
expect(children[1].x).toBeCloseTo(175, 0)
|
||||
})
|
||||
|
||||
test('space-between alignment ignores stored primary spacing vertically', () => {
|
||||
const graph = new SceneGraph()
|
||||
const frame = autoFrame(graph, pageId(graph), {
|
||||
layoutMode: 'VERTICAL',
|
||||
width: 100,
|
||||
height: 400,
|
||||
itemSpacing: 32,
|
||||
primaryAxisAlign: 'SPACE_BETWEEN'
|
||||
})
|
||||
rect(graph, frame.id, 50, 50)
|
||||
rect(graph, frame.id, 50, 50)
|
||||
rect(graph, frame.id, 50, 50)
|
||||
|
||||
computeLayout(graph, frame.id)
|
||||
|
||||
const children = graph.getChildren(frame.id)
|
||||
expect(children[0].y).toBe(0)
|
||||
expect(children[1].y).toBeCloseTo(175, 0)
|
||||
expect(children[2].y).toBe(350)
|
||||
})
|
||||
|
||||
test('center alignment (vertical)', () => {
|
||||
const graph = new SceneGraph()
|
||||
const frame = autoFrame(graph, pageId(graph), {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { computeAllLayouts, SceneGraph } from '@open-pencil/core'
|
||||
import { getAbsolutePositionFull } from '@open-pencil/scene-graph'
|
||||
|
||||
describe('imported auto-layout bounds', () => {
|
||||
test('preserves visible hug container bounds when hidden children would collapse layout', () => {
|
||||
|
|
@ -104,6 +105,373 @@ describe('imported auto-layout bounds', () => {
|
|||
expect(graph.getNode(field.id)).toMatchObject({ x: 0, y: 0, width: 276, height: 40 })
|
||||
})
|
||||
|
||||
test('uses imported HUG dimensions when positioning following siblings', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const column = graph.createNode('FRAME', page.id, {
|
||||
width: 200,
|
||||
height: 100,
|
||||
layoutMode: 'VERTICAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED'
|
||||
})
|
||||
graph.createNode('FRAME', column.id, {
|
||||
width: 200,
|
||||
height: 1,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'HUG',
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
strokes: [
|
||||
{
|
||||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||
weight: 1,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
align: 'CENTER'
|
||||
}
|
||||
],
|
||||
figmaDerivedLayout: { x: 0, y: 0, width: 200, height: 1 }
|
||||
})
|
||||
const following = graph.createNode('RECTANGLE', column.id, { width: 200, height: 20 })
|
||||
|
||||
computeAllLayouts(graph)
|
||||
|
||||
expect(graph.getNode(following.id)?.y).toBe(1)
|
||||
})
|
||||
|
||||
test('positions generated fill children from exact imported dimensions and gaps', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const row = graph.createNode('FRAME', page.id, {
|
||||
width: 288,
|
||||
height: 40,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
itemSpacing: 16
|
||||
})
|
||||
const first = graph.createNode('INSTANCE', row.id, {
|
||||
width: 136,
|
||||
height: 14,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
layoutGrow: 1,
|
||||
figmaDerivedLayout: { width: 136, height: 14 }
|
||||
})
|
||||
const second = graph.createNode('INSTANCE', row.id, {
|
||||
width: 136,
|
||||
height: 40,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
layoutGrow: 1,
|
||||
figmaDerivedLayout: { width: 136, height: 40 }
|
||||
})
|
||||
graph.createNode('RECTANGLE', first.id, { width: 200, height: 14 })
|
||||
|
||||
computeAllLayouts(graph)
|
||||
|
||||
expect(graph.getNode(first.id)).toMatchObject({ x: 0, width: 136 })
|
||||
expect(graph.getNode(second.id)).toMatchObject({ x: 152, width: 136 })
|
||||
})
|
||||
|
||||
test('uses exact imported dimensions with space-between despite stored spacing', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const row = graph.createNode('FRAME', page.id, {
|
||||
width: 288,
|
||||
height: 40,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
primaryAxisAlign: 'SPACE_BETWEEN',
|
||||
itemSpacing: 16
|
||||
})
|
||||
const first = graph.createNode('INSTANCE', row.id, {
|
||||
width: 136,
|
||||
height: 40,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
layoutGrow: 1,
|
||||
figmaDerivedLayout: { width: 136, height: 40 }
|
||||
})
|
||||
const second = graph.createNode('INSTANCE', row.id, {
|
||||
width: 152,
|
||||
height: 40,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
layoutGrow: 1,
|
||||
figmaDerivedLayout: { width: 152, height: 40 }
|
||||
})
|
||||
|
||||
computeAllLayouts(graph)
|
||||
|
||||
expect(graph.getNode(first.id)).toMatchObject({ x: 0, width: 136 })
|
||||
expect(graph.getNode(second.id)).toMatchObject({ x: 136, width: 152 })
|
||||
})
|
||||
|
||||
test('keeps normal flex sizing when imported dimensions do not fit the parent', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const row = graph.createNode('FRAME', page.id, {
|
||||
width: 320,
|
||||
height: 40,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
itemSpacing: 16
|
||||
})
|
||||
const first = graph.createNode('INSTANCE', row.id, {
|
||||
width: 136,
|
||||
height: 14,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
layoutGrow: 1,
|
||||
figmaDerivedLayout: { width: 136, height: 14 }
|
||||
})
|
||||
const second = graph.createNode('INSTANCE', row.id, {
|
||||
width: 136,
|
||||
height: 40,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
layoutGrow: 1,
|
||||
figmaDerivedLayout: { width: 136, height: 40 }
|
||||
})
|
||||
|
||||
computeAllLayouts(graph)
|
||||
|
||||
expect(graph.getNode(first.id)).toMatchObject({ x: 0, width: 136 })
|
||||
expect(graph.getNode(second.id)).toMatchObject({ x: 168, width: 136 })
|
||||
})
|
||||
|
||||
test('stretches generated children inside authoritative imported bounds', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const column = graph.createNode('INSTANCE', page.id, {
|
||||
width: 302,
|
||||
height: 60,
|
||||
layoutMode: 'VERTICAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
figmaDerivedLayout: { width: 302, height: 60 }
|
||||
})
|
||||
const label = graph.createNode('INSTANCE', column.id, {
|
||||
width: 280,
|
||||
height: 14,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'HUG',
|
||||
layoutAlignSelf: 'STRETCH'
|
||||
})
|
||||
|
||||
computeAllLayouts(graph)
|
||||
|
||||
expect(graph.getNode(label.id)).toMatchObject({ x: 0, width: 302 })
|
||||
})
|
||||
|
||||
test('does not infer authoritative stretch without generated parent bounds', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const column = graph.createNode('FRAME', page.id, {
|
||||
width: 624,
|
||||
height: 290,
|
||||
layoutMode: 'VERTICAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED'
|
||||
})
|
||||
const label = graph.createNode('INSTANCE', column.id, {
|
||||
width: 44,
|
||||
height: 14,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'HUG',
|
||||
layoutAlignSelf: 'STRETCH'
|
||||
})
|
||||
|
||||
computeAllLayouts(graph)
|
||||
|
||||
expect(graph.getNode(label.id)?.width).toBe(44)
|
||||
})
|
||||
|
||||
test('preserves hidden child geometry while excluding it from parent flow', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const column = graph.createNode('FRAME', page.id, {
|
||||
width: 280,
|
||||
height: 40,
|
||||
layoutMode: 'VERTICAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED'
|
||||
})
|
||||
const label = graph.createNode('INSTANCE', column.id, {
|
||||
width: 280,
|
||||
height: 14,
|
||||
visible: false,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'HUG'
|
||||
})
|
||||
graph.createNode('TEXT', label.id, {
|
||||
width: 37,
|
||||
height: 14,
|
||||
text: 'Label',
|
||||
textAutoResize: 'WIDTH_AND_HEIGHT',
|
||||
figmaDerivedLayout: { x: 0, y: 0, width: 37, height: 14 }
|
||||
})
|
||||
|
||||
computeAllLayouts(graph)
|
||||
|
||||
expect(graph.getNode(label.id)).toMatchObject({ width: 280, height: 14, visible: false })
|
||||
})
|
||||
|
||||
test('uses exact derived width for a growing text leaf when siblings fill the parent', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const row = graph.createNode('INSTANCE', page.id, {
|
||||
width: 224,
|
||||
height: 32,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
itemSpacing: 8,
|
||||
paddingTop: 8,
|
||||
paddingRight: 8,
|
||||
paddingBottom: 8,
|
||||
paddingLeft: 8
|
||||
})
|
||||
graph.createNode('INSTANCE', row.id, { width: 16, height: 16 })
|
||||
const text = graph.createNode('TEXT', row.id, {
|
||||
width: 196,
|
||||
height: 20,
|
||||
text: 'Models',
|
||||
textAutoResize: 'HEIGHT',
|
||||
layoutGrow: 1,
|
||||
figmaDerivedLayout: { width: 160, height: 20 }
|
||||
})
|
||||
const chevron = graph.createNode('INSTANCE', row.id, { width: 16, height: 16 })
|
||||
|
||||
computeAllLayouts(graph)
|
||||
|
||||
expect(graph.getNode(text.id)).toMatchObject({ x: 32, width: 160 })
|
||||
expect(graph.getNode(chevron.id)?.x).toBe(200)
|
||||
})
|
||||
|
||||
test('lets live Yoga resize imported text when stored and derived sizes agree', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const frame = graph.createNode('FRAME', page.id, {
|
||||
width: 200,
|
||||
height: 100,
|
||||
layoutMode: 'VERTICAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
paddingLeft: 20,
|
||||
paddingRight: 20
|
||||
})
|
||||
const text = graph.createNode('TEXT', frame.id, {
|
||||
width: 424,
|
||||
height: 40,
|
||||
layoutAlignSelf: 'STRETCH',
|
||||
figmaDerivedLayout: { width: 424, height: 40 }
|
||||
})
|
||||
graph.updateNode(text.id, {
|
||||
source: { ...text.source, format: 'fig', id: '1:3' }
|
||||
})
|
||||
|
||||
computeAllLayouts(graph)
|
||||
|
||||
expect(graph.getNode(text.id)).toMatchObject({ width: 160, height: 40 })
|
||||
})
|
||||
|
||||
test('preserves imported HUG cross size backed by stretched derived children', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const frame = graph.createNode('FRAME', page.id, {
|
||||
width: 381,
|
||||
height: 102,
|
||||
layoutMode: 'VERTICAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'HUG',
|
||||
paddingTop: 24,
|
||||
paddingRight: 24,
|
||||
paddingBottom: 24,
|
||||
paddingLeft: 24
|
||||
})
|
||||
graph.updateNode(frame.id, { source: { ...frame.source, format: 'fig' } })
|
||||
graph.createNode('TEXT', frame.id, {
|
||||
width: 333,
|
||||
height: 30,
|
||||
text: 'Bar Chart',
|
||||
textAutoResize: 'HEIGHT',
|
||||
layoutAlignSelf: 'STRETCH',
|
||||
figmaDerivedLayout: { width: 333, height: 30 }
|
||||
})
|
||||
|
||||
computeAllLayouts(graph)
|
||||
|
||||
expect(graph.getNode(frame.id)?.width).toBe(381)
|
||||
})
|
||||
|
||||
test('preserves direct imported frame geometry inside imported parents', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const parent = graph.createNode('FRAME', page.id, {
|
||||
width: 768,
|
||||
height: 454,
|
||||
layoutMode: 'VERTICAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
counterAxisAlign: 'CENTER'
|
||||
})
|
||||
const child = graph.createNode('FRAME', parent.id, {
|
||||
x: 192,
|
||||
y: 40,
|
||||
width: 384,
|
||||
height: 414,
|
||||
layoutMode: 'VERTICAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
layoutAlignSelf: 'STRETCH'
|
||||
})
|
||||
graph.updateNode(parent.id, { source: { ...parent.source, format: 'fig' } })
|
||||
graph.updateNode(child.id, { source: { ...child.source, format: 'fig' } })
|
||||
|
||||
computeAllLayouts(graph)
|
||||
|
||||
expect(graph.getNode(child.id)).toMatchObject({ x: 192, y: 40, width: 384, height: 414 })
|
||||
})
|
||||
|
||||
test('preserves transformed bounds for rotated imported flow children', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const parent = graph.createNode('FRAME', page.id, {
|
||||
width: 17,
|
||||
height: 20,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
paddingLeft: 16
|
||||
})
|
||||
const separator = graph.createNode('INSTANCE', parent.id, {
|
||||
x: 6.5,
|
||||
y: 9.5,
|
||||
width: 20,
|
||||
height: 1,
|
||||
rotation: -90
|
||||
})
|
||||
graph.updateNode(separator.id, {
|
||||
source: { ...separator.source, format: 'fig' }
|
||||
})
|
||||
const before = getAbsolutePositionFull(separator, graph)
|
||||
|
||||
computeAllLayouts(graph)
|
||||
|
||||
const afterNode = graph.getNode(separator.id)
|
||||
expect(afterNode).toBeDefined()
|
||||
const after = getAbsolutePositionFull(afterNode ?? separator, graph)
|
||||
expect(after).toMatchObject({
|
||||
boundX: before.boundX,
|
||||
boundY: before.boundY,
|
||||
width: before.width,
|
||||
height: before.height
|
||||
})
|
||||
})
|
||||
|
||||
test('uses Yoga positions for imported instances while preserving imported size', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -48,6 +48,47 @@ describe('text measurement', () => {
|
|||
expect(graph.getNode(tabs.id)?.height).toBe(42)
|
||||
})
|
||||
|
||||
test('direct imported text keeps its NodeChange bounds over glyph layout metadata', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = pageId(graph)
|
||||
const frame = autoFrame(graph, page, {
|
||||
width: 424,
|
||||
height: 100,
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED'
|
||||
})
|
||||
const text = graph.createNode('TEXT', frame.id, {
|
||||
text: 'Truncated preview',
|
||||
width: 424,
|
||||
height: 40,
|
||||
textAutoResize: 'HEIGHT',
|
||||
figmaDerivedLayout: { width: 424, height: 120 },
|
||||
source: {
|
||||
format: 'fig',
|
||||
id: '1:2',
|
||||
orderKey: '!',
|
||||
editedFields: [],
|
||||
fig: {
|
||||
rawNodeFields: {},
|
||||
rawTransform: null,
|
||||
rawSize: null,
|
||||
layout: null,
|
||||
derivedSymbolDataLayoutVersion: null,
|
||||
derivedSymbolData: [],
|
||||
symbolOverrides: [],
|
||||
componentPropAssignments: [],
|
||||
uniformScaleFactor: null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setTextMeasurer(() => ({ width: 424, height: 120 }))
|
||||
computeAllLayouts(graph, page)
|
||||
setTextMeasurer(null)
|
||||
|
||||
expect(graph.getNode(text.id)?.height).toBe(40)
|
||||
})
|
||||
|
||||
test('live text without derived glyphs still uses CanvasKit measurement', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = pageId(graph)
|
||||
|
|
|
|||
Loading…
Reference in a new issue