diff --git a/packages/core/src/figma-api.ts b/packages/core/src/figma-api.ts index d3afb4602..d6570cb13 100644 --- a/packages/core/src/figma-api.ts +++ b/packages/core/src/figma-api.ts @@ -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 { @@ -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 { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 770e6e628..713d7d588 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,7 @@ export { type Stroke, type StrokeCap, type StrokeJoin, + type MaskType, type Effect, type BlendMode, type ImageScaleMode, diff --git a/packages/core/src/kiwi/fig-import.ts b/packages/core/src/kiwi/fig-import.ts index b7e40789e..c26d29f9e 100644 --- a/packages/core/src/kiwi/fig-import.ts +++ b/packages/core/src/kiwi/fig-import.ts @@ -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 | 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) }) diff --git a/packages/core/src/scene-graph.ts b/packages/core/src/scene-graph.ts index 0d6c99992..2b377b078 100644 --- a/packages/core/src/scene-graph.ts +++ b/packages/core/src/scene-graph.ts @@ -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 = {}): 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, diff --git a/tests/engine/figma-api.test.ts b/tests/engine/figma-api.test.ts index 0f63944ef..6c21ba18c 100644 --- a/tests/engine/figma-api.test.ts +++ b/tests/engine/figma-api.test.ts @@ -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() + }) }) })