Fill Figma Plugin API gaps: 29 new tests, 30+ new properties and methods

SceneNode: add minWidth/maxWidth/minHeight/maxHeight, isMask, maskType,
counterAxisAlignContent, itemReverseZIndex, strokesIncludedInLayout,
expanded, textTruncation, autoRename, strokeMiterLimit. All with defaults
and .fig import mapping.

FigmaNodeProxy: add clone(), strokeCap, strokeJoin, strokeMiterLimit,
strokeTopWeight/BottomWeight/LeftWeight/RightWeight, minWidth/maxWidth/
minHeight/maxHeight, isMask, maskType, expanded, textTruncation,
autoRename, insertCharacters, deleteCharacters, primaryAxisSizingMode,
counterAxisSizingMode, counterAxisAlignContent, itemReverseZIndex,
strokesIncludedInLayout, layoutAlign, findAllWithCriteria.

FigmaAPI: add createComponentFromNode, getVariableById, getLocalVariables,
getLocalVariableCollections, getVariableCollectionById.

Fix layoutSizingHorizontal/Vertical for self-framing auto-layout nodes
with no auto-layout parent — now checks the node's own layoutMode.
This commit is contained in:
Danila Poyarkov 2026-03-01 13:35:02 +03:00 committed by Anton A S
parent 850a1179b3
commit d08eb4f3b4
5 changed files with 663 additions and 10 deletions

View file

@ -6,6 +6,8 @@ import type {
Stroke,
Effect,
LayoutMode,
Variable,
VariableCollection,
} from './scene-graph'
import { SceneGraph } from './scene-graph'
@ -307,12 +309,68 @@ class FigmaNodeProxy {
}
}
get dashPattern(): number[] {
return this._raw().dashPattern
get dashPattern(): readonly number[] {
return Object.freeze([...this._raw().dashPattern])
}
set dashPattern(v: number[]) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { dashPattern: v })
set dashPattern(v: readonly number[]) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { dashPattern: [...v] })
}
get strokeCap(): string {
return this._raw().strokeCap
}
set strokeCap(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokeCap: v as SceneNode['strokeCap'] })
}
get strokeJoin(): string {
return this._raw().strokeJoin
}
set strokeJoin(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokeJoin: v as SceneNode['strokeJoin'] })
}
get strokeMiterLimit(): number {
return this._raw().strokeMiterLimit
}
set strokeMiterLimit(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokeMiterLimit: v })
}
get strokeTopWeight(): number {
return this._raw().borderTopWeight
}
set strokeTopWeight(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { borderTopWeight: v, independentStrokeWeights: true })
}
get strokeBottomWeight(): number {
return this._raw().borderBottomWeight
}
set strokeBottomWeight(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { borderBottomWeight: v, independentStrokeWeights: true })
}
get strokeLeftWeight(): number {
return this._raw().borderLeftWeight
}
set strokeLeftWeight(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { borderLeftWeight: v, independentStrokeWeights: true })
}
get strokeRightWeight(): number {
return this._raw().borderRightWeight
}
set strokeRightWeight(v: number) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { borderRightWeight: v, independentStrokeWeights: true })
}
// --- Text ---
@ -415,6 +473,34 @@ class FigmaNodeProxy {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { maxLines: v })
}
get textTruncation(): string {
return this._raw().textTruncation
}
set textTruncation(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { textTruncation: v as SceneNode['textTruncation'] })
}
get autoRename(): boolean {
return this._raw().autoRename
}
set autoRename(v: boolean) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { autoRename: v })
}
insertCharacters(start: number, characters: string): void {
const n = this._raw()
const text = n.text.slice(0, start) + characters + n.text.slice(start)
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { text })
}
deleteCharacters(start: number, end: number): void {
const n = this._raw()
const text = n.text.slice(0, start) + n.text.slice(end)
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { text })
}
// --- Auto-layout ---
get layoutMode(): LayoutMode {
@ -497,6 +583,48 @@ class FigmaNodeProxy {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { layoutWrap: v as SceneNode['layoutWrap'] })
}
get primaryAxisSizingMode(): string {
return this._raw().primaryAxisSizing === 'HUG' ? 'AUTO' : this._raw().primaryAxisSizing
}
set primaryAxisSizingMode(v: string) {
const mapped = v === 'AUTO' ? 'HUG' : v
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { primaryAxisSizing: mapped as SceneNode['primaryAxisSizing'] })
}
get counterAxisSizingMode(): string {
return this._raw().counterAxisSizing === 'HUG' ? 'AUTO' : this._raw().counterAxisSizing
}
set counterAxisSizingMode(v: string) {
const mapped = v === 'AUTO' ? 'HUG' : v
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { counterAxisSizing: mapped as SceneNode['counterAxisSizing'] })
}
get counterAxisAlignContent(): string {
return this._raw().counterAxisAlignContent
}
set counterAxisAlignContent(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { counterAxisAlignContent: v as SceneNode['counterAxisAlignContent'] })
}
get itemReverseZIndex(): boolean {
return this._raw().itemReverseZIndex
}
set itemReverseZIndex(v: boolean) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { itemReverseZIndex: v })
}
get strokesIncludedInLayout(): boolean {
return this._raw().strokesIncludedInLayout
}
set strokesIncludedInLayout(v: boolean) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokesIncludedInLayout: v })
}
// --- Layout child props ---
get layoutPositioning(): string {
@ -515,17 +643,29 @@ class FigmaNodeProxy {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { layoutGrow: v })
}
get layoutAlign(): string {
const n = this._raw()
return n.layoutAlignSelf === 'STRETCH' ? 'STRETCH' : 'INHERIT'
}
set layoutAlign(v: string) {
const mapped = v === 'STRETCH' ? 'STRETCH' : 'AUTO'
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { layoutAlignSelf: mapped as SceneNode['layoutAlignSelf'] })
}
get layoutSizingHorizontal(): string {
const n = this._raw()
const parent = n.parentId ? this[INTERNAL_GRAPH].getNode(n.parentId) : undefined
if (!parent || parent.layoutMode === 'NONE') return n.primaryAxisSizing
return parent.layoutMode === 'HORIZONTAL' ? n.primaryAxisSizing : n.counterAxisSizing
const refLayout = (parent && parent.layoutMode !== 'NONE') ? parent.layoutMode : n.layoutMode
if (refLayout === 'NONE') return 'FIXED'
return refLayout === 'HORIZONTAL' ? n.primaryAxisSizing : n.counterAxisSizing
}
set layoutSizingHorizontal(v: string) {
const n = this._raw()
const parent = n.parentId ? this[INTERNAL_GRAPH].getNode(n.parentId) : undefined
if (parent && parent.layoutMode === 'VERTICAL') {
const refLayout = (parent && parent.layoutMode !== 'NONE') ? parent.layoutMode : n.layoutMode
if (refLayout === 'VERTICAL') {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { counterAxisSizing: v as SceneNode['counterAxisSizing'] })
} else {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { primaryAxisSizing: v as SceneNode['primaryAxisSizing'] })
@ -535,14 +675,16 @@ class FigmaNodeProxy {
get layoutSizingVertical(): string {
const n = this._raw()
const parent = n.parentId ? this[INTERNAL_GRAPH].getNode(n.parentId) : undefined
if (!parent || parent.layoutMode === 'NONE') return n.counterAxisSizing
return parent.layoutMode === 'VERTICAL' ? n.primaryAxisSizing : n.counterAxisSizing
const refLayout = (parent && parent.layoutMode !== 'NONE') ? parent.layoutMode : n.layoutMode
if (refLayout === 'NONE') return 'FIXED'
return refLayout === 'VERTICAL' ? n.primaryAxisSizing : n.counterAxisSizing
}
set layoutSizingVertical(v: string) {
const n = this._raw()
const parent = n.parentId ? this[INTERNAL_GRAPH].getNode(n.parentId) : undefined
if (parent && parent.layoutMode === 'HORIZONTAL') {
const refLayout = (parent && parent.layoutMode !== 'NONE') ? parent.layoutMode : n.layoutMode
if (refLayout === 'HORIZONTAL') {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { counterAxisSizing: v as SceneNode['counterAxisSizing'] })
} else {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { primaryAxisSizing: v as SceneNode['primaryAxisSizing'] })
@ -563,6 +705,68 @@ class FigmaNodeProxy {
})
}
// --- Dimension constraints ---
get minWidth(): number | null {
return this._raw().minWidth
}
set minWidth(v: number | null) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { minWidth: v })
}
get maxWidth(): number | null {
return this._raw().maxWidth
}
set maxWidth(v: number | null) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { maxWidth: v })
}
get minHeight(): number | null {
return this._raw().minHeight
}
set minHeight(v: number | null) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { minHeight: v })
}
get maxHeight(): number | null {
return this._raw().maxHeight
}
set maxHeight(v: number | null) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { maxHeight: v })
}
// --- Mask ---
get isMask(): boolean {
return this._raw().isMask
}
set isMask(v: boolean) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { isMask: v })
}
get maskType(): string {
return this._raw().maskType
}
set maskType(v: string) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { maskType: v as SceneNode['maskType'] })
}
// --- UI state ---
get expanded(): boolean {
return this._raw().expanded
}
set expanded(v: boolean) {
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { expanded: v })
}
// --- Components ---
get mainComponent(): FigmaNodeProxy | null {
@ -605,6 +809,14 @@ class FigmaNodeProxy {
this[INTERNAL_GRAPH].reorderChild(child[INTERNAL_ID], this[INTERNAL_ID], index)
}
clone(): FigmaNodeProxy {
const n = this._raw()
const parentId = n.parentId ?? this[INTERNAL_API].currentPageId
const cloned = this[INTERNAL_GRAPH].cloneTree(this[INTERNAL_ID], parentId)
if (!cloned) throw new Error(`Failed to clone node ${this[INTERNAL_ID]}`)
return this[INTERNAL_API].wrapNode(cloned.id)
}
remove(): void {
this[INTERNAL_GRAPH].deleteNode(this[INTERNAL_ID])
}
@ -650,6 +862,11 @@ class FigmaNodeProxy {
.filter((proxy) => !callback || callback(proxy))
}
findAllWithCriteria(criteria: { types?: string[] }): FigmaNodeProxy[] {
const types = criteria.types ? new Set(criteria.types) : null
return this.findAll((node) => !types || types.has(node.type))
}
// --- Serialization ---
toJSON(): Record<string, unknown> {
@ -826,6 +1043,63 @@ export class FigmaAPI {
this.graph.deleteNode(node[INTERNAL_ID])
}
createComponentFromNode(node: FigmaNodeProxy): FigmaNodeProxy {
const raw = this.graph.getNode(node[INTERNAL_ID])
if (!raw) throw new Error('Node not found')
const parentId = raw.parentId ?? this._currentPageId
const comp = this.graph.createNode('COMPONENT', parentId)
this.graph.updateNode(comp.id, {
name: raw.name,
width: raw.width,
height: raw.height,
x: raw.x,
y: raw.y,
fills: structuredClone(raw.fills),
strokes: structuredClone(raw.strokes),
effects: structuredClone(raw.effects),
cornerRadius: raw.cornerRadius,
topLeftRadius: raw.topLeftRadius,
topRightRadius: raw.topRightRadius,
bottomRightRadius: raw.bottomRightRadius,
bottomLeftRadius: raw.bottomLeftRadius,
independentCorners: raw.independentCorners,
opacity: raw.opacity,
layoutMode: raw.layoutMode,
primaryAxisAlign: raw.primaryAxisAlign,
counterAxisAlign: raw.counterAxisAlign,
itemSpacing: raw.itemSpacing,
paddingTop: raw.paddingTop,
paddingRight: raw.paddingRight,
paddingBottom: raw.paddingBottom,
paddingLeft: raw.paddingLeft,
})
for (const childId of raw.childIds) {
this.graph.cloneTree(childId, comp.id)
}
this.graph.deleteNode(node[INTERNAL_ID])
return this.wrapNode(comp.id)
}
// --- Variables ---
getVariableById(id: string): Variable | null {
return this.graph.variables.get(id) ?? null
}
getLocalVariables(type?: string): Variable[] {
const vars = [...this.graph.variables.values()]
if (type) return vars.filter((v) => v.type === type)
return vars
}
getLocalVariableCollections(): VariableCollection[] {
return [...this.graph.variableCollections.values()]
}
getVariableCollectionById(id: string): VariableCollection | null {
return this.graph.variableCollections.get(id) ?? null
}
// --- Stubs ---
async loadFontAsync(_fontName: FigmaFontName): Promise<void> {

View file

@ -17,6 +17,7 @@ export {
type Stroke,
type StrokeCap,
type StrokeJoin,
type MaskType,
type Effect,
type BlendMode,
type ImageScaleMode,

View file

@ -478,6 +478,7 @@ export function importNodeChanges(
counterAxisSpacing: (ext(nc).stackCounterSpacing as number) ?? 0,
layoutPositioning: ext(nc).stackPositioning === 'ABSOLUTE' ? 'ABSOLUTE' : 'AUTO',
layoutGrow: (ext(nc).stackChildPrimaryGrow as number) ?? 0,
layoutAlignSelf: (ext(nc).stackChildAlignSelf as string) === 'STRETCH' ? 'STRETCH' : 'AUTO',
vectorNetwork: resolveVectorNetwork(nc, blobs),
arcData: mapArcData(ext(nc).arcData as Record<string, number> | undefined),
strokeCap: (nc.strokeCap ?? 'NONE') as StrokeCap,
@ -488,6 +489,19 @@ export function importNodeChanges(
borderBottomWeight: (ext(nc).borderBottomWeight as number) ?? 0,
borderLeftWeight: (ext(nc).borderLeftWeight as number) ?? 0,
independentStrokeWeights: (ext(nc).borderStrokeWeightsIndependent as boolean) ?? false,
strokeMiterLimit: (ext(nc).strokeMiterAngle as number) ?? 4,
minWidth: (ext(nc).minWidth as number) ?? null,
maxWidth: (ext(nc).maxWidth as number) ?? null,
minHeight: (ext(nc).minHeight as number) ?? null,
maxHeight: (ext(nc).maxHeight as number) ?? null,
isMask: (ext(nc).isMask as boolean) ?? false,
maskType: ((ext(nc).maskType as string) ?? 'ALPHA') as 'ALPHA' | 'VECTOR' | 'LUMINANCE',
counterAxisAlignContent: (ext(nc).stackCounterAlignContent as string) === 'SPACE_BETWEEN' ? 'SPACE_BETWEEN' : 'AUTO',
itemReverseZIndex: (ext(nc).stackReverseZIndex as boolean) ?? false,
strokesIncludedInLayout: (ext(nc).strokesIncludedInLayout as boolean) ?? false,
expanded: nc.frameMaskDisabled !== true,
textTruncation: (ext(nc).textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED',
autoRename: (ext(nc).autoRename as boolean) ?? true,
boundVariables: extractBoundVariables(nc)
})

View file

@ -100,6 +100,7 @@ export interface Fill {
export type StrokeCap = 'NONE' | 'ROUND' | 'SQUARE' | 'ARROW_LINES' | 'ARROW_EQUILATERAL'
export type StrokeJoin = 'MITER' | 'BEVEL' | 'ROUND'
export type MaskType = 'ALPHA' | 'VECTOR' | 'LUMINANCE'
export interface Stroke {
color: Color
@ -237,6 +238,24 @@ export interface SceneNode {
borderLeftWeight: number
independentStrokeWeights: boolean
strokeMiterLimit: number
minWidth: number | null
maxWidth: number | null
minHeight: number | null
maxHeight: number | null
isMask: boolean
maskType: MaskType
counterAxisAlignContent: 'AUTO' | 'SPACE_BETWEEN'
itemReverseZIndex: boolean
strokesIncludedInLayout: boolean
expanded: boolean
textTruncation: 'DISABLED' | 'ENDING'
autoRename: boolean
pointCount: number
starInnerRadius: number
@ -346,6 +365,19 @@ function createDefaultNode(type: NodeType, overrides: Partial<SceneNode> = {}):
borderBottomWeight: 0,
borderLeftWeight: 0,
independentStrokeWeights: false,
strokeMiterLimit: 4,
minWidth: null,
maxWidth: null,
minHeight: null,
maxHeight: null,
isMask: false,
maskType: 'ALPHA',
counterAxisAlignContent: 'AUTO',
itemReverseZIndex: false,
strokesIncludedInLayout: false,
expanded: true,
textTruncation: 'DISABLED',
autoRename: true,
pointCount: 5,
starInnerRadius: 0.38,
componentId: null,

View file

@ -588,5 +588,337 @@ describe('FigmaAPI', () => {
rect.strokeWeight = 4
expect(rect.strokeWeight).toBe(4)
})
test('strokeCap and strokeJoin', () => {
const api = createAPI()
const line = api.createLine()
expect(line.strokeCap).toBe('NONE')
expect(line.strokeJoin).toBe('MITER')
line.strokeCap = 'ROUND'
line.strokeJoin = 'BEVEL'
expect(line.strokeCap).toBe('ROUND')
expect(line.strokeJoin).toBe('BEVEL')
})
test('strokeMiterLimit', () => {
const api = createAPI()
const rect = api.createRectangle()
expect(rect.strokeMiterLimit).toBe(4)
rect.strokeMiterLimit = 8
expect(rect.strokeMiterLimit).toBe(8)
})
test('individual stroke weights', () => {
const api = createAPI()
const frame = api.createFrame()
frame.strokeTopWeight = 1
frame.strokeRightWeight = 2
frame.strokeBottomWeight = 3
frame.strokeLeftWeight = 4
expect(frame.strokeTopWeight).toBe(1)
expect(frame.strokeRightWeight).toBe(2)
expect(frame.strokeBottomWeight).toBe(3)
expect(frame.strokeLeftWeight).toBe(4)
})
})
describe('clone', () => {
test('clone creates a deep copy', () => {
const api = createAPI()
const frame = api.createFrame()
frame.name = 'Original'
frame.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
const child = api.createRectangle()
child.name = 'Child'
frame.appendChild(child)
const cloned = frame.clone()
expect(cloned.id).not.toBe(frame.id)
expect(cloned.name).toBe('Original')
expect(cloned.fills[0].color.r).toBe(1)
expect(cloned.children.length).toBe(1)
expect(cloned.children[0].name).toBe('Child')
expect(cloned.children[0].id).not.toBe(child.id)
})
})
describe('min/max dimensions', () => {
test('defaults to null', () => {
const api = createAPI()
const frame = api.createFrame()
expect(frame.minWidth).toBeNull()
expect(frame.maxWidth).toBeNull()
expect(frame.minHeight).toBeNull()
expect(frame.maxHeight).toBeNull()
})
test('can set and read', () => {
const api = createAPI()
const frame = api.createFrame()
frame.minWidth = 50
frame.maxWidth = 400
frame.minHeight = 30
frame.maxHeight = 600
expect(frame.minWidth).toBe(50)
expect(frame.maxWidth).toBe(400)
expect(frame.minHeight).toBe(30)
expect(frame.maxHeight).toBe(600)
})
test('can reset to null', () => {
const api = createAPI()
const frame = api.createFrame()
frame.minWidth = 50
frame.minWidth = null
expect(frame.minWidth).toBeNull()
})
})
describe('mask', () => {
test('isMask defaults to false', () => {
const api = createAPI()
const rect = api.createRectangle()
expect(rect.isMask).toBe(false)
expect(rect.maskType).toBe('ALPHA')
})
test('can set mask properties', () => {
const api = createAPI()
const rect = api.createRectangle()
rect.isMask = true
rect.maskType = 'LUMINANCE'
expect(rect.isMask).toBe(true)
expect(rect.maskType).toBe('LUMINANCE')
})
})
describe('auto-layout extras', () => {
test('primaryAxisSizingMode maps FIXED/AUTO', () => {
const api = createAPI()
const frame = api.createFrame()
frame.layoutMode = 'VERTICAL'
expect(frame.primaryAxisSizingMode).toBe('FIXED')
frame.primaryAxisSizingMode = 'AUTO'
expect(frame.primaryAxisSizingMode).toBe('AUTO')
expect(frame.layoutSizingVertical).toBe('HUG')
})
test('counterAxisSizingMode maps FIXED/AUTO', () => {
const api = createAPI()
const frame = api.createFrame()
frame.layoutMode = 'VERTICAL'
frame.counterAxisSizingMode = 'AUTO'
expect(frame.counterAxisSizingMode).toBe('AUTO')
expect(frame.layoutSizingHorizontal).toBe('HUG')
})
test('counterAxisAlignContent', () => {
const api = createAPI()
const frame = api.createFrame()
expect(frame.counterAxisAlignContent).toBe('AUTO')
frame.counterAxisAlignContent = 'SPACE_BETWEEN'
expect(frame.counterAxisAlignContent).toBe('SPACE_BETWEEN')
})
test('itemReverseZIndex', () => {
const api = createAPI()
const frame = api.createFrame()
expect(frame.itemReverseZIndex).toBe(false)
frame.itemReverseZIndex = true
expect(frame.itemReverseZIndex).toBe(true)
})
test('strokesIncludedInLayout', () => {
const api = createAPI()
const frame = api.createFrame()
expect(frame.strokesIncludedInLayout).toBe(false)
frame.strokesIncludedInLayout = true
expect(frame.strokesIncludedInLayout).toBe(true)
})
test('layoutAlign maps to layoutAlignSelf', () => {
const api = createAPI()
const parent = api.createFrame()
parent.layoutMode = 'VERTICAL'
const child = api.createFrame()
parent.appendChild(child)
expect(child.layoutAlign).toBe('INHERIT')
child.layoutAlign = 'STRETCH'
expect(child.layoutAlign).toBe('STRETCH')
})
})
describe('text extras', () => {
test('textTruncation', () => {
const api = createAPI()
const text = api.createText()
expect(text.textTruncation).toBe('DISABLED')
text.textTruncation = 'ENDING'
expect(text.textTruncation).toBe('ENDING')
})
test('autoRename', () => {
const api = createAPI()
const text = api.createText()
expect(text.autoRename).toBe(true)
text.autoRename = false
expect(text.autoRename).toBe(false)
})
test('insertCharacters', () => {
const api = createAPI()
const text = api.createText()
text.characters = 'Hello World'
text.insertCharacters(5, ' Beautiful')
expect(text.characters).toBe('Hello Beautiful World')
})
test('deleteCharacters', () => {
const api = createAPI()
const text = api.createText()
text.characters = 'Hello Beautiful World'
text.deleteCharacters(5, 15)
expect(text.characters).toBe('Hello World')
})
})
describe('expanded', () => {
test('defaults to true', () => {
const api = createAPI()
const frame = api.createFrame()
expect(frame.expanded).toBe(true)
})
test('can collapse', () => {
const api = createAPI()
const frame = api.createFrame()
frame.expanded = false
expect(frame.expanded).toBe(false)
})
})
describe('findAllWithCriteria', () => {
test('filters by type', () => {
const api = createAPI()
api.createFrame()
api.createRectangle()
api.createText()
api.createRectangle()
const rects = api.currentPage.findAllWithCriteria({ types: ['RECTANGLE'] })
expect(rects.length).toBe(2)
expect(rects.every((n) => n.type === 'RECTANGLE')).toBe(true)
})
test('filters by multiple types', () => {
const api = createAPI()
api.createFrame()
api.createRectangle()
api.createText()
const result = api.currentPage.findAllWithCriteria({ types: ['FRAME', 'TEXT'] })
expect(result.length).toBe(2)
})
})
describe('createComponentFromNode', () => {
test('converts frame to component', () => {
const api = createAPI()
const frame = api.createFrame()
frame.name = 'MyButton'
frame.resize(200, 50)
const child = api.createRectangle()
child.name = 'Background'
frame.appendChild(child)
const frameId = frame.id
const comp = api.createComponentFromNode(frame)
expect(comp.type).toBe('COMPONENT')
expect(comp.name).toBe('MyButton')
expect(comp.width).toBe(200)
expect(comp.height).toBe(50)
expect(comp.children.length).toBe(1)
expect(comp.children[0].name).toBe('Background')
expect(api.getNodeById(frameId)).toBeNull()
})
})
describe('variables', () => {
test('getLocalVariables returns empty by default', () => {
const api = createAPI()
expect(api.getLocalVariables()).toEqual([])
})
test('getLocalVariables returns added variables', () => {
const api = createAPI()
api.graph.addCollection({
id: 'col1',
name: 'Colors',
modes: [{ modeId: 'mode1', name: 'Default' }],
defaultModeId: 'mode1',
variableIds: [],
})
api.graph.addVariable({
id: 'var1',
name: 'primary',
type: 'COLOR',
collectionId: 'col1',
valuesByMode: { mode1: { r: 1, g: 0, b: 0, a: 1 } },
description: '',
hiddenFromPublishing: false,
})
expect(api.getLocalVariables().length).toBe(1)
expect(api.getLocalVariables('COLOR').length).toBe(1)
expect(api.getLocalVariables('FLOAT').length).toBe(0)
})
test('getVariableById', () => {
const api = createAPI()
api.graph.addCollection({
id: 'col1',
name: 'Spacing',
modes: [{ modeId: 'mode1', name: 'Default' }],
defaultModeId: 'mode1',
variableIds: [],
})
api.graph.addVariable({
id: 'var1',
name: 'spacing-sm',
type: 'FLOAT',
collectionId: 'col1',
valuesByMode: { mode1: 8 },
description: '',
hiddenFromPublishing: false,
})
const v = api.getVariableById('var1')
expect(v).not.toBeNull()
expect(v!.name).toBe('spacing-sm')
expect(api.getVariableById('nonexistent')).toBeNull()
})
test('getLocalVariableCollections', () => {
const api = createAPI()
api.graph.addCollection({
id: 'col1',
name: 'Colors',
modes: [{ modeId: 'mode1', name: 'Default' }],
defaultModeId: 'mode1',
variableIds: [],
})
const cols = api.getLocalVariableCollections()
expect(cols.length).toBe(1)
expect(cols[0].name).toBe('Colors')
})
test('getVariableCollectionById', () => {
const api = createAPI()
api.graph.addCollection({
id: 'col1',
name: 'Colors',
modes: [{ modeId: 'mode1', name: 'Default' }],
defaultModeId: 'mode1',
variableIds: [],
})
expect(api.getVariableCollectionById('col1')?.name).toBe('Colors')
expect(api.getVariableCollectionById('nonexistent')).toBeNull()
})
})
})