diff --git a/.husky/pre-commit b/.husky/pre-commit index 692f7c8..b1a974e 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ -#!/usr/bin/env sh - -bun run lint -bun run test +#!/usr/bin/env sh + +bun run lint +bun run test diff --git a/package.json b/package.json index 947688c..2c74eff 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "rspack dev", "build": "rspack build", "watch": "rspack build --watch", - "test": "bun test --coverage", + "test": "tsc --noEmit && bun test --coverage", "lint": "biome check --error-on-warnings", "lint:fix": "biome check --fix", "prepare": "husky" diff --git a/src/__tests__/__snapshots__/code.test.ts.snap b/src/__tests__/__snapshots__/code.test.ts.snap index 3ea73d9..5830663 100644 --- a/src/__tests__/__snapshots__/code.test.ts.snap +++ b/src/__tests__/__snapshots__/code.test.ts.snap @@ -1,36 +1,55 @@ -// Bun Snapshot v1, https://bun.sh/docs/test/snapshots - -exports[`registerCodegen should register codegen 1`] = ` -[ - { - "code": -"export function Test() { - return - }" -, - "language": "TYPESCRIPT", - "title": "Test - Components", - }, - { - "code": -"echo 'export function Test() { - return - }' > Test.tsx" -, - "language": "BASH", - "title": "Test - Components CLI", - }, -] -`; - -exports[`registerCodegen should register codegen 2`] = ` -[ - { - "code": "", - "language": "TYPESCRIPT", - "title": "Main", - }, -] -`; - -exports[`registerCodegen should register codegen 3`] = `[]`; +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`registerCodegen should register codegen 1`] = ` +[ + { + "code": +"export function Test() { + return + }" +, + "language": "TYPESCRIPT", + "title": "Test - Components", + }, + { + "code": +"mkdir -p src/components + +echo 'import { Box } from \\'@devup-ui/react\\' + +export function Test() { + return + }' > src/components/Test.tsx" +, + "language": "BASH", + "title": "Test - Components CLI (Bash)", + }, + { + "code": +"New-Item -ItemType Directory -Force -Path src\\components | Out-Null + +@' +import { Box } from '@devup-ui/react' + +export function Test() { + return + } +'@ | Out-File -FilePath src\\components\\Test.tsx -Encoding UTF8" +, + "language": "BASH", + "title": "Test - Components CLI (PowerShell)", + }, +] +`; + +exports[`registerCodegen should register codegen 2`] = ` +[ + { + "code": "", + "language": "TYPESCRIPT", + "title": "Main", + }, +] +`; + +exports[`registerCodegen should register codegen 3`] = `[]`; diff --git a/src/__tests__/code-responsive.test.ts b/src/__tests__/code-responsive.test.ts index 36ed476..502979e 100644 --- a/src/__tests__/code-responsive.test.ts +++ b/src/__tests__/code-responsive.test.ts @@ -25,7 +25,7 @@ const originalGenerateResponsiveCode = describe('registerCodegen responsive error handling', () => { beforeEach(() => { - Codegen.prototype.run = runMock + Codegen.prototype.run = runMock as unknown as typeof Codegen.prototype.run Codegen.prototype.getComponentsCodes = getComponentsCodesMock Codegen.prototype.getCode = getCodeMock ResponsiveCodegen.prototype.generateResponsiveCode = @@ -48,9 +48,8 @@ describe('registerCodegen responsive error handling', () => { }) test('swallows responsive errors and still returns base code', async () => { - const handlerCalls: Parameters< - Parameters[0]['codegen']['on'] - >[1][] = [] + const handlerCalls: ((event: CodegenEvent) => Promise)[] = + [] const ctx = { editorType: 'dev', mode: 'codegen', diff --git a/src/__tests__/code.test.ts b/src/__tests__/code.test.ts index 07ad1a2..295421a 100644 --- a/src/__tests__/code.test.ts +++ b/src/__tests__/code.test.ts @@ -95,6 +95,7 @@ describe('registerCodegen', () => { node: { type: 'COMPONENT', name: 'Test', + visible: true, }, language: 'devup-ui', }, @@ -109,6 +110,7 @@ describe('registerCodegen', () => { node: { type: 'FRAME', name: 'Main', + visible: true, }, language: 'devup-ui', }, @@ -123,6 +125,7 @@ describe('registerCodegen', () => { node: { type: 'FRAME', name: 'Other', + visible: true, }, language: 'other', }, @@ -182,3 +185,32 @@ it('auto-runs on module load when figma is present', async () => { expect(codegenOn).toHaveBeenCalledWith('generate', expect.any(Function)) }) + +describe('extractImports', () => { + it('should extract keyframes import when code contains keyframes(', () => { + const result = codeModule.extractImports([ + [ + 'AnimatedBox', + '', + ], + ]) + expect(result).toContain('keyframes') + expect(result).toContain('Box') + }) + + it('should extract keyframes import when code contains keyframes`', () => { + const result = codeModule.extractImports([ + ['AnimatedBox', ''], + ]) + expect(result).toContain('keyframes') + expect(result).toContain('Box') + }) + + it('should not extract keyframes when not present', () => { + const result = codeModule.extractImports([ + ['SimpleBox', ''], + ]) + expect(result).not.toContain('keyframes') + expect(result).toContain('Box') + }) +}) diff --git a/src/code-impl.ts b/src/code-impl.ts index 91520ed..e98ba11 100644 --- a/src/code-impl.ts +++ b/src/code-impl.ts @@ -4,6 +4,80 @@ import { exportDevup, importDevup } from './commands/devup' import { exportAssets } from './commands/exportAssets' import { exportComponents } from './commands/exportComponents' +export function extractImports( + componentsCodes: ReadonlyArray, +): string[] { + const allCode = componentsCodes.map(([_, code]) => code).join('\n') + const imports = new Set() + + const devupComponents = [ + 'Center', + 'VStack', + 'Flex', + 'Grid', + 'Box', + 'Text', + 'Image', + ] + + for (const component of devupComponents) { + const regex = new RegExp(`<${component}[\\s/>]`, 'g') + if (regex.test(allCode)) { + imports.add(component) + } + } + + // keyframes 함수 체크 + if (/keyframes\s*\(|keyframes`/.test(allCode)) { + imports.add('keyframes') + } + + return Array.from(imports).sort() +} + +function generateBashCLI( + componentsCodes: ReadonlyArray, +): string { + const imports = extractImports(componentsCodes) + const importStatement = + imports.length > 0 + ? `import { ${imports.join(', ')} } from '@devup-ui/react'\n\n` + : '' + + const commands = [ + 'mkdir -p src/components', + '', + ...componentsCodes.map(([componentName, code]) => { + const fullCode = importStatement + code + const escapedCode = fullCode.replace(/'/g, "\\'") + return `echo '${escapedCode}' > src/components/${componentName}.tsx` + }), + ] + + return commands.join('\n') +} + +function generatePowerShellCLI( + componentsCodes: ReadonlyArray, +): string { + const imports = extractImports(componentsCodes) + const importStatement = + imports.length > 0 + ? `import { ${imports.join(', ')} } from '@devup-ui/react'\n\n` + : '' + + const commands = [ + 'New-Item -ItemType Directory -Force -Path src\\components | Out-Null', + '', + ...componentsCodes.map(([componentName, code]) => { + const fullCode = importStatement + code + return `@'\n${fullCode}\n'@ | Out-File -FilePath src\\components\\${componentName}.tsx -Encoding UTF8` + }), + ] + + return commands.join('\n') +} + export function registerCodegen(ctx: typeof figma) { if (ctx.editorType === 'dev' && ctx.mode === 'codegen') { ctx.codegen.on('generate', async ({ node, language }) => { @@ -15,7 +89,6 @@ export function registerCodegen(ctx: typeof figma) { const componentsCodes = codegen.getComponentsCodes() console.info(`[benchmark] devup-ui end ${Date.now() - time}ms`) - // 반응형 코드 생성 (부모가 Section인 경우) const parentSection = ResponsiveCodegen.hasParentSection(node) let responsiveResult: { title: string @@ -60,14 +133,14 @@ export function registerCodegen(ctx: typeof figma) { code: componentsCodes.map((code) => code[1]).join('\n\n'), }, { - title: `${node.name} - Components CLI`, + title: `${node.name} - Components CLI (Bash)`, + language: 'BASH', + code: generateBashCLI(componentsCodes), + }, + { + title: `${node.name} - Components CLI (PowerShell)`, language: 'BASH', - code: componentsCodes - .map( - ([componentName, code]) => - `echo '${code}' > ${componentName}.tsx`, - ) - .join('\n'), + code: generatePowerShellCLI(componentsCodes), }, ] as const) : []), diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts index 61856c1..aa716ae 100644 --- a/src/codegen/Codegen.ts +++ b/src/codegen/Codegen.ts @@ -3,12 +3,14 @@ import { getProps } from './props' import { getSelectorProps } from './props/selector' import { renderComponent, renderNode } from './render' import { renderText } from './render/text' +import type { ComponentTree, NodeTree } from './types' import { checkAssetNode } from './utils/check-asset-node' import { checkSameColor } from './utils/check-same-color' import { getDevupComponentByNode, getDevupComponentByProps, } from './utils/get-devup-component' +import { getPageNode } from './utils/get-page-node' import { buildCssUrl } from './utils/wrap-url' export class Codegen { @@ -18,6 +20,10 @@ export class Codegen { > = new Map() code: string = '' + // Tree representations + private tree: NodeTree | null = null + private componentTrees: Map = new Map() + constructor(private node: SceneNode) { if (node.type === 'COMPONENT' && node.parent?.type === 'COMPONENT_SET') { this.node = node.parent @@ -76,12 +82,12 @@ export class Codegen { const assetNode = checkAssetNode(node) if (assetNode) { const props = await getProps(node) - props.src = `/icons/${node.name}.${assetNode}` + props.src = `/${assetNode === 'svg' ? 'icons' : 'images'}/${node.name}.${assetNode}` if (assetNode === 'svg') { const maskColor = await checkSameColor(node) if (maskColor) { // support mask image icon - props.maskImage = buildCssUrl(props.src) + props.maskImage = buildCssUrl(props.src as string) props.maskRepeat = 'no-repeat' props.maskSize = 'contain' props.bg = maskColor @@ -117,6 +123,12 @@ export class Codegen { left: props.left, right: props.right, bottom: props.bottom, + w: + // if the node is a page root, set the width to 100% + (getPageNode(node as BaseNode & ChildrenMixin) as SceneNode) + ?.width === node.width + ? '100%' + : undefined, }, dep, [ret], @@ -150,4 +162,201 @@ export class Codegen { if (node === this.node) this.code = ret return ret } + + /** + * Build a NodeTree representation of the node hierarchy. + * This is the intermediate JSON representation that can be compared/merged. + */ + async buildTree(node: SceneNode = this.node): Promise { + // Handle asset nodes (images/SVGs) + const assetNode = checkAssetNode(node) + if (assetNode) { + const props = await getProps(node) + props.src = `/${assetNode === 'svg' ? 'icons' : 'images'}/${node.name}.${assetNode}` + if (assetNode === 'svg') { + const maskColor = await checkSameColor(node) + if (maskColor) { + props.maskImage = buildCssUrl(props.src as string) + props.maskRepeat = 'no-repeat' + props.maskSize = 'contain' + props.bg = maskColor + delete props.src + } + } + return { + component: 'src' in props ? 'Image' : 'Box', + props, + children: [], + nodeType: node.type, + nodeName: node.name, + } + } + + const props = await getProps(node) + + // Handle COMPONENT_SET or COMPONENT - add to componentTrees + if ( + (node.type === 'COMPONENT_SET' || node.type === 'COMPONENT') && + ((this.node.type === 'COMPONENT_SET' && + node === this.node.defaultVariant) || + this.node.type === 'COMPONENT') + ) { + await this.addComponentTree( + node.type === 'COMPONENT_SET' ? node.defaultVariant : node, + ) + } + + // Handle INSTANCE nodes - treat as component reference + if (node.type === 'INSTANCE') { + const mainComponent = await node.getMainComponentAsync() + if (mainComponent) await this.addComponentTree(mainComponent) + + const componentName = getComponentName(mainComponent || node) + + // Check if needs position wrapper + if (props.pos) { + return { + component: 'Box', + props: { + pos: props.pos, + top: props.top, + left: props.left, + right: props.right, + bottom: props.bottom, + transform: props.transform, + w: + (getPageNode(node as BaseNode & ChildrenMixin) as SceneNode) + ?.width === node.width + ? '100%' + : undefined, + }, + children: [ + { + component: componentName, + props: {}, + children: [], + nodeType: node.type, + nodeName: node.name, + isComponent: true, + }, + ], + nodeType: 'WRAPPER', + nodeName: `${node.name}_wrapper`, + } + } + + return { + component: componentName, + props: {}, + children: [], + nodeType: node.type, + nodeName: node.name, + isComponent: true, + } + } + + // Build children recursively + const children: NodeTree[] = [] + if ('children' in node) { + for (const child of node.children) { + if (child.type === 'INSTANCE') { + const mainComponent = await child.getMainComponentAsync() + if (mainComponent) await this.addComponentTree(mainComponent) + } + children.push(await this.buildTree(child)) + } + } + + // Handle TEXT nodes + let textChildren: string[] | undefined + if (node.type === 'TEXT') { + const { children: textContent, props: textProps } = await renderText(node) + textChildren = textContent + Object.assign(props, textProps) + } + + const component = getDevupComponentByNode(node, props) + + return { + component, + props, + children, + nodeType: node.type, + nodeName: node.name, + textChildren, + } + } + + /** + * Get the NodeTree representation of the node. + * Builds the tree if not already built. + */ + async getTree(): Promise { + if (!this.tree) { + this.tree = await this.buildTree(this.node) + } + return this.tree + } + + /** + * Get component trees (for COMPONENT_SET/COMPONENT nodes). + */ + getComponentTrees(): Map { + return this.componentTrees + } + + /** + * Add a component to componentTrees. + */ + private async addComponentTree(node: ComponentNode): Promise { + if (this.componentTrees.has(node)) return + + const childrenTrees: NodeTree[] = [] + if ('children' in node) { + for (const child of node.children) { + if (child.type === 'INSTANCE') { + const mainComponent = await child.getMainComponentAsync() + if (mainComponent) await this.addComponentTree(mainComponent) + } + childrenTrees.push(await this.buildTree(child)) + } + } + + const props = await getProps(node) + const selectorProps = await getSelectorProps(node) + const variants: Record = {} + + if (selectorProps) { + Object.assign(props, selectorProps.props) + Object.assign(variants, selectorProps.variants) + } + + this.componentTrees.set(node, { + name: getComponentName(node), + tree: { + component: getDevupComponentByProps(props), + props, + children: childrenTrees, + nodeType: node.type, + nodeName: node.name, + }, + variants, + }) + } + + /** + * Render a NodeTree to JSX string. + * Static method so it can be used independently. + */ + static renderTree(tree: NodeTree, depth: number = 0): string { + // Handle TEXT nodes with textChildren + if (tree.textChildren && tree.textChildren.length > 0) { + return renderNode(tree.component, tree.props, depth, tree.textChildren) + } + + const childrenCodes = tree.children.map((child) => + Codegen.renderTree(child, depth + 1), + ) + return renderNode(tree.component, tree.props, depth, childrenCodes) + } } diff --git a/src/codegen/__tests__/codegen.test.ts b/src/codegen/__tests__/codegen.test.ts index 955259a..f24f1aa 100644 --- a/src/codegen/__tests__/codegen.test.ts +++ b/src/codegen/__tests__/codegen.test.ts @@ -107,7 +107,27 @@ function createTextSegment(characters: string): StyledTextSegment { } as unknown as StyledTextSegment } +function addVisibleToAll(node: SceneNode, visited = new Set()) { + if (visited.has(node)) return + visited.add(node) + if (!('visible' in node)) { + ;(node as unknown as { visible: boolean }).visible = true + } + if ('children' in node) { + for (const child of node.children) { + addVisibleToAll(child, visited) + } + } + if ('parent' in node && node.parent) { + addVisibleToAll(node.parent as SceneNode, visited) + } + if ('defaultVariant' in node && node.defaultVariant) { + addVisibleToAll(node.defaultVariant as SceneNode, visited) + } +} + function addParent(parent: SceneNode) { + addVisibleToAll(parent) if ('children' in parent) { for (const child of parent.children) { ;(child as unknown as { parent: SceneNode }).parent = parent @@ -162,7 +182,7 @@ describe('Codegen', () => { }, ], } as unknown as RectangleNode, - expected: ``, + expected: ``, }, { title: 'renders objectFit cover for image asset', @@ -183,7 +203,7 @@ describe('Codegen', () => { }, ], } as unknown as RectangleNode, - expected: ``, + expected: ``, }, { title: 'omits objectFit when image scale mode is FILL', @@ -204,7 +224,7 @@ describe('Codegen', () => { }, ], } as unknown as RectangleNode, - expected: ``, + expected: ``, }, { title: 'renders svg asset with vector node', @@ -562,11 +582,13 @@ describe('Codegen', () => { type: 'RECTANGLE', name: 'AbsoluteChild', layoutPositioning: 'ABSOLUTE', + x: 0, + y: 0, width: 300, height: 200, constraints: { - horizontal: 'MAX', - vertical: 'MAX', + horizontal: 'MIN', + vertical: 'MIN', }, }, ], @@ -602,7 +624,7 @@ describe('Codegen', () => { ], } as unknown as FrameNode, expected: ` - + `, }, { @@ -633,12 +655,11 @@ describe('Codegen', () => { } as unknown as FrameNode, expected: ` `, }, @@ -669,7 +690,7 @@ describe('Codegen', () => { ], } as unknown as FrameNode, expected: ` - + `, }, { @@ -700,11 +721,11 @@ describe('Codegen', () => { } as unknown as FrameNode, expected: ` `, }, @@ -1215,7 +1236,7 @@ describe('Codegen', () => { name: 'Section 1', }, } as unknown as FrameNode, - expected: ``, + expected: ``, }, { title: 'renders frame with vertical center align child width shrinker', @@ -1493,13 +1514,7 @@ describe('Codegen', () => { primaryAxisAlignItems: 'SPACE_BETWEEN', counterAxisAlignItems: 'CENTER', } as unknown as FrameNode, - expected: ` + expected: ` `, @@ -1611,7 +1626,7 @@ describe('Codegen', () => { }, ], } as unknown as FrameNode, - expected: ``, + expected: ``, }, { title: 'renders noise effect props', @@ -1674,7 +1689,7 @@ describe('Codegen', () => { }, ], } as unknown as FrameNode, - expected: ``, + expected: ``, }, { title: 'renders text node with content', @@ -2115,7 +2130,7 @@ describe('Codegen', () => { height: 50, rotation: 45, } as unknown as FrameNode, - expected: ``, + expected: ``, }, { title: 'renders frame with negative rotation transform', @@ -2129,7 +2144,8 @@ describe('Codegen', () => { height: 40, rotation: -30, } as unknown as FrameNode, - expected: ``, + // revsered rotation + expected: ``, }, { title: 'renders frame with decimal rotation transform', @@ -2143,7 +2159,7 @@ describe('Codegen', () => { height: 60, rotation: 15.5, } as unknown as FrameNode, - expected: ``, + expected: ``, }, { title: 'renders frame with opacity less than 1', @@ -3021,18 +3037,18 @@ describe('Codegen', () => { } as unknown as ComponentSetNode })(), expected: ` - - + + `, expectedComponents: [ [ 'Button', `export interface ButtonProps { - state: default | hover + state: 'default' | 'hover' } export function Button() { - return + return }`, ], ], @@ -3068,14 +3084,14 @@ export function Button() { } as unknown as ComponentSetNode })(), expected: ` - - + + `, expectedComponents: [ [ 'Button', `export function Button() { - return + return }`, ], ], @@ -3134,14 +3150,14 @@ export function Button() { } as unknown as ComponentSetNode })(), expected: ` - - + + `, expectedComponents: [ [ 'Button', `export function Button() { - return + return }`, ], ], @@ -3201,8 +3217,8 @@ export function Button() { } as unknown as ComponentSetNode })(), expected: ` - - + + `, expectedComponents: [ [ @@ -3213,7 +3229,7 @@ export function Button() { _hover={{ "opacity": "0.8" }} - boxSize="100%" + h="100%" transition="0.3ms ease-in-out" transitionProperty="opacity" /> @@ -3311,14 +3327,14 @@ export function Button() { } as unknown as ComponentSetNode })(), expected: ` - - + + `, expectedComponents: [ [ 'Button', `export function Button() { - return + return }`, ], ], @@ -3363,4 +3379,808 @@ export function Button() { expect(codegen.getCode()).toBe(expected) expect(componentsCodes).toEqual(expectedComponents) }) + + test('renders instance with page root width and sets width to 100%', async () => { + const mainComponent = { + type: 'COMPONENT', + name: 'TestComponent', + children: [], + getMainComponentAsync: async () => null, + } as unknown as ComponentNode + + const pageNode = { + type: 'PAGE', + } as unknown as PageNode + + const pageRootNode = { + type: 'FRAME', + name: 'PageRoot', + parent: pageNode, + width: 1440, + height: 900, + } as unknown as FrameNode + + const instanceNode = { + type: 'INSTANCE', + name: 'TestInstance', + parent: pageRootNode, + width: 1440, + height: 100, + x: 100, + y: 50, + getMainComponentAsync: async () => mainComponent, + layoutPositioning: 'ABSOLUTE', + constraints: { + horizontal: 'MIN', + vertical: 'MIN', + }, + } as unknown as InstanceNode + + const codegen = new Codegen(instanceNode) + await codegen.run() + const code = codegen.getCode() + + expect(code).toContain('w="100%"') + }) + + test('renders instance without page root width match and does not set width to 100%', async () => { + const mainComponent = { + type: 'COMPONENT', + name: 'TestComponent', + children: [], + getMainComponentAsync: async () => null, + } as unknown as ComponentNode + + const pageNode = { + type: 'PAGE', + } as unknown as PageNode + + const pageRootNode = { + type: 'FRAME', + name: 'PageRoot', + parent: pageNode, + width: 1440, + height: 900, + } as unknown as FrameNode + + const instanceNode = { + type: 'INSTANCE', + name: 'TestInstance', + parent: pageRootNode, + width: 800, + height: 100, + x: 100, + y: 50, + getMainComponentAsync: async () => mainComponent, + layoutPositioning: 'ABSOLUTE', + constraints: { + horizontal: 'MIN', + vertical: 'MIN', + }, + } as unknown as InstanceNode + + const codegen = new Codegen(instanceNode) + await codegen.run() + const code = codegen.getCode() + + expect(code).not.toContain('w="100%"') + }) +}) + +describe('Codegen Tree Methods', () => { + describe('buildTree', () => { + test('builds tree for simple frame', async () => { + const node = { + type: 'FRAME', + name: 'SimpleFrame', + children: [], + visible: true, + } as unknown as FrameNode + addParent(node) + + const codegen = new Codegen(node) + const tree = await codegen.buildTree() + + expect(tree.component).toBe('Box') + expect(tree.nodeType).toBe('FRAME') + expect(tree.nodeName).toBe('SimpleFrame') + expect(tree.children).toEqual([]) + }) + + test('builds tree for asset node (image)', async () => { + const node = { + type: 'RECTANGLE', + name: 'TestImage', + isAsset: true, + children: [], + visible: true, + fills: [ + { + type: 'IMAGE', + visible: true, + }, + ], + } as unknown as RectangleNode + addParent(node) + + const codegen = new Codegen(node) + const tree = await codegen.buildTree() + + expect(tree.component).toBe('Image') + expect(tree.props.src).toBe('/images/TestImage.png') + expect(tree.nodeType).toBe('RECTANGLE') + }) + + test('builds tree for SVG asset with mask color', async () => { + const node = { + type: 'VECTOR', + name: 'TestIcon', + isAsset: true, + children: [], + visible: true, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 1, g: 0, b: 0 }, + opacity: 1, + }, + ], + } as unknown as VectorNode + addParent(node) + + const codegen = new Codegen(node) + const tree = await codegen.buildTree() + + expect(tree.component).toBe('Box') + expect(tree.props.maskImage).toBe('url(/icons/TestIcon.svg)') + expect(tree.props.maskRepeat).toBe('no-repeat') + expect(tree.props.maskSize).toBe('contain') + expect(tree.props.bg).toBe('#F00') + expect(tree.props.src).toBeUndefined() + }) + + test('builds tree for SVG asset without same color (returns Image)', async () => { + const node = { + type: 'FRAME', + name: 'MultiColorIcon', + isAsset: true, + children: [ + { + type: 'VECTOR', + name: 'Part1', + visible: true, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 1, g: 0, b: 0 }, + opacity: 1, + }, + ], + }, + { + type: 'VECTOR', + name: 'Part2', + visible: true, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 0, g: 1, b: 0 }, + opacity: 1, + }, + ], + }, + ], + visible: true, + fills: [], + } as unknown as FrameNode + addParent(node) + + const codegen = new Codegen(node) + const tree = await codegen.buildTree() + + expect(tree.component).toBe('Image') + expect(tree.props.src).toBe('/icons/MultiColorIcon.svg') + }) + + test('builds tree for frame with children', async () => { + const child1 = { + type: 'FRAME', + name: 'Child1', + children: [], + visible: true, + } as unknown as FrameNode + + const child2 = { + type: 'FRAME', + name: 'Child2', + children: [], + visible: true, + } as unknown as FrameNode + + const node = { + type: 'FRAME', + name: 'ParentFrame', + children: [child1, child2], + visible: true, + inferredAutoLayout: { + layoutMode: 'HORIZONTAL', + itemSpacing: 8, + }, + primaryAxisAlignItems: 'MIN', + counterAxisAlignItems: 'MIN', + } as unknown as FrameNode + addParent(node) + + const codegen = new Codegen(node) + const tree = await codegen.buildTree() + + expect(tree.component).toBe('Flex') + expect(tree.children.length).toBe(2) + expect(tree.children[0].nodeName).toBe('Child1') + expect(tree.children[1].nodeName).toBe('Child2') + }) + + test('builds tree for TEXT node', async () => { + const node = { + type: 'TEXT', + name: 'TextNode', + characters: 'Hello World', + visible: true, + textAutoResize: 'WIDTH_AND_HEIGHT', + textAlignHorizontal: 'LEFT', + textAlignVertical: 'TOP', + strokes: [], + effects: [], + getStyledTextSegments: () => [createTextSegment('Hello World')], + } as unknown as TextNode + addParent(node) + + const codegen = new Codegen(node) + const tree = await codegen.buildTree() + + expect(tree.component).toBe('Text') + expect(tree.nodeType).toBe('TEXT') + expect(tree.textChildren).toBeDefined() + }) + + test('builds tree for INSTANCE node without position wrapper', async () => { + const mainComponent = { + type: 'COMPONENT', + name: 'MainComponent', + children: [], + visible: true, + } as unknown as ComponentNode + addParent(mainComponent) + + const instanceNode = { + type: 'INSTANCE', + name: 'InstanceNode', + visible: true, + getMainComponentAsync: async () => mainComponent, + } as unknown as InstanceNode + addParent(instanceNode) + + const codegen = new Codegen(instanceNode) + const tree = await codegen.buildTree() + + expect(tree.component).toBe('MainComponent') + expect(tree.isComponent).toBe(true) + expect(tree.props).toEqual({}) + }) + + test('builds tree for INSTANCE node with position wrapper (absolute)', async () => { + const mainComponent = { + type: 'COMPONENT', + name: 'AbsoluteComponent', + children: [], + visible: true, + } as unknown as ComponentNode + addParent(mainComponent) + + const parent = { + type: 'FRAME', + name: 'Parent', + children: [], + visible: true, + width: 500, + } as unknown as FrameNode + + const instanceNode = { + type: 'INSTANCE', + name: 'AbsoluteInstance', + visible: true, + width: 100, + height: 50, + x: 10, + y: 20, + layoutPositioning: 'ABSOLUTE', + constraints: { + horizontal: 'MIN', + vertical: 'MIN', + }, + getMainComponentAsync: async () => mainComponent, + parent, + } as unknown as InstanceNode + + ;(parent as unknown as { children: SceneNode[] }).children = [ + instanceNode, + ] + addParent(parent) + + const codegen = new Codegen(instanceNode) + const tree = await codegen.buildTree() + + expect(tree.component).toBe('Box') + expect(tree.props.pos).toBe('absolute') + expect(tree.children.length).toBe(1) + expect(tree.children[0].component).toBe('AbsoluteComponent') + expect(tree.children[0].isComponent).toBe(true) + }) + + test('builds tree for INSTANCE with position and 100% width', async () => { + const mainComponent = { + type: 'COMPONENT', + name: 'FullWidthComponent', + children: [], + visible: true, + } as unknown as ComponentNode + addParent(mainComponent) + + const page = { + type: 'PAGE', + name: 'Page', + width: 200, + parent: null, + } as unknown as PageNode + + const parent = { + type: 'FRAME', + name: 'PageRoot', + children: [], + visible: true, + width: 200, + parent: page, + } as unknown as FrameNode + + const instanceNode = { + type: 'INSTANCE', + name: 'FullWidthInstance', + visible: true, + width: 200, + height: 50, + x: 0, + y: 0, + layoutPositioning: 'ABSOLUTE', + constraints: { + horizontal: 'MIN', + vertical: 'MIN', + }, + getMainComponentAsync: async () => mainComponent, + parent, + } as unknown as InstanceNode + + ;(parent as unknown as { children: SceneNode[] }).children = [ + instanceNode, + ] + addParent(parent) + + const codegen = new Codegen(instanceNode) + const tree = await codegen.buildTree() + + expect(tree.component).toBe('Box') + expect(tree.props.w).toBe('100%') + }) + + test('builds tree for COMPONENT_SET node', async () => { + const defaultVariant = { + type: 'COMPONENT', + name: 'Default', + children: [], + visible: true, + reactions: [], + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'ButtonSet', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: {}, + } as unknown as ComponentSetNode + addParent(node) + + const codegen = new Codegen(node) + await codegen.buildTree() + + const componentTrees = codegen.getComponentTrees() + expect(componentTrees.size).toBeGreaterThan(0) + }) + + test('builds tree for COMPONENT node directly', async () => { + const node = { + type: 'COMPONENT', + name: 'DirectComponent', + children: [], + visible: true, + } as unknown as ComponentNode + addParent(node) + + const codegen = new Codegen(node) + await codegen.buildTree() + + const componentTrees = codegen.getComponentTrees() + expect(componentTrees.size).toBeGreaterThan(0) + }) + + test('builds tree with nested INSTANCE children', async () => { + const mainComponent = { + type: 'COMPONENT', + name: 'NestedComp', + children: [], + visible: true, + } as unknown as ComponentNode + addParent(mainComponent) + + const instanceChild = { + type: 'INSTANCE', + name: 'NestedInstance', + visible: true, + getMainComponentAsync: async () => mainComponent, + } as unknown as InstanceNode + + const parent = { + type: 'FRAME', + name: 'ParentWithInstance', + children: [instanceChild], + visible: true, + } as unknown as FrameNode + addParent(parent) + + const codegen = new Codegen(parent) + const tree = await codegen.buildTree() + + expect(tree.children.length).toBe(1) + expect(tree.children[0].isComponent).toBe(true) + }) + }) + + describe('getTree', () => { + test('builds and caches tree on first call', async () => { + const node = { + type: 'FRAME', + name: 'CachedFrame', + children: [], + visible: true, + } as unknown as FrameNode + addParent(node) + + const codegen = new Codegen(node) + const tree1 = await codegen.getTree() + const tree2 = await codegen.getTree() + + expect(tree1).toBe(tree2) // Same reference (cached) + expect(tree1.nodeName).toBe('CachedFrame') + }) + }) + + describe('getComponentTrees', () => { + test('returns empty map when no components', async () => { + const node = { + type: 'FRAME', + name: 'NoComponents', + children: [], + visible: true, + } as unknown as FrameNode + addParent(node) + + const codegen = new Codegen(node) + await codegen.buildTree() + + const componentTrees = codegen.getComponentTrees() + expect(componentTrees.size).toBe(0) + }) + + test('returns component trees after building', async () => { + const componentChild = { + type: 'COMPONENT', + name: 'ChildComp', + children: [], + visible: true, + } as unknown as ComponentNode + + const defaultVariant = { + type: 'COMPONENT', + name: 'Default', + children: [componentChild], + visible: true, + reactions: [], + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'CompSet', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: {}, + } as unknown as ComponentSetNode + addParent(node) + + const codegen = new Codegen(node) + await codegen.buildTree() + + const componentTrees = codegen.getComponentTrees() + expect(componentTrees.size).toBeGreaterThan(0) + }) + }) + + describe('addComponentTree (via buildTree)', () => { + test('adds component with selector props', async () => { + const defaultVariant = { + type: 'COMPONENT', + name: 'State=Default', + children: [], + visible: true, + reactions: [], + } as unknown as ComponentNode + + const hoverVariant = { + type: 'COMPONENT', + name: 'State=Hover', + children: [], + visible: true, + reactions: [], + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 0, g: 0.5, b: 1 }, + opacity: 1, + }, + ], + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'ButtonWithHover', + children: [defaultVariant, hoverVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: {}, + } as unknown as ComponentSetNode + addParent(node) + + const codegen = new Codegen(node) + await codegen.buildTree() + + const componentTrees = codegen.getComponentTrees() + expect(componentTrees.size).toBeGreaterThan(0) + }) + + test('does not duplicate component trees', async () => { + const mainComponent = { + type: 'COMPONENT', + name: 'SharedComp', + children: [], + visible: true, + } as unknown as ComponentNode + addParent(mainComponent) + + const instance1 = { + type: 'INSTANCE', + name: 'Instance1', + visible: true, + getMainComponentAsync: async () => mainComponent, + } as unknown as InstanceNode + + const instance2 = { + type: 'INSTANCE', + name: 'Instance2', + visible: true, + getMainComponentAsync: async () => mainComponent, + } as unknown as InstanceNode + + const parent = { + type: 'FRAME', + name: 'ParentWithDuplicates', + children: [instance1, instance2], + visible: true, + } as unknown as FrameNode + addParent(parent) + + const codegen = new Codegen(parent) + await codegen.buildTree() + + const componentTrees = codegen.getComponentTrees() + // Should only have 1 entry for SharedComp, not duplicates + expect(componentTrees.size).toBe(1) + }) + + test('handles component with INSTANCE children', async () => { + const nestedComponent = { + type: 'COMPONENT', + name: 'NestedComp', + children: [], + visible: true, + } as unknown as ComponentNode + addParent(nestedComponent) + + const nestedInstance = { + type: 'INSTANCE', + name: 'NestedInstance', + visible: true, + getMainComponentAsync: async () => nestedComponent, + } as unknown as InstanceNode + + const mainComponent = { + type: 'COMPONENT', + name: 'ParentComp', + children: [nestedInstance], + visible: true, + reactions: [], + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'CompSetWithNestedInstance', + children: [mainComponent], + defaultVariant: mainComponent, + visible: true, + componentPropertyDefinitions: {}, + } as unknown as ComponentSetNode + addParent(node) + + const codegen = new Codegen(node) + await codegen.buildTree() + + const componentTrees = codegen.getComponentTrees() + expect(componentTrees.size).toBe(2) // ParentComp and NestedComp + }) + }) + + describe('renderTree (static)', () => { + test('renders simple tree to JSX', () => { + const tree = { + component: 'Box', + props: { w: '100px', h: '50px' }, + children: [], + nodeType: 'FRAME', + nodeName: 'SimpleBox', + } + + const result = Codegen.renderTree(tree) + expect(result).toContain(' { + const tree = { + component: 'Flex', + props: { direction: 'column' }, + children: [ + { + component: 'Box', + props: { w: '100px' }, + children: [], + nodeType: 'FRAME', + nodeName: 'Child1', + }, + { + component: 'Box', + props: { h: '50px' }, + children: [], + nodeType: 'FRAME', + nodeName: 'Child2', + }, + ], + nodeType: 'FRAME', + nodeName: 'Parent', + } + + const result = Codegen.renderTree(tree) + expect(result).toContain(' { + const tree = { + component: 'Text', + props: { fontSize: '16px' }, + children: [], + nodeType: 'TEXT', + nodeName: 'TextNode', + textChildren: ['Hello', ' ', 'World'], + } + + const result = Codegen.renderTree(tree) + expect(result).toContain(' { + const tree = { + component: 'Flex', + props: {}, + children: [ + { + component: 'Flex', + props: {}, + children: [ + { + component: 'Box', + props: {}, + children: [], + nodeType: 'FRAME', + nodeName: 'DeepChild', + }, + ], + nodeType: 'FRAME', + nodeName: 'MiddleChild', + }, + ], + nodeType: 'FRAME', + nodeName: 'Root', + } + + const result = Codegen.renderTree(tree, 0) + expect(result).toContain(' { + const tree = { + component: 'MyButton', + props: {}, + children: [], + nodeType: 'INSTANCE', + nodeName: 'ButtonInstance', + isComponent: true, + } + + const result = Codegen.renderTree(tree) + expect(result).toContain(' { + test('sanitizes property name that is only digits', async () => { + const defaultVariant = { + type: 'COMPONENT', + name: '123=Default', + children: [], + visible: true, + reactions: [], + variantProperties: { '123': 'Default' }, + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'NumericPropertySet', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + '123': { + type: 'VARIANT', + variantOptions: ['Default', 'Active'], + }, + }, + } as unknown as ComponentSetNode + addParent(node) + + const codegen = new Codegen(node) + await codegen.buildTree() + + // The numeric property name should be sanitized to 'variant' + const componentTrees = codegen.getComponentTrees() + expect(componentTrees.size).toBeGreaterThan(0) + }) + }) }) diff --git a/src/codegen/props/__tests__/cursor.test.ts b/src/codegen/props/__tests__/cursor.test.ts new file mode 100644 index 0000000..fc660ea --- /dev/null +++ b/src/codegen/props/__tests__/cursor.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'bun:test' +import { getCursorProps } from '../cursor' + +describe('getCursorProps', () => { + test('returns empty object when node has no reactions property', () => { + const node = { + type: 'FRAME', + name: 'Test', + } as SceneNode + + expect(getCursorProps(node)).toEqual({}) + }) + + test('returns empty object when node.reactions is null', () => { + const node = { + type: 'FRAME', + name: 'Test', + reactions: null, + } as unknown as SceneNode + + expect(getCursorProps(node)).toEqual({}) + }) + + test('returns empty object when node.reactions is empty array', () => { + const node = { + type: 'FRAME', + name: 'Test', + reactions: [], + } as unknown as SceneNode + + expect(getCursorProps(node)).toEqual({}) + }) + + test('returns cursor: pointer when reaction has ON_CLICK trigger', () => { + const node = { + type: 'FRAME', + name: 'Test', + reactions: [ + { + trigger: { type: 'ON_CLICK' }, + actions: [{ type: 'NODE', destinationId: '123' }], + }, + ], + } as unknown as SceneNode + + expect(getCursorProps(node)).toEqual({ cursor: 'pointer' }) + }) + + test('returns empty object when reaction has non-click trigger', () => { + const node = { + type: 'FRAME', + name: 'Test', + reactions: [ + { + trigger: { type: 'ON_HOVER' }, + actions: [{ type: 'NODE', destinationId: '123' }], + }, + ], + } as unknown as SceneNode + + expect(getCursorProps(node)).toEqual({}) + }) + + test('returns cursor: pointer when any reaction has ON_CLICK trigger among multiple', () => { + const node = { + type: 'FRAME', + name: 'Test', + reactions: [ + { + trigger: { type: 'ON_HOVER' }, + actions: [{ type: 'NODE', destinationId: '123' }], + }, + { + trigger: { type: 'ON_CLICK' }, + actions: [{ type: 'NODE', destinationId: '456' }], + }, + { + trigger: { type: 'AFTER_TIMEOUT' }, + actions: [{ type: 'NODE', destinationId: '789' }], + }, + ], + } as unknown as SceneNode + + expect(getCursorProps(node)).toEqual({ cursor: 'pointer' }) + }) + + test('returns empty object when reaction trigger is undefined', () => { + const node = { + type: 'FRAME', + name: 'Test', + reactions: [ + { + trigger: undefined, + actions: [{ type: 'NODE', destinationId: '123' }], + }, + ], + } as unknown as SceneNode + + expect(getCursorProps(node)).toEqual({}) + }) +}) diff --git a/src/codegen/props/__tests__/position.test.ts b/src/codegen/props/__tests__/position.test.ts new file mode 100644 index 0000000..75aa299 --- /dev/null +++ b/src/codegen/props/__tests__/position.test.ts @@ -0,0 +1,435 @@ +import { describe, expect, it, vi } from 'vitest' +import { canBeAbsolute, getPositionProps } from '../position' + +vi.mock('../../utils/check-asset-node', () => ({ + checkAssetNode: () => null, +})) + +vi.mock('../../utils/is-page-root', () => ({ + isPageRoot: () => false, +})) + +describe('position', () => { + describe('canBeAbsolute', () => { + it('should return true for ABSOLUTE positioned node', () => { + const node = { + layoutPositioning: 'ABSOLUTE', + parent: { + type: 'FRAME', + }, + } as any + + expect(canBeAbsolute(node)).toBe(true) + }) + + it('should return true for AUTO positioned node in freelayout parent with constraints', () => { + const node = { + layoutPositioning: 'AUTO', + constraints: { + horizontal: 'MIN', + vertical: 'MIN', + }, + parent: { + layoutPositioning: 'AUTO', + width: 100, + height: 100, + type: 'FRAME', + }, + } as any + + expect(canBeAbsolute(node)).toBe(true) + }) + + it('should return false for AUTO positioned node in freelayout parent without constraints', () => { + const node = { + layoutPositioning: 'AUTO', + parent: { + layoutPositioning: 'AUTO', + width: 100, + height: 100, + type: 'FRAME', + }, + } as any + + expect(canBeAbsolute(node)).toBe(false) + }) + + it('should return false for node without parent', () => { + const node = { + layoutPositioning: 'ABSOLUTE', + } as any + + expect(canBeAbsolute(node)).toBe(false) + }) + }) + + describe('getPositionProps', () => { + it('should return absolute position props with constraints', () => { + const node = { + layoutPositioning: 'ABSOLUTE', + x: 10, + y: 20, + width: 50, + height: 60, + constraints: { + horizontal: 'MIN', + vertical: 'MIN', + }, + parent: { + type: 'FRAME', + width: 200, + height: 300, + }, + } as any + + const result = getPositionProps(node) + + expect(result).toEqual({ + pos: 'absolute', + left: '10px', + right: undefined, + top: '20px', + bottom: undefined, + }) + }) + + it('should return absolute position props in freelayout using fallback x/y', () => { + const node = { + layoutPositioning: 'AUTO', + x: 15, + y: 25, + width: 40, + height: 50, + constraints: { + horizontal: 'MIN', + vertical: 'MIN', + }, + parent: { + layoutPositioning: 'AUTO', + type: 'FRAME', + width: 200, + height: 300, + }, + } as any + + const result = getPositionProps(node) + + expect(result).toEqual({ + pos: 'absolute', + left: '15px', + right: undefined, + top: '25px', + bottom: undefined, + }) + }) + + it('should return undefined when no constraints in freelayout and canBeAbsolute is false', () => { + const node = { + layoutPositioning: 'AUTO', + x: 15, + y: 25, + width: 40, + height: 50, + parent: { + layoutPositioning: 'AUTO', + type: 'FRAME', + width: 200, + height: 300, + }, + } as any + + const result = getPositionProps(node) + + expect(result).toBeUndefined() + }) + + it('should return absolute position with x/y when ABSOLUTE node has no constraints in freelayout parent', () => { + const node = { + layoutPositioning: 'ABSOLUTE', + x: 15, + y: 25, + width: 40, + height: 50, + parent: { + layoutPositioning: 'AUTO', + type: 'FRAME', + width: 200, + height: 300, + }, + } as any + + const result = getPositionProps(node) + + expect(result).toEqual({ + pos: 'absolute', + left: '15px', + top: '25px', + }) + }) + + it('should return undefined when no constraints and parent is not freelayout (line 43)', () => { + const node = { + layoutPositioning: 'ABSOLUTE', + x: 10, + y: 20, + width: 50, + height: 60, + parent: { + inferredAutoLayout: { + layoutMode: 'HORIZONTAL', + }, + type: 'FRAME', + width: 200, + height: 300, + }, + } as any + + const result = getPositionProps(node) + + expect(result).toBeUndefined() + }) + + it('should return relative position for parent with absolute children', () => { + const node = { + type: 'FRAME', + children: [ + { + layoutPositioning: 'ABSOLUTE', + }, + ], + } as any + const result = getPositionProps(node) + + expect(result).toEqual({ + pos: 'relative', + }) + }) + + it('should return relative position for freelayout parent with AUTO children', () => { + const node = { + type: 'FRAME', + layoutPositioning: 'AUTO', + children: [ + { + layoutPositioning: 'AUTO', + }, + ], + } as any + + const result = getPositionProps(node) + + expect(result).toEqual({ + pos: 'relative', + }) + }) + + it('should handle MAX constraints', () => { + const node = { + layoutPositioning: 'ABSOLUTE', + x: 10, + y: 20, + width: 50, + height: 60, + constraints: { + horizontal: 'MAX', + vertical: 'MAX', + }, + parent: { + type: 'FRAME', + width: 200, + height: 300, + }, + } as any + + const result = getPositionProps(node) + + expect(result).toEqual({ + pos: 'absolute', + left: undefined, + right: '140px', // 200 - 10 - 50 + top: undefined, + bottom: '220px', // 300 - 20 - 60 + }) + }) + + it('should handle CENTER constraints (default case)', () => { + const node = { + layoutPositioning: 'ABSOLUTE', + x: 10, + y: 20, + width: 50, + height: 60, + constraints: { + horizontal: 'CENTER', + vertical: 'CENTER', + }, + parent: { + type: 'FRAME', + width: 200, + height: 300, + }, + } as any + + const result = getPositionProps(node) + + expect(result).toEqual({ + pos: 'absolute', + left: '50%', + right: undefined, + top: '50%', + bottom: undefined, + transform: 'translate(-50%, -50%)', + }) + }) + + it('should get constraints from children[0] when node has no constraints', () => { + const node = { + layoutPositioning: 'ABSOLUTE', + x: 5, + y: 10, + width: 30, + height: 40, + children: [ + { + constraints: { + horizontal: 'MIN', + vertical: 'MIN', + }, + }, + ], + parent: { + type: 'FRAME', + width: 100, + height: 150, + }, + } as any + + const result = getPositionProps(node) + + expect(result).toEqual({ + pos: 'absolute', + left: '5px', + right: undefined, + top: '10px', + bottom: undefined, + }) + }) + + it('should return undefined when children[0] exists but has no constraints and parent is not freelayout', () => { + const node = { + layoutPositioning: 'ABSOLUTE', + x: 5, + y: 10, + width: 30, + height: 40, + children: [ + { + // no constraints + }, + ], + parent: { + inferredAutoLayout: { + layoutMode: 'VERTICAL', + }, + type: 'FRAME', + width: 100, + height: 150, + }, + } as any + + const result = getPositionProps(node) + + expect(result).toBeUndefined() + }) + + it('should handle SCALE horizontal constraint (default case)', () => { + const node = { + layoutPositioning: 'ABSOLUTE', + x: 10, + y: 20, + width: 50, + height: 60, + constraints: { + horizontal: 'SCALE', + vertical: 'MIN', + }, + parent: { + type: 'FRAME', + width: 200, + height: 300, + }, + } as any + + const result = getPositionProps(node) + + expect(result).toEqual({ + pos: 'absolute', + left: '0px', + right: '0px', + top: '20px', + bottom: undefined, + transform: undefined, + }) + }) + + it('should handle STRETCH vertical constraint (default case)', () => { + const node = { + layoutPositioning: 'ABSOLUTE', + x: 10, + y: 20, + width: 50, + height: 60, + constraints: { + horizontal: 'MIN', + vertical: 'STRETCH', + }, + parent: { + type: 'FRAME', + width: 200, + height: 300, + }, + } as any + + const result = getPositionProps(node) + + expect(result).toEqual({ + pos: 'absolute', + left: '10px', + right: undefined, + top: '0px', + bottom: '0px', + transform: undefined, + }) + }) + + it('should handle both SCALE constraints (default case for both)', () => { + const node = { + layoutPositioning: 'ABSOLUTE', + x: 10, + y: 20, + width: 50, + height: 60, + constraints: { + horizontal: 'SCALE', + vertical: 'SCALE', + }, + parent: { + type: 'FRAME', + width: 200, + height: 300, + }, + } as any + + const result = getPositionProps(node) + + expect(result).toEqual({ + pos: 'absolute', + left: '0px', + right: '0px', + top: '0px', + bottom: '0px', + transform: undefined, + }) + }) + }) +}) diff --git a/src/codegen/props/__tests__/reaction.test.ts b/src/codegen/props/__tests__/reaction.test.ts index 3837cac..55fa12f 100644 --- a/src/codegen/props/__tests__/reaction.test.ts +++ b/src/codegen/props/__tests__/reaction.test.ts @@ -3,8 +3,15 @@ import { getReactionProps } from '../reaction' // Mock figma global const mockGetNodeByIdAsync = vi.fn() +const mockGetVariableByIdAsync = vi.fn() ;(global as any).figma = { getNodeByIdAsync: mockGetNodeByIdAsync, + util: { + rgba: (color: any) => color, + }, + variables: { + getVariableByIdAsync: mockGetVariableByIdAsync, + }, } describe('getReactionProps', () => { @@ -232,7 +239,7 @@ describe('getReactionProps', () => { expect(result.animationName).toBeDefined() expect(result.animationName).toContain('bg') - expect(result.animationName).toContain('rgb(0, 255, 0)') + expect(result.animationName).toContain('#0F0') // hex format instead of rgb }) it('should return empty object when no changes detected', async () => { @@ -455,9 +462,11 @@ describe('getReactionProps', () => { const result = await getReactionProps(node1) - // Should stop at node2 and not loop back to node1 + // Should stop at node2 and not loop back to node1 (prevents infinite recursion) + // But it's detected as a loop, so duration includes return-to-initial step expect(result.animationName).toBeDefined() - expect(result.animationDuration).toBe('0.5s') // Only one transition + expect(result.animationDuration).toBe('1s') // 0.5s + 0.5s (return to initial) + expect(result.animationIterationCount).toBe('infinite') }) it('should prevent infinite loops with self-referencing nodes', async () => { @@ -611,7 +620,7 @@ describe('getReactionProps', () => { expect(buttonAnimation).toContain('100%') expect(buttonAnimation).toContain('translate(100px, 0px)') // Position change expect(buttonAnimation).toContain('0.5') // Opacity change - expect(buttonAnimation).toContain('rgb(0, 255, 0)') // Color change + expect(buttonAnimation).toContain('#0F0') // Color change (hex format) // Text child should get its animation from cache const textResult = await getReactionProps(textChild) @@ -623,7 +632,7 @@ describe('getReactionProps', () => { expect(textAnimation).toContain('0.8') // Opacity change }) - it('should detect loop animations and add infinite iteration count', async () => { + it('should detect loop animations and add infinite iteration count with 100% returning to initial', async () => { const node1 = { id: 'node1', type: 'FRAME', @@ -680,8 +689,1312 @@ describe('getReactionProps', () => { const result = await getReactionProps(node1) + expect(result.animationName).toBeDefined() + // Duration should include the return-to-initial step: 0.5s + 0.5s = 1s + expect(result.animationDuration).toBe('1s') + expect(result.animationIterationCount).toBe('infinite') + + // Check that keyframes include 100% returning to initial state + const animationName = result.animationName as string + expect(animationName).toContain('100%') + // The 50% keyframe should contain the changes, and 100% should return to initial + expect(animationName).toContain('50%') + }) + + it('should return cached child animation from parent cache', async () => { + const buttonChild = { + id: 'child1', + name: 'Button', + type: 'FRAME', + x: 0, + y: 0, + parent: { id: 'parent' }, + } as any + + const parentNode = { + id: 'parent', + type: 'FRAME', + children: [buttonChild], + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'dest', + transition: { + type: 'SMART_ANIMATE', + duration: 0.3, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0.01, + }, + }, + ], + } as any + + const destNode = { + id: 'dest', + type: 'FRAME', + children: [ + { + id: 'child1-new', + name: 'Button', + type: 'FRAME', + x: 100, + y: 0, + }, + ], + } as any + + mockGetNodeByIdAsync.mockResolvedValue(destNode) + + // First call to parent should populate cache + await getReactionProps(parentNode) + + // Second call to child should use cached value + const childResult = await getReactionProps(buttonChild) + + expect(childResult.animationName).toBeDefined() + expect(childResult.animationDelay).toBe('0.01s') + }) + + it('should handle failed node lookup gracefully', async () => { + const node = { + id: 'node1', + type: 'FRAME', + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'missing', + transition: { + type: 'SMART_ANIMATE', + duration: 0.3, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + mockGetNodeByIdAsync.mockRejectedValue(new Error('Node not found')) + + // Suppress console.error for this test + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + const result = await getReactionProps(node) + + expect(result).toEqual({}) + + consoleErrorSpy.mockRestore() + }) + + it('should handle DOCUMENT or PAGE node types', async () => { + const node = { + id: 'node1', + type: 'FRAME', + opacity: 1, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'page-node', + transition: { + type: 'SMART_ANIMATE', + duration: 0.3, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const pageNode = { + id: 'page-node', + type: 'PAGE', + } as any + + mockGetNodeByIdAsync.mockResolvedValue(pageNode) + + const result = await getReactionProps(node) + + expect(result).toEqual({}) + }) + + it('should handle animation chain error gracefully', async () => { + const node1 = { + id: 'node1', + type: 'FRAME', + x: 0, + y: 0, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'node2', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const node2 = { + id: 'node2', + type: 'FRAME', + x: 100, + y: 0, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'node3', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + mockGetNodeByIdAsync.mockImplementation(async (id: string) => { + if (id === 'node2') return node2 + if (id === 'node3') throw new Error('Failed to get node3') + return null + }) + + // Suppress console.error for this test + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + const result = await getReactionProps(node1) + expect(result.animationName).toBeDefined() expect(result.animationDuration).toBe('0.5s') + + consoleErrorSpy.mockRestore() + }) + + it('should match children by name with loop and delay', async () => { + const parentNode = { + id: 'parent', + type: 'FRAME', + children: [ + { + id: 'child1', + name: 'Button', + type: 'FRAME', + x: 0, + y: 0, + parent: { id: 'parent' }, + }, + ], + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'dest', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0.02, + }, + }, + ], + } as any + + const destNode = { + id: 'dest', + type: 'FRAME', + children: [ + { + id: 'child1-new', + name: 'Button', + type: 'FRAME', + x: 100, + y: 0, + }, + ], + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'parent', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + mockGetNodeByIdAsync.mockResolvedValue(destNode) + + await getReactionProps(parentNode) + + const childNode = parentNode.children[0] + const result = await getReactionProps(childNode) + expect(result.animationIterationCount).toBe('infinite') + expect(result.animationDelay).toBe('0.02s') + }) + + it('should handle children with no matching nodes in some steps', async () => { + const parentNode = { + id: 'parent', + type: 'FRAME', + children: [ + { + id: 'child1', + name: 'Button', + type: 'FRAME', + x: 0, + y: 0, + parent: { id: 'parent' }, + }, + ], + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'dest', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + // Destination has no children named 'Button' + const destNode = { + id: 'dest', + type: 'FRAME', + children: [ + { + id: 'child2', + name: 'Text', + type: 'FRAME', + x: 100, + y: 0, + }, + ], + } as any + + mockGetNodeByIdAsync.mockResolvedValue(destNode) + + const result = await getReactionProps(parentNode) + + expect(result).toEqual({}) + }) + + it('should handle parent and child nodes without children prop', async () => { + const parentNode = { + id: 'parent', + type: 'FRAME', + x: 0, + y: 0, + opacity: 1, + // No children property + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'dest', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const destNode = { + id: 'dest', + type: 'FRAME', + x: 100, + y: 0, + opacity: 0.5, + } as any + + mockGetNodeByIdAsync.mockResolvedValue(destNode) + + const result = await getReactionProps(parentNode) + + expect(result.animationName).toBeDefined() + expect(result.animationDuration).toBe('0.5s') + }) + + it('should return empty when child not found in cache', async () => { + const childWithNoCache = { + id: 'child-no-cache', + name: 'NoCache', + type: 'FRAME', + parent: { id: 'non-existent-parent' }, + } as any + + const result = await getReactionProps(childWithNoCache) + + expect(result).toEqual({}) + }) + + it('should handle node with direct self-loop (currentNodeId === startNode.id)', async () => { + const loopNode = { + id: 'loop-node', + type: 'FRAME', + x: 0, + y: 0, + opacity: 1, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'loop-node', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + mockGetNodeByIdAsync.mockResolvedValue(loopNode) + + const result = await getReactionProps(loopNode) + + // Direct self-loop should result in empty (isLoop: true but chain is empty) + expect(result).toEqual({}) + }) + + it('should handle chain with nested loop detection (isLoop propagation)', async () => { + const node1 = { + id: 'node1', + type: 'FRAME', + x: 0, + y: 0, + opacity: 1, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'node2', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const node2 = { + id: 'node2', + type: 'FRAME', + x: 100, + y: 0, + opacity: 0.8, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'node3', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const node3 = { + id: 'node3', + type: 'FRAME', + x: 200, + y: 0, + opacity: 0.5, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'node1', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + mockGetNodeByIdAsync.mockImplementation(async (id: string) => { + if (id === 'node2') return node2 + if (id === 'node3') return node3 + return null + }) + + const result = await getReactionProps(node1) + + expect(result.animationName).toBeDefined() + expect(result.animationIterationCount).toBe('infinite') + // 0.5 + 0.5 for chain + 0.5 for return-to-initial = 1.5s + expect(result.animationDuration).toBe('1.5s') + // Keyframes should include 100% returning to initial state + const animationName = result.animationName as string + expect(animationName).toContain('100%') + }) + + it('should handle visited node in recursive chain (line 284)', async () => { + const node1 = { + id: 'node1', + type: 'FRAME', + x: 0, + y: 0, + opacity: 1, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'node2', + transition: { + type: 'SMART_ANIMATE', + duration: 0.3, + }, + }, + { + type: 'NODE', + destinationId: 'node3', + transition: { + type: 'SMART_ANIMATE', + duration: 0.3, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const node2 = { + id: 'node2', + type: 'FRAME', + x: 50, + y: 0, + opacity: 0.8, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'node3', + transition: { + type: 'SMART_ANIMATE', + duration: 0.3, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const node3 = { + id: 'node3', + type: 'FRAME', + x: 100, + y: 0, + opacity: 0.5, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'node2', + transition: { + type: 'SMART_ANIMATE', + duration: 0.3, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + mockGetNodeByIdAsync.mockImplementation(async (id: string) => { + if (id === 'node2') return node2 + if (id === 'node3') return node3 + return null + }) + + const result = await getReactionProps(node1) + + expect(result.animationName).toBeDefined() + expect(result.animationDuration).toBe('0.6s') + }) + + it('should handle children without matching in prev/current nodes (line 413)', async () => { + const parentNode = { + id: 'parent', + type: 'FRAME', + children: [ + { + id: 'child1', + name: 'Button', + type: 'FRAME', + x: 0, + y: 0, + parent: { id: 'parent' }, + }, + ], + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'dest', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + // Dest has no children property + const destNode = { + id: 'dest', + type: 'FRAME', + x: 100, + y: 0, + } as any + + mockGetNodeByIdAsync.mockResolvedValue(destNode) + + const result = await getReactionProps(parentNode) + + expect(result).toEqual({}) + }) + + it('should handle firstChild missing in animation (line 465-466)', async () => { + const child1 = { + id: 'child1', + name: 'Button', + type: 'FRAME', + x: 0, + y: 0, + opacity: 1, + parent: { id: 'parent' }, + } as any + + const parentNode = { + id: 'parent', + type: 'FRAME', + children: [child1], + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'dest', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const destNode = { + id: 'dest', + type: 'FRAME', + children: [ + { + id: 'child1-dest', + name: 'Button', + type: 'FRAME', + x: 100, + y: 0, + opacity: 0.5, + }, + ], + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'dest2', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const destNode2 = { + id: 'dest2', + type: 'FRAME', + children: [ + { + id: 'child1-dest2', + name: 'Button', + type: 'FRAME', + x: 200, + y: 0, + opacity: 1, + }, + ], + } as any + + mockGetNodeByIdAsync.mockImplementation(async (id: string) => { + if (id === 'dest') return destNode + if (id === 'dest2') return destNode2 + return null + }) + + await getReactionProps(parentNode) + + const result = await getReactionProps(child1) + + expect(result.animationName).toBeDefined() + expect(result.animationDuration).toBe('1s') + }) + + it('should return cached child animation when cache exists for child name (line 41-43)', async () => { + const child1 = { + id: 'child1', + name: 'AnimatedButton', + type: 'FRAME', + x: 0, + y: 0, + parent: { id: 'parent' }, + } as any + + const parentNode = { + id: 'parent', + type: 'FRAME', + children: [child1], + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'dest', + transition: { + type: 'SMART_ANIMATE', + duration: 0.8, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0.03, + }, + }, + ], + } as any + + const destNode = { + id: 'dest', + type: 'FRAME', + children: [ + { + id: 'child1-dest', + name: 'AnimatedButton', + type: 'FRAME', + x: 150, + y: 50, + }, + ], + } as any + + mockGetNodeByIdAsync.mockResolvedValue(destNode) + + await getReactionProps(parentNode) + + const result = await getReactionProps(child1) + + expect(result.animationName).toBeDefined() + expect(result.animationDuration).toBe('0.8s') + expect(result.animationDelay).toBe('0.03s') + }) + + it('should handle when parent cache exists but child name not in cache (line 43)', async () => { + const child1 = { + id: 'child1', + name: 'CachedButton', + type: 'FRAME', + x: 0, + y: 0, + parent: { id: 'parent' }, + } as any + + const child2 = { + id: 'child2', + name: 'UncachedButton', + type: 'FRAME', + x: 0, + y: 0, + parent: { id: 'parent' }, + } as any + + const parentNode = { + id: 'parent', + type: 'FRAME', + children: [child1], + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'dest', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const destNode = { + id: 'dest', + type: 'FRAME', + children: [ + { + id: 'child1-dest', + name: 'CachedButton', + type: 'FRAME', + x: 100, + y: 0, + }, + ], + } as any + + mockGetNodeByIdAsync.mockResolvedValue(destNode) + + await getReactionProps(parentNode) + + const result = await getReactionProps(child2) + + expect(result).toEqual({}) + }) + + it('should handle rotation animation with cumulative rotation in loop (lines 188-193, 201, 290-295)', async () => { + const node1 = { + id: 'node1', + type: 'FRAME', + x: 0, + y: 0, + rotation: 0, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'node2', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const node2 = { + id: 'node2', + type: 'FRAME', + x: 0, + y: 0, + rotation: 90, // Rotated 90 degrees + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'node1', // Loop back + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + mockGetNodeByIdAsync.mockResolvedValue(node2) + + const result = await getReactionProps(node1) + + expect(result.animationName).toBeDefined() + expect(result.animationIterationCount).toBe('infinite') + // Should contain rotate in keyframes + const animationName = result.animationName as string + expect(animationName).toContain('rotate') + expect(animationName).toContain('100%') + }) + + it('should handle child rotation animation (lines 484, 505-506, 540-542, 553, 565-566, 607-612, 641-646)', async () => { + const child1 = { + id: 'child1', + name: 'RotatingChild', + type: 'FRAME', + x: 0, + y: 0, + rotation: 0, + parent: { id: 'parent' }, + } as any + + const parentNode = { + id: 'parent', + type: 'FRAME', + children: [child1], + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'dest', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const destNode = { + id: 'dest', + type: 'FRAME', + children: [ + { + id: 'child1-dest', + name: 'RotatingChild', + type: 'FRAME', + x: 0, + y: 0, + rotation: 45, + }, + ], + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'parent', // Loop back + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + mockGetNodeByIdAsync.mockResolvedValue(destNode) + + await getReactionProps(parentNode) + + const result = await getReactionProps(child1) + + expect(result.animationName).toBeDefined() + expect(result.animationIterationCount).toBe('infinite') + const animationName = result.animationName as string + expect(animationName).toContain('rotate') + }) + + it('should handle rotation delta greater than 180 degrees (line 758)', async () => { + const node1 = { + id: 'node1', + type: 'FRAME', + rotation: 170, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'node2', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + // Rotation from 170 to -170 should take the short path (+20 degrees) + const node2 = { + id: 'node2', + type: 'FRAME', + rotation: -170, + } as any + + mockGetNodeByIdAsync.mockResolvedValue(node2) + + const result = await getReactionProps(node1) + + expect(result.animationName).toBeDefined() + const animationName = result.animationName as string + expect(animationName).toContain('rotate') + // The delta should be normalized (170 to -170 = -340, normalized to +20) + }) + + it('should handle rotation with multi-step chain (covers cumulative rotation)', async () => { + const node1 = { + id: 'node1', + type: 'FRAME', + rotation: 0, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'node2', + transition: { + type: 'SMART_ANIMATE', + duration: 0.3, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const node2 = { + id: 'node2', + type: 'FRAME', + rotation: 90, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'node3', + transition: { + type: 'SMART_ANIMATE', + duration: 0.3, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const node3 = { + id: 'node3', + type: 'FRAME', + rotation: 180, + } as any + + mockGetNodeByIdAsync.mockImplementation(async (id: string) => { + if (id === 'node2') return node2 + if (id === 'node3') return node3 + return null + }) + + const result = await getReactionProps(node1) + + expect(result.animationName).toBeDefined() + const animationName = result.animationName as string + expect(animationName).toContain('rotate') + // Should have intermediate keyframes + expect(animationName).toContain('50%') + expect(animationName).toContain('100%') + }) + + it('should set rotate(0deg) when hasRotationAnimation but no initialValues.transform (line 201)', async () => { + // This test covers the case where rotation animation exists but + // the starting transform value is not in startingValues + const node1 = { + id: 'node1', + type: 'FRAME', + rotation: 0, // Starting at 0 + // No x, y changes, only rotation + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'node2', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const node2 = { + id: 'node2', + type: 'FRAME', + rotation: 45, // Only rotation changes + } as any + + mockGetNodeByIdAsync.mockResolvedValue(node2) + + const result = await getReactionProps(node1) + + expect(result.animationName).toBeDefined() + const animationName = result.animationName as string + // Should contain rotate(0deg) in initial keyframe + expect(animationName).toContain('rotate(0deg)') + expect(animationName).toContain('rotate(-45deg)') + }) + + it('should handle child rotation with starting values from startChild (lines 538-542)', async () => { + const child1 = { + id: 'child1', + name: 'RotatingIcon', + type: 'FRAME', + x: 0, + y: 0, + rotation: 0, + parent: { id: 'parent' }, + } as any + + const parentNode = { + id: 'parent', + type: 'FRAME', + children: [child1], + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'dest1', + transition: { + type: 'SMART_ANIMATE', + duration: 0.3, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const destNode1 = { + id: 'dest1', + type: 'FRAME', + children: [ + { + id: 'child1-dest1', + name: 'RotatingIcon', + type: 'FRAME', + x: 0, + y: 0, + rotation: 120, + }, + ], + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'dest2', + transition: { + type: 'SMART_ANIMATE', + duration: 0.3, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const destNode2 = { + id: 'dest2', + type: 'FRAME', + children: [ + { + id: 'child1-dest2', + name: 'RotatingIcon', + type: 'FRAME', + x: 0, + y: 0, + rotation: -120, + }, + ], + } as any + + mockGetNodeByIdAsync.mockImplementation(async (id: string) => { + if (id === 'dest1') return destNode1 + if (id === 'dest2') return destNode2 + return null + }) + + await getReactionProps(parentNode) + + const result = await getReactionProps(child1) + + expect(result.animationName).toBeDefined() + const animationName = result.animationName as string + expect(animationName).toContain('rotate') }) }) diff --git a/src/codegen/props/__tests__/selector.test.ts b/src/codegen/props/__tests__/selector.test.ts new file mode 100644 index 0000000..8455785 --- /dev/null +++ b/src/codegen/props/__tests__/selector.test.ts @@ -0,0 +1,396 @@ +import { describe, expect, test } from 'bun:test' +import { getSelectorProps } from '../selector' + +// Mock figma global +;(globalThis as { figma?: unknown }).figma = { + mixed: Symbol('mixed'), + util: { + rgba: (color: { r: number; g: number; b: number; a?: number }) => ({ + r: color.r, + g: color.g, + b: color.b, + a: color.a ?? 1, + }), + }, +} as unknown as typeof figma + +describe('getSelectorProps', () => { + test('returns undefined for non-COMPONENT_SET node', async () => { + const node = { + type: 'FRAME', + name: 'NotComponentSet', + children: [], + visible: true, + } as unknown as FrameNode + + const result = await getSelectorProps(node as unknown as ComponentSetNode) + expect(result).toBeUndefined() + }) + + test('handles componentPropertyDefinitions with effect property', async () => { + const defaultVariant = { + type: 'COMPONENT', + name: 'State=Default, effect=default', + children: [], + visible: true, + reactions: [], + variantProperties: { State: 'Default', effect: 'default' }, + } as unknown as ComponentNode + + const hoverVariant = { + type: 'COMPONENT', + name: 'State=Hover, effect=hover', + children: [], + visible: true, + reactions: [], + variantProperties: { State: 'Hover', effect: 'hover' }, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 0, g: 0.5, b: 1 }, + opacity: 1, + }, + ], + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'EffectButton', + children: [defaultVariant, hoverVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + State: { + type: 'VARIANT', + variantOptions: ['Default', 'Hover'], + }, + effect: { + type: 'VARIANT', + variantOptions: ['default', 'hover'], + }, + }, + } as unknown as ComponentSetNode + + const result = await getSelectorProps(node) + expect(result).toBeDefined() + // effect should not be in variants (it's treated specially) + expect(result?.variants.effect).toBeUndefined() + expect(result?.variants.State).toBe("'Default' | 'Hover'") + }) + + test('handles COMPONENT node with COMPONENT_SET parent', async () => { + const defaultVariant = { + type: 'COMPONENT', + name: 'State=Default', + children: [], + visible: true, + reactions: [], + } as unknown as ComponentNode + + const componentSet = { + type: 'COMPONENT_SET', + name: 'TestSet', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + State: { + type: 'VARIANT', + variantOptions: ['Default'], + }, + }, + } as unknown as ComponentSetNode + + // Set parent relationship + ;(defaultVariant as unknown as { parent: ComponentSetNode }).parent = + componentSet + + const result = await getSelectorProps(defaultVariant) + expect(result).toBeDefined() + expect(result?.variants.State).toBe("'Default'") + }) + + describe('sanitizePropertyName', () => { + test('returns variant for numeric-only property name', async () => { + const defaultVariant = { + type: 'COMPONENT', + name: '123=Default', + children: [], + visible: true, + reactions: [], + variantProperties: { '123': 'Default' }, + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'NumericPropertySet', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + '123': { + type: 'VARIANT', + variantOptions: ['Default', 'Active'], + }, + }, + } as unknown as ComponentSetNode + + const result = await getSelectorProps(node) + + expect(result).toBeDefined() + expect(result?.variants).toBeDefined() + // Numeric property name '123' is prefixed with _ to become '_123' + expect(result?.variants._123).toBe("'Default' | 'Active'") + }) + + test('returns variant for empty property name after cleaning', async () => { + const defaultVariant = { + type: 'COMPONENT', + name: '한글=Default', + children: [], + visible: true, + reactions: [], + variantProperties: { 한글: 'Default' }, + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'KoreanPropertySet', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + 한글: { + type: 'VARIANT', + variantOptions: ['Default', 'Active'], + }, + }, + } as unknown as ComponentSetNode + + const result = await getSelectorProps(node) + + expect(result).toBeDefined() + expect(result?.variants).toBeDefined() + // Korean property name should be sanitized to 'variant' (empty after cleaning) + expect(result?.variants.variant).toBe("'Default' | 'Active'") + }) + + test('converts property name with spaces and special chars to camelCase', async () => { + const defaultVariant = { + type: 'COMPONENT', + name: 'my-prop_name test=Default', + children: [], + visible: true, + reactions: [], + variantProperties: { 'my-prop_name test': 'Default' }, + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'CamelCasePropertySet', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + 'my-prop_name test': { + type: 'VARIANT', + variantOptions: ['Default', 'Active'], + }, + }, + } as unknown as ComponentSetNode + + const result = await getSelectorProps(node) + + expect(result).toBeDefined() + expect(result?.variants).toBeDefined() + // Property name with special chars should be converted to camelCase + expect(result?.variants.myPropNameTest).toBe("'Default' | 'Active'") + }) + + test('sanitizes property name that starts with digit', async () => { + const defaultVariant = { + type: 'COMPONENT', + name: '1abc=Default', + children: [], + visible: true, + reactions: [], + variantProperties: { '1abc': 'Default' }, + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'DigitStartPropertySet', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + '1abc': { + type: 'VARIANT', + variantOptions: ['Default', 'Active'], + }, + }, + } as unknown as ComponentSetNode + + const result = await getSelectorProps(node) + + expect(result).toBeDefined() + expect(result?.variants).toBeDefined() + // Property name starting with digit should be prefixed with _ + expect(result?.variants._1abc).toBe("'Default' | 'Active'") + }) + }) + + describe('triggerTypeToEffect', () => { + test('handles ON_HOVER trigger type', async () => { + const defaultVariant = { + type: 'COMPONENT', + name: 'State=Default', + children: [], + visible: true, + reactions: [{ trigger: { type: 'ON_HOVER' } }], + } as unknown as ComponentNode + + const hoverVariant = { + type: 'COMPONENT', + name: 'State=Hover', + children: [], + visible: true, + reactions: [{ trigger: { type: 'ON_HOVER' } }], + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 0, g: 0.5, b: 1 }, + opacity: 1, + }, + ], + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'HoverButton', + children: [defaultVariant, hoverVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + State: { + type: 'VARIANT', + variantOptions: ['Default', 'Hover'], + }, + }, + } as unknown as ComponentSetNode + + const result = await getSelectorProps(node) + expect(result).toBeDefined() + // Should have _hover props when triggerType is ON_HOVER + expect(result?.props._hover).toBeDefined() + }) + + test('handles ON_PRESS trigger type', async () => { + const defaultVariant = { + type: 'COMPONENT', + name: 'State=Default', + children: [], + visible: true, + reactions: [{ trigger: { type: 'ON_PRESS' } }], + } as unknown as ComponentNode + + const activeVariant = { + type: 'COMPONENT', + name: 'State=Active', + children: [], + visible: true, + reactions: [{ trigger: { type: 'ON_PRESS' } }], + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 1, g: 0, b: 0 }, + opacity: 1, + }, + ], + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'ActiveButton', + children: [defaultVariant, activeVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + State: { + type: 'VARIANT', + variantOptions: ['Default', 'Active'], + }, + }, + } as unknown as ComponentSetNode + + const result = await getSelectorProps(node) + expect(result).toBeDefined() + // Should have _active props when triggerType is ON_PRESS + expect(result?.props._active).toBeDefined() + }) + + test('handles SMART_ANIMATE transition with reactions', async () => { + const defaultVariant = { + type: 'COMPONENT', + name: 'State=Default', + children: [], + visible: true, + reactions: [ + { + trigger: { type: 'ON_HOVER' }, + actions: [ + { + type: 'NODE', + transition: { + type: 'SMART_ANIMATE', + duration: 0.3, + easing: { type: 'EASE_IN_AND_OUT' }, + }, + }, + ], + }, + ], + } as unknown as ComponentNode + + const hoverVariant = { + type: 'COMPONENT', + name: 'State=Hover', + children: [], + visible: true, + reactions: [{ trigger: { type: 'ON_HOVER' } }], + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 0, g: 0.5, b: 1 }, + opacity: 1, + }, + ], + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'AnimatedButton', + children: [defaultVariant, hoverVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + State: { + type: 'VARIANT', + variantOptions: ['Default', 'Hover'], + }, + }, + } as unknown as ComponentSetNode + + const result = await getSelectorProps(node) + expect(result).toBeDefined() + expect(result?.props._hover).toBeDefined() + // Should have transition props when SMART_ANIMATE is used + expect(result?.props.transition).toBeDefined() + expect(result?.props.transitionProperty).toBeDefined() + }) + }) +}) diff --git a/src/codegen/props/auto-layout.ts b/src/codegen/props/auto-layout.ts index b41321d..b782254 100644 --- a/src/codegen/props/auto-layout.ts +++ b/src/codegen/props/auto-layout.ts @@ -22,7 +22,7 @@ export function getAutoLayoutProps( VERTICAL: 'column', }[layoutMode], gap: - childrenCount > 1 + childrenCount > 1 && node.primaryAxisAlignItems !== 'SPACE_BETWEEN' ? addPx(node.inferredAutoLayout.itemSpacing) : undefined, justifyContent: getJustifyContent(node), diff --git a/src/codegen/props/cursor.ts b/src/codegen/props/cursor.ts new file mode 100644 index 0000000..d6c3ad4 --- /dev/null +++ b/src/codegen/props/cursor.ts @@ -0,0 +1,16 @@ +export function getCursorProps(node: SceneNode): Record { + if (!('reactions' in node) || !node.reactions) { + return {} + } + + // Check if any reaction has ON_CLICK trigger + const hasClickTrigger = node.reactions.some( + (reaction) => reaction.trigger?.type === 'ON_CLICK', + ) + + if (hasClickTrigger) { + return { cursor: 'pointer' } + } + + return {} +} diff --git a/src/codegen/props/effect.ts b/src/codegen/props/effect.ts index e673069..30c900e 100644 --- a/src/codegen/props/effect.ts +++ b/src/codegen/props/effect.ts @@ -51,7 +51,6 @@ function _getEffectPropsFromEffect(effect: Effect): Record { case 'BACKGROUND_BLUR': return { backdropFilter: `blur(${effect.radius}px)`, - WebkitBackdropFilter: `blur(${effect.radius}px)`, } case 'NOISE': @@ -66,7 +65,6 @@ function _getEffectPropsFromEffect(effect: Effect): Record { case 'GLASS': return { backdropFilter: `blur(${effect.radius}px)`, - WebkitBackdropFilter: `blur(${effect.radius}px)`, } } } diff --git a/src/codegen/props/index.ts b/src/codegen/props/index.ts index 82eeee3..5c056b3 100644 --- a/src/codegen/props/index.ts +++ b/src/codegen/props/index.ts @@ -2,6 +2,7 @@ import { getAutoLayoutProps } from './auto-layout' import { getBackgroundProps } from './background' import { getBlendProps } from './blend' import { getBorderProps, getBorderRadiusProps } from './border' +import { getCursorProps } from './cursor' import { getEffectProps } from './effect' import { getEllipsisProps } from './ellipsis' import { getGridChildProps } from './grid-child' @@ -16,12 +17,11 @@ import { getTextAlignProps } from './text-align' import { getTextShadowProps } from './text-shadow' import { getTextStrokeProps } from './text-stroke' import { getTransformProps } from './transform' +import { getVisibilityProps } from './visibility' export async function getProps( node: SceneNode, -): Promise< - Record -> { +): Promise> { return { ...getAutoLayoutProps(node), ...getMinMaxProps(node), @@ -43,6 +43,8 @@ export async function getProps( ...(await getTextStrokeProps(node)), ...(await getTextShadowProps(node)), ...(await getReactionProps(node)), + ...getCursorProps(node), + ...getVisibilityProps(node), } } @@ -54,14 +56,23 @@ export function filterPropsWithComponent( for (const [key, value] of Object.entries(props)) { switch (component) { case 'Flex': + // Only skip display/flexDir if it's exactly the default value (not responsive array) + if (key === 'display' && value === 'flex') continue + if (key === 'flexDir' && value === 'row') continue + break case 'Grid': - if (['display'].includes(key)) continue + // Only skip display if it's exactly 'grid' (not responsive array or other value) + if (key === 'display' && value === 'grid') continue break case 'Center': - if (['alignItems', 'justifyContent', 'display'].includes(key)) continue + if (['alignItems', 'justifyContent'].includes(key)) continue + if (key === 'display' && value === 'flex') continue + if (key === 'flexDir' && value === 'row') continue break case 'VStack': - if (['flexDir', 'display'].includes(key)) continue + // Only skip flexDir if it's exactly 'column' (not responsive array or other value) + if (key === 'flexDir' && value === 'column') continue + if (key === 'display' && value === 'flex') continue break case 'Image': @@ -69,7 +80,6 @@ export function filterPropsWithComponent( if (component === 'Box' && !('maskImage' in props)) break if ( [ - 'display', 'alignItems', 'justifyContent', 'flexDir', @@ -80,6 +90,7 @@ export function filterPropsWithComponent( ].includes(key) ) continue + if (key === 'display' && value === 'flex') continue if (!('maskImage' in props) && ['bg'].includes(key)) continue break } diff --git a/src/codegen/props/layout.ts b/src/codegen/props/layout.ts index e77d150..b186633 100644 --- a/src/codegen/props/layout.ts +++ b/src/codegen/props/layout.ts @@ -1,6 +1,8 @@ import { addPx } from '../utils/add-px' +import { checkAssetNode } from '../utils/check-asset-node' import { getPageNode } from '../utils/get-page-node' import { isChildWidthShrinker } from '../utils/is-child-width-shrinker' +import { canBeAbsolute } from './position' export function getMinMaxProps( node: SceneNode, @@ -44,18 +46,22 @@ function _getTextLayoutProps( function _getLayoutProps( node: SceneNode, ): Record { - if ( - 'layoutPositioning' in node && - node.layoutPositioning === 'ABSOLUTE' && - node.parent && - 'width' in node.parent && - 'height' in node.parent && - node.parent.width === node.width && - node.parent.height === node.height - ) { + if (canBeAbsolute(node)) { return { - w: '100%', - h: '100%', + w: + node.type === 'TEXT' || + (node.parent && + 'width' in node.parent && + node.parent.width > node.width) + ? checkAssetNode(node) + ? addPx(node.width) + : undefined + : '100%', + // if node does not have children, it is a single node, so it should be 100% + h: + ('children' in node && node.children.length > 0) || node.type === 'TEXT' + ? undefined + : '100%', } } const hType = @@ -82,7 +88,9 @@ function _getLayoutProps( ? 1 : undefined, w: - rootNode === node && node.width === 1920 + rootNode === node && + node.width === + (getPageNode(node as BaseNode & ChildrenMixin) as SceneNode)?.width ? undefined : wType === 'FIXED' ? addPx(node.width) diff --git a/src/codegen/props/position.ts b/src/codegen/props/position.ts index 6dd0363..034e4f1 100644 --- a/src/codegen/props/position.ts +++ b/src/codegen/props/position.ts @@ -1,6 +1,8 @@ import { addPx } from '../utils/add-px' +import { checkAssetNode } from '../utils/check-asset-node' +import { isPageRoot } from '../utils/is-page-root' -function isFreelayout(node: BaseNode & ChildrenMixin) { +export function isFreelayout(node: BaseNode & ChildrenMixin) { return ( (!('inferredAutoLayout' in node) || !node.inferredAutoLayout) && 'layoutPositioning' in node && @@ -8,17 +10,23 @@ function isFreelayout(node: BaseNode & ChildrenMixin) { ) } -export function getPositionProps( - node: SceneNode, -): Record | undefined { - if ( +export function canBeAbsolute(node: SceneNode): boolean { + return !!( 'parent' in node && node.parent && (('layoutPositioning' in node && node.layoutPositioning === 'ABSOLUTE') || - isFreelayout(node.parent)) && - 'width' in node.parent && - 'height' in node.parent - ) { + (isFreelayout(node.parent) && + 'width' in node.parent && + 'height' in node.parent && + 'constraints' in node && + !isPageRoot(node as SceneNode))) + ) +} + +export function getPositionProps( + node: SceneNode, +): Record | undefined { + if ('parent' in node && node.parent && canBeAbsolute(node)) { const constraints = 'constraints' in node ? node.constraints @@ -27,40 +35,57 @@ export function getPositionProps( 'constraints' in node.children[0] ? node.children[0].constraints : undefined - if (!constraints) return + if (!constraints) { + if (isFreelayout(node.parent)) + return { + pos: 'absolute', + left: addPx(node.x) ?? '0px', + top: addPx(node.y) ?? '0px', + } + return + } const { horizontal, vertical } = constraints + let left: string | undefined let right: string | undefined let top: string | undefined let bottom: string | undefined - if (isFreelayout(node.parent)) { - left = addPx(node.x) ?? '0px' - top = addPx(node.y) ?? '0px' - } else { - switch (horizontal) { - case 'MIN': - left = addPx(node.x) ?? '0px' - break - case 'MAX': - right = addPx(node.parent.width - node.x - node.width) ?? '0px' - break - default: - left = '0px' - right = '0px' - break - } - switch (vertical) { - case 'MIN': - top = addPx(node.y) ?? '0px' - break - case 'MAX': - bottom = addPx(node.parent.height - node.y - node.height) ?? '0px' - break - default: - top = '0px' - bottom = '0px' - break - } + let translateX: string | undefined + let translateY: string | undefined + switch (horizontal) { + case 'MIN': + left = addPx(node.x) ?? '0px' + break + case 'MAX': + right = + addPx((node.parent as SceneNode).width - node.x - node.width) ?? '0px' + break + case 'CENTER': + left = '50%' + translateX = '50%' + break + default: + left = '0px' + right = '0px' + break + } + switch (vertical) { + case 'MIN': + top = addPx(node.y) ?? '0px' + break + case 'MAX': + bottom = + addPx((node.parent as SceneNode).height - node.y - node.height) ?? + '0px' + break + case 'CENTER': + top = '50%' + translateY = '50%' + break + default: + top = '0px' + bottom = '0px' + break } return { pos: 'absolute', @@ -68,10 +93,19 @@ export function getPositionProps( right, top, bottom, + transform: + translateX && translateY + ? `translate(-${translateX}, -${translateY})` + : translateX + ? `translateX(-${translateX})` + : translateY + ? `translateY(-${translateY})` + : undefined, } } if ( 'children' in node && + !checkAssetNode(node) && (node.children.some( (child) => 'layoutPositioning' in child && child.layoutPositioning === 'ABSOLUTE', @@ -80,7 +114,8 @@ export function getPositionProps( node.children.some( (child) => 'layoutPositioning' in child && child.layoutPositioning === 'AUTO', - ))) + ))) && + !isPageRoot(node) ) { return { pos: 'relative', diff --git a/src/codegen/props/reaction.ts b/src/codegen/props/reaction.ts index 35dbb03..7004f63 100644 --- a/src/codegen/props/reaction.ts +++ b/src/codegen/props/reaction.ts @@ -1,4 +1,6 @@ import { fmtPct } from '../utils/fmtPct' +import { isPageRoot } from '../utils/is-page-root' +import { solidToString } from '../utils/solid-to-string' interface KeyframeData { [percentage: string]: Record @@ -133,17 +135,32 @@ export async function getReactionProps( const animatedProperties = new Set() for (const changes of allChanges) { for (const key of Object.keys(changes)) { - animatedProperties.add(key) + // rotationDelta will be converted to transform + if (key === 'rotationDelta') { + animatedProperties.add('transform') + } else { + animatedProperties.add(key) + } } } // Get initial values for animated properties from the START node // Only include properties that have different values across frames const initialValues: Record = {} + // Check if rotation animation exists + const hasRotationAnimation = allChanges.some( + (changes) => 'rotationDelta' in changes, + ) + if (allChanges.length > 0 && animatedProperties.size > 0) { // For each property, check if it has different values const propertyNeedsInitial = new Map() for (const key of animatedProperties) { + // For transform with rotation, always needs initial value + if (key === 'transform' && hasRotationAnimation) { + propertyNeedsInitial.set(key, true) + continue + } const values = new Set() for (const changes of allChanges) { if (changes[key] !== undefined) { @@ -165,11 +182,16 @@ export async function getReactionProps( // For each animated property, use the starting value only if it has multiple different values for (const key of animatedProperties) { - if ( - propertyNeedsInitial.get(key) && - startingValues[key] !== undefined - ) { - initialValues[key] = startingValues[key] + if (propertyNeedsInitial.get(key)) { + if (startingValues[key] !== undefined) { + initialValues[key] = startingValues[key] + } else if ( + key === 'transform' && + hasRotationAnimation + ) { + // For rotation animation, start at 0deg + initialValues[key] = 'rotate(0deg)' + } } } } @@ -181,6 +203,11 @@ export async function getReactionProps( // Exclude properties that appear multiple times with the same value const propertyHasMultipleValues = new Map() for (const key of animatedProperties) { + // For transform with rotation, always include + if (key === 'transform' && hasRotationAnimation) { + propertyHasMultipleValues.set(key, true) + continue + } const values = new Set() let occurrenceCount = 0 for (const changes of allChanges) { @@ -197,20 +224,38 @@ export async function getReactionProps( } // Second pass: build keyframes with incremental changes + // For loop animations, we need to add one more step to return to initial state + const effectiveTotalDuration = isLoop + ? totalDuration + animationChain[0].duration + : totalDuration + let accumulatedTime = 0 let previousKeyframe: Record = { ...initialValues, } + let cumulativeRotation = 0 // Track cumulative rotation + for (let i = 0; i < animationChain.length; i++) { const step = animationChain[i] accumulatedTime += step.duration const percentage = Math.round( - (accumulatedTime / totalDuration) * 100, + (accumulatedTime / effectiveTotalDuration) * 100, ) const percentageKey = `${percentage}%` - const changes = allChanges[i] + const changes = { ...allChanges[i] } + + // Convert rotationDelta to cumulative transform + if ('rotationDelta' in changes) { + cumulativeRotation += changes.rotationDelta as number + const existingTransform = + (changes.transform as string) || '' + changes.transform = existingTransform + ? `${existingTransform} rotate(${fmtPct(cumulativeRotation)}deg)` + : `rotate(${fmtPct(cumulativeRotation)}deg)` + delete changes.rotationDelta + } // Only include properties that changed from previous keyframe AND have multiple values const incrementalChanges: Record = {} @@ -232,10 +277,25 @@ export async function getReactionProps( } } + // For loop animations, add 100% keyframe that returns to initial state + if (isLoop && Object.keys(keyframes).length > 1) { + const finalKeyframe = { ...initialValues } + // If there was rotation, complete the full rotation cycle + if (cumulativeRotation !== 0) { + const fullRotation = + Math.sign(cumulativeRotation) * + Math.ceil(Math.abs(cumulativeRotation) / 360) * + 360 + // Replace the rotation value entirely (don't append to existing rotate(0deg)) + finalKeyframe.transform = `rotate(${fmtPct(fullRotation)}deg)` + } + keyframes['100%'] = finalKeyframe + } + if (Object.keys(keyframes).length > 1) { const props: Record = { animationName: `keyframes(${JSON.stringify(keyframes)})`, - animationDuration: `${fmtDuration(totalDuration)}s`, + animationDuration: `${fmtDuration(effectiveTotalDuration)}s`, animationTimingFunction: getEasingFunction(firstEasing), animationFillMode: 'forwards', } @@ -279,11 +339,6 @@ async function buildAnimationChain( const currentNodeId = currentNode.id let isLoop = false - // Prevent infinite loops by checking if we've visited this node - if (visitedIds.has(currentNodeId)) { - return { chain, isLoop: false } - } - // Check for circular reference back to start node (this means it's a loop!) if (currentNodeId === startNode.id) { return { chain, isLoop: true } @@ -419,17 +474,32 @@ async function generateChildAnimations( const animatedProperties = new Set() for (const changes of allChanges) { for (const key of Object.keys(changes)) { - animatedProperties.add(key) + // rotationDelta will be converted to transform + if (key === 'rotationDelta') { + animatedProperties.add('transform') + } else { + animatedProperties.add(key) + } } } // Get initial values for animated properties from the START state // Only include properties that have different values across frames const initialValues: Record = {} + // Check if rotation animation exists + const hasRotationAnimation = allChanges.some( + (changes) => 'rotationDelta' in changes, + ) + if (allChanges.length > 0 && animatedProperties.size > 0) { // For each property, check if it has different values const propertyNeedsInitial = new Map() for (const key of animatedProperties) { + // For transform with rotation, always needs initial value + if (key === 'transform' && hasRotationAnimation) { + propertyNeedsInitial.set(key, true) + continue + } const values = new Set() for (const changes of allChanges) { if (changes[key] !== undefined) { @@ -459,11 +529,13 @@ async function generateChildAnimations( // For each animated property, use the starting value only if it has multiple different values for (const key of animatedProperties) { - if ( - propertyNeedsInitial.get(key) && - startingValues[key] !== undefined - ) { - initialValues[key] = startingValues[key] + if (propertyNeedsInitial.get(key)) { + if (startingValues[key] !== undefined) { + initialValues[key] = startingValues[key] + } else if (key === 'transform' && hasRotationAnimation) { + // For rotation animation, start at 0deg + initialValues[key] = 'rotate(0deg)' + } } } } @@ -478,6 +550,11 @@ async function generateChildAnimations( // Exclude properties that appear multiple times with the same value const propertyHasMultipleValues = new Map() for (const key of animatedProperties) { + // For transform with rotation, always include (cumulative values are always different) + if (key === 'transform' && hasRotationAnimation) { + propertyHasMultipleValues.set(key, true) + continue + } const values = new Set() let occurrenceCount = 0 for (const changes of allChanges) { @@ -494,16 +571,36 @@ async function generateChildAnimations( } // Second pass: build keyframes with incremental changes + // For loop animations, we need to add one more step to return to initial state + // So totalDuration needs to account for this extra step + const effectiveTotalDuration = isLoop + ? totalDuration + chain[0].duration + : totalDuration + accumulatedTime = 0 let previousKeyframe: Record = { ...initialValues } + let cumulativeRotation = 0 // Track cumulative rotation for continuous rotation animation + for (let i = 0; i < chain.length; i++) { const step = chain[i] accumulatedTime += step.duration - const percentage = Math.round((accumulatedTime / totalDuration) * 100) + const percentage = Math.round( + (accumulatedTime / effectiveTotalDuration) * 100, + ) const percentageKey = `${percentage}%` - const changes = allChanges[i] + const changes = { ...allChanges[i] } + + // Convert rotationDelta to cumulative transform + if ('rotationDelta' in changes) { + cumulativeRotation += changes.rotationDelta as number + const existingTransform = (changes.transform as string) || '' + changes.transform = existingTransform + ? `${existingTransform} rotate(${fmtPct(cumulativeRotation)}deg)` + : `rotate(${fmtPct(cumulativeRotation)}deg)` + delete changes.rotationDelta + } // Only include properties that changed from previous keyframe AND have multiple values const incrementalChanges: Record = {} @@ -523,6 +620,24 @@ async function generateChildAnimations( } } + // For loop animations, add 100% keyframe that returns to initial state + // For rotation, calculate what the final rotation should be to complete the loop + if (isLoop && hasChanges) { + const finalKeyframe = { ...initialValues } + // If there was rotation, complete the full rotation cycle + if (cumulativeRotation !== 0) { + // Calculate the final rotation to complete a full cycle back to 0 + // e.g., if we rotated 270deg (3 steps of 90deg), final should be 360deg + const fullRotation = + Math.sign(cumulativeRotation) * + Math.ceil(Math.abs(cumulativeRotation) / 360) * + 360 + // Replace the rotation value entirely (don't append to existing rotate(0deg)) + finalKeyframe.transform = `rotate(${fmtPct(fullRotation)}deg)` + } + keyframes['100%'] = finalKeyframe + } + // If this child has changes, add animation props if (hasChanges && Object.keys(keyframes).length > 1) { const firstEasing = firstStep.easing || { type: 'LINEAR' } @@ -530,7 +645,7 @@ async function generateChildAnimations( const props: Record = { animationName: `keyframes(${JSON.stringify(keyframes)})`, - animationDuration: `${fmtDuration(totalDuration)}s`, + animationDuration: `${fmtDuration(effectiveTotalDuration)}s`, animationTimingFunction: getEasingFunction(firstEasing), animationFillMode: 'forwards', } @@ -559,7 +674,13 @@ async function generateSingleNodeDifferences( const changes: Record = {} // Check position changes - if ('x' in fromNode && 'x' in toNode && 'y' in fromNode && 'y' in toNode) { + if ( + !isPageRoot(toNode.parent as BaseNode) && + 'x' in fromNode && + 'x' in toNode && + 'y' in fromNode && + 'y' in toNode + ) { if (fromNode.x !== toNode.x || fromNode.y !== toNode.y) { const deltaX = toNode.x - fromNode.x const deltaY = toNode.y - fromNode.y @@ -609,18 +730,28 @@ async function generateSingleNodeDifferences( toFill.type === 'SOLID' && !isSameColor(fromFill.color, toFill.color) ) { - changes.bg = rgbToString(toFill.color, toFill.opacity) + changes.bg = await solidToString(toFill) } } } // Check rotation changes + // Store rotation delta instead of absolute value to support cumulative rotation if ('rotation' in fromNode && 'rotation' in toNode) { if (fromNode.rotation !== toNode.rotation) { - const existingTransform = (changes.transform as string) || '' - changes.transform = existingTransform - ? `${existingTransform} rotate(${fmtPct(toNode.rotation)}deg)` - : `rotate(${fmtPct(toNode.rotation)}deg)` + // Calculate the shortest rotation delta, considering Figma's -180 to 180 range + let delta = toNode.rotation - fromNode.rotation + + // Normalize delta to handle wrap-around (e.g., 170 to -170 should be +20, not -340) + // If absolute delta > 180, it means we crossed the -180/180 boundary + if (delta > 180) { + delta -= 360 + } else if (delta < -180) { + delta += 360 + } + + // Store as rotationDelta for cumulative calculation in keyframe building + changes.rotationDelta = -delta // Negate because Figma rotation is clockwise-negative } } @@ -649,15 +780,3 @@ function isSameColor(color1: RGB, color2: RGB): boolean { Math.abs(color1.b - color2.b) < 0.01 ) } - -function rgbToString(color: RGB, opacity?: number): string { - const r = Math.round(color.r * 255) - const g = Math.round(color.g * 255) - const b = Math.round(color.b * 255) - - if (opacity !== undefined && opacity < 1) { - return `rgba(${r}, ${g}, ${b}, ${opacity})` - } - - return `rgb(${r}, ${g}, ${b})` -} diff --git a/src/codegen/props/selector.ts b/src/codegen/props/selector.ts index becc1e3..f38da9f 100644 --- a/src/codegen/props/selector.ts +++ b/src/codegen/props/selector.ts @@ -1,6 +1,29 @@ import { fmtPct } from '../utils/fmtPct' import { getProps } from '.' +// 속성 이름을 유효한 TypeScript 식별자로 변환 +const toUpperCase = (_: string, chr: string) => chr.toUpperCase() + +function sanitizePropertyName(name: string): string { + // 1. 공백과 특수문자를 처리하여 camelCase로 변환 + const result = name + .trim() + // 공백이나 특수문자 뒤의 문자를 대문자로 (camelCase 변환) + .replace(/[\s\-_]+(.)/g, toUpperCase) + // 숫자로 시작하면 앞에 _ 추가 + .replace(/^(\d)/, '_$1') + + // 2. 유효하지 않은 문자 제거 (한글, 특수문자 등) + const cleaned = result.replace(/[^\w$]/g, '') + + // 3. 완전히 비어있거나 숫자로만 구성된 경우 기본값 사용 + if (!cleaned || /^\d+$/.test(cleaned)) { + return 'variant' + } + + return cleaned +} + export async function getSelectorProps( node: ComponentSetNode | ComponentNode, ): Promise< @@ -36,7 +59,12 @@ export async function getSelectorProps( const result = Object.entries(node.componentPropertyDefinitions).reduce( (acc, [name, definition]) => { if (name !== 'effect') { - acc.variants[name] = definition.variantOptions?.join(' | ') || '' + const sanitizedName = sanitizePropertyName(name) + // variant 옵션값들을 문자열 리터럴로 감싸기 + acc.variants[sanitizedName] = + definition.variantOptions + ?.map((option) => `'${option}'`) + .join(' | ') || '' } return acc }, @@ -47,12 +75,11 @@ export async function getSelectorProps( ) if (components.length > 0) { + const findNodeAction = (action: Action) => action.type === 'NODE' + const getTransition = (reaction: Reaction) => + reaction.actions?.find(findNodeAction)?.transition const transition = node.defaultVariant.reactions - .flatMap( - (reaction) => - reaction.actions?.find((action) => action.type === 'NODE') - ?.transition, - ) + .flatMap(getTransition) .flat()[0] const diffKeys = new Set() for (const [effect, props] of components) { diff --git a/src/codegen/props/transform.ts b/src/codegen/props/transform.ts index f285639..e8c4190 100644 --- a/src/codegen/props/transform.ts +++ b/src/codegen/props/transform.ts @@ -1,10 +1,12 @@ import { fmtPct } from '../utils/fmtPct' +import { canBeAbsolute } from './position' export function getTransformProps( node: SceneNode, ): Record | undefined { - if ('rotation' in node && node.rotation !== 0) + if ('rotation' in node && Math.abs(node.rotation) > 0.01) return { - transform: `rotate(${fmtPct(node.rotation)}deg)`, + transform: `rotate(${fmtPct(-node.rotation)}deg)`, + transformOrigin: canBeAbsolute(node) ? 'top left' : undefined, } } diff --git a/src/codegen/props/visibility.ts b/src/codegen/props/visibility.ts new file mode 100644 index 0000000..fa5f4a0 --- /dev/null +++ b/src/codegen/props/visibility.ts @@ -0,0 +1,9 @@ +export function getVisibilityProps( + node: SceneNode, +): Record | undefined { + return node.visible + ? undefined + : { + display: 'none', + } +} diff --git a/src/codegen/render/index.ts b/src/codegen/render/index.ts index f6d56cd..47c21e5 100644 --- a/src/codegen/render/index.ts +++ b/src/codegen/render/index.ts @@ -6,7 +6,7 @@ import { propsToString } from '../utils/props-to-str' export function renderNode( component: string, - props: Record, + props: Record, deps: number = 0, childrenCodes: string[], ): string { diff --git a/src/codegen/responsive/ResponsiveCodegen.ts b/src/codegen/responsive/ResponsiveCodegen.ts index e04689c..5c4d472 100644 --- a/src/codegen/responsive/ResponsiveCodegen.ts +++ b/src/codegen/responsive/ResponsiveCodegen.ts @@ -1,28 +1,16 @@ -import { getProps } from '../props' +import { Codegen } from '../Codegen' import { renderNode } from '../render' -import { getDevupComponentByNode } from '../utils/get-devup-component' +import type { NodeTree, Props } from '../types' import { - BREAKPOINT_ORDER, + BREAKPOINT_INDEX, type BreakpointKey, getBreakpointByWidth, mergePropsToResponsive, - optimizeResponsiveValue, } from './index' -type PropValue = boolean | string | number | undefined | null | object -type Props = Record - -interface NodePropsMap { - breakpoint: BreakpointKey - props: Props - children: Map - nodeType: string - nodeName: string - node: SceneNode -} - /** * Generate responsive code by merging children inside a Section. + * Uses Codegen to build NodeTree for each breakpoint, then merges them. */ export class ResponsiveCodegen { private breakpointNodes: Map = new Map() @@ -46,36 +34,6 @@ export class ResponsiveCodegen { } } - /** - * Recursively extract props and children from a node. - * Reuses getProps. - */ - private async extractNodeProps( - node: SceneNode, - breakpoint: BreakpointKey, - ): Promise { - const props = await getProps(node) - const children = new Map() - - if ('children' in node) { - for (const child of node.children) { - const childProps = await this.extractNodeProps(child, breakpoint) - const existing = children.get(child.name) || [] - existing.push(childProps) - children.set(child.name, existing) - } - } - - return { - breakpoint, - props, - children, - nodeType: node.type, - nodeName: node.name, - node, - } - } - /** * Generate responsive code. */ @@ -85,61 +43,140 @@ export class ResponsiveCodegen { } if (this.breakpointNodes.size === 1) { - // If only one breakpoint, generate normal code (reuse existing path). + // If only one breakpoint, generate normal code using Codegen. const [, node] = [...this.breakpointNodes.entries()][0] - return await this.generateNodeCode(node, 0) + const codegen = new Codegen(node) + const tree = await codegen.getTree() + return Codegen.renderTree(tree, 0) } - // Extract props per breakpoint node. - const breakpointNodeProps = new Map() + // Extract trees per breakpoint using Codegen. + const breakpointTrees = new Map() for (const [bp, node] of this.breakpointNodes) { - const nodeProps = await this.extractNodeProps(node, bp) - breakpointNodeProps.set(bp, nodeProps) + const codegen = new Codegen(node) + const tree = await codegen.getTree() + breakpointTrees.set(bp, tree) } - // Merge responsively and generate code. - return await this.generateMergedCode(breakpointNodeProps, 0) + // Merge trees and generate code. + return this.generateMergedCode(breakpointTrees, 0) + } + + /** + * Convert NodeTree children array to Map by nodeName. + */ + private treeChildrenToMap(tree: NodeTree): Map { + const result = new Map() + for (const child of tree.children) { + const existing = result.get(child.nodeName) || [] + existing.push(child) + result.set(child.nodeName, existing) + } + return result } /** - * Generate merged responsive code. - * Reuses renderNode. + * Generate merged responsive code from NodeTree objects. */ - private async generateMergedCode( - nodesByBreakpoint: Map, + private generateMergedCode( + treesByBreakpoint: Map, depth: number, - ): Promise { - // Merge props. + ): string { + const firstTree = [...treesByBreakpoint.values()][0] + + // If node is INSTANCE or COMPONENT, render as component reference + if (firstTree.isComponent) { + // For components, we might still need position props + const propsMap = new Map() + for (const [bp, tree] of treesByBreakpoint) { + const posProps: Props = {} + if (tree.props.pos) posProps.pos = tree.props.pos + if (tree.props.top) posProps.top = tree.props.top + if (tree.props.left) posProps.left = tree.props.left + if (tree.props.right) posProps.right = tree.props.right + if (tree.props.bottom) posProps.bottom = tree.props.bottom + if (tree.props.display) posProps.display = tree.props.display + propsMap.set(bp, posProps) + } + const mergedProps = mergePropsToResponsive(propsMap) + + // If component has position props, wrap in Box + if (Object.keys(mergedProps).length > 0) { + const componentCode = renderNode(firstTree.component, {}, depth + 1, []) + return renderNode('Box', mergedProps, depth, [componentCode]) + } + + return renderNode(firstTree.component, {}, depth, []) + } + + // Handle WRAPPER nodes (position wrapper for components) + if (firstTree.nodeType === 'WRAPPER') { + const propsMap = new Map() + for (const [bp, tree] of treesByBreakpoint) { + propsMap.set(bp, tree.props) + } + const mergedProps = mergePropsToResponsive(propsMap) + + // Recursively merge the inner component + const innerTrees = new Map() + for (const [bp, tree] of treesByBreakpoint) { + if (tree.children.length > 0) { + innerTrees.set(bp, tree.children[0]) + } + } + + const innerCode = + innerTrees.size > 0 + ? this.generateMergedCode(innerTrees, depth + 1) + : '' + + return renderNode('Box', mergedProps, depth, innerCode ? [innerCode] : []) + } + + // Merge props across breakpoints const propsMap = new Map() - for (const [bp, nodeProps] of nodesByBreakpoint) { - propsMap.set(bp, nodeProps.props) + for (const [bp, tree] of treesByBreakpoint) { + propsMap.set(bp, tree.props) } const mergedProps = mergePropsToResponsive(propsMap) - // Decide component type from the first node (reuse existing util). - const firstNodeProps = [...nodesByBreakpoint.values()][0] - const component = getDevupComponentByNode( - firstNodeProps.node, - firstNodeProps.props, - ) + // Handle TEXT nodes with textChildren + if (firstTree.textChildren && firstTree.textChildren.length > 0) { + // For text nodes, merge the text children + // Currently just using the first tree's text children + return renderNode( + firstTree.component, + mergedProps, + depth, + firstTree.textChildren, + ) + } - // Merge child nodes (preserve order). + // Merge children by name const childrenCodes: string[] = [] const processedChildNames = new Set() - // Base order on the first breakpoint children. - const firstBreakpointChildren = firstNodeProps.children + // Convert all trees' children to maps + const childrenMaps = new Map>() + for (const [bp, tree] of treesByBreakpoint) { + childrenMaps.set(bp, this.treeChildrenToMap(tree)) + } + + // Get all child names in order (first tree's order, then others) + const firstBreakpoint = [...treesByBreakpoint.keys()][0] + const firstChildrenMap = childrenMaps.get(firstBreakpoint) const allChildNames: string[] = [] - // Keep the first breakpoint child order. - for (const name of firstBreakpointChildren.keys()) { - allChildNames.push(name) - processedChildNames.add(name) + if (firstChildrenMap) { + for (const name of firstChildrenMap.keys()) { + allChildNames.push(name) + processedChildNames.add(name) + } } - // Add children that exist only in other breakpoints. - for (const nodeProps of nodesByBreakpoint.values()) { - for (const name of nodeProps.children.keys()) { + // Add children that exist only in other breakpoints + for (const childMap of childrenMaps.values()) { + for (const name of childMap.keys()) { if (!processedChildNames.has(name)) { allChildNames.push(name) processedChildNames.add(name) @@ -148,11 +185,11 @@ export class ResponsiveCodegen { } for (const childName of allChildNames) { - const childByBreakpoint = new Map() + const childByBreakpoint = new Map() const presentBreakpoints = new Set() - for (const [bp, nodeProps] of nodesByBreakpoint) { - const children = nodeProps.children.get(childName) + for (const [bp, childMap] of childrenMaps) { + const children = childMap.get(childName) if (children && children.length > 0) { childByBreakpoint.set(bp, children[0]) presentBreakpoints.add(bp) @@ -160,85 +197,43 @@ export class ResponsiveCodegen { } if (childByBreakpoint.size > 0) { - // Add display props when a child exists only at specific breakpoints. - if (presentBreakpoints.size < nodesByBreakpoint.size) { - const displayProps = this.getDisplayProps( - presentBreakpoints, - new Set(nodesByBreakpoint.keys()), - ) - for (const nodeProps of childByBreakpoint.values()) { - Object.assign(nodeProps.props, displayProps) - } - } - - const childCode = await this.generateMergedCode( - childByBreakpoint, - depth, + // Add display:none props when a child exists only at specific breakpoints + // Find the smallest breakpoint where child exists + const sortedPresentBreakpoints = [...presentBreakpoints].sort( + (a, b) => BREAKPOINT_INDEX[a] - BREAKPOINT_INDEX[b], ) - childrenCodes.push(childCode) - } - } - - // Reuse renderNode. - return renderNode(component, mergedProps, depth, childrenCodes) - } + const smallestPresentBp = sortedPresentBreakpoints[0] + const smallestPresentIdx = BREAKPOINT_INDEX[smallestPresentBp] - /** - * Build display props so a child shows only on present breakpoints. - */ - private getDisplayProps( - presentBreakpoints: Set, - allBreakpoints: Set, - ): Props { - // Always use 5 slots: [mobile, sm, tablet, lg, pc] - // If the child exists on the breakpoint: null (visible); otherwise 'none' (hidden). - // If the Section lacks that breakpoint entirely: null. - const displayValues: (string | null)[] = BREAKPOINT_ORDER.map((bp) => { - if (!allBreakpoints.has(bp)) return null // Section lacks this breakpoint - return presentBreakpoints.has(bp) ? null : 'none' - }) - - // If all null, return empty object. - if (displayValues.every((v) => v === null)) { - return {} - } - - // Remove trailing nulls only (keep leading nulls). - while ( - displayValues.length > 0 && - displayValues[displayValues.length - 1] === null - ) { - displayValues.pop() - } - - // Empty array => empty object. - if (displayValues.length === 0) { - return {} - } - - return { display: optimizeResponsiveValue(displayValues) } - } - - /** - * Generate code for a single node (fallback). - * Reuses existing module. - */ - private async generateNodeCode( - node: SceneNode, - depth: number, - ): Promise { - const props = await getProps(node) - const childrenCodes: string[] = [] + // Find the smallest breakpoint in the section + const sortedSectionBreakpoints = [...treesByBreakpoint.keys()].sort( + (a, b) => BREAKPOINT_INDEX[a] - BREAKPOINT_INDEX[b], + ) + const smallestSectionBp = sortedSectionBreakpoints[0] + const smallestSectionIdx = BREAKPOINT_INDEX[smallestSectionBp] + + // If child's smallest breakpoint is larger than section's smallest, + // we need to add display:none for the smaller breakpoints + if (smallestPresentIdx > smallestSectionIdx) { + // Add display:none for all breakpoints smaller than where child exists + for (const bp of treesByBreakpoint.keys()) { + if (!presentBreakpoints.has(bp)) { + const firstChildTree = [...childByBreakpoint.values()][0] + const hiddenTree: NodeTree = { + ...firstChildTree, + props: { ...firstChildTree.props, display: 'none' }, + } + childByBreakpoint.set(bp, hiddenTree) + } + } + } - if ('children' in node) { - for (const child of node.children) { - const childCode = await this.generateNodeCode(child, depth + 1) + const childCode = this.generateMergedCode(childByBreakpoint, depth) childrenCodes.push(childCode) } } - const component = getDevupComponentByNode(node, props) - return renderNode(component, props, depth, childrenCodes) + return renderNode(firstTree.component, mergedProps, depth, childrenCodes) } /** diff --git a/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts index b7bf356..b23d25f 100644 --- a/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts +++ b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' -import { BREAKPOINT_ORDER, type BreakpointKey } from '../index' +import type { NodeTree } from '../../types' -const getPropsMock = mock(async (node: SceneNode) => ({ id: node.name })) const renderNodeMock = mock( ( component: string, @@ -11,22 +10,38 @@ const renderNodeMock = mock( ) => `render:${component}:depth=${depth}:${JSON.stringify(props)}|${children.join(';')}`, ) -const getDevupComponentByNodeMock = mock(() => 'Box') + +// Mock Codegen class +const mockGetTree = mock( + async (): Promise => ({ + component: 'Box', + props: { id: 'test' }, + children: [], + nodeType: 'FRAME', + nodeName: 'test', + }), +) + +const mockRenderTree = mock((tree: NodeTree, depth: number) => + renderNodeMock(tree.component, tree.props, depth, []), +) + +const MockCodegen = class { + getTree = mockGetTree + static renderTree = mockRenderTree +} describe('ResponsiveCodegen', () => { let ResponsiveCodegen: typeof import('../ResponsiveCodegen').ResponsiveCodegen beforeEach(async () => { - mock.module('../../props', () => ({ getProps: getPropsMock })) mock.module('../../render', () => ({ renderNode: renderNodeMock })) - mock.module('../../utils/get-devup-component', () => ({ - getDevupComponentByNode: getDevupComponentByNodeMock, - })) + mock.module('../../Codegen', () => ({ Codegen: MockCodegen })) ;({ ResponsiveCodegen } = await import('../ResponsiveCodegen')) - getPropsMock.mockClear() renderNodeMock.mockClear() - getDevupComponentByNodeMock.mockClear() + mockGetTree.mockClear() + mockRenderTree.mockClear() }) afterEach(() => { @@ -58,7 +73,7 @@ describe('ResponsiveCodegen', () => { expect(result).toBe('// No responsive variants found in section') }) - it('falls back to single breakpoint generation', async () => { + it('falls back to single breakpoint generation using Codegen', async () => { const child = makeNode('mobile', 320, [makeNode('leaf', undefined, [])]) const section = { type: 'SECTION', @@ -66,26 +81,55 @@ describe('ResponsiveCodegen', () => { } as unknown as SectionNode const generator = new ResponsiveCodegen(section) - const nodeCode = await ( - generator as unknown as { - generateNodeCode: (node: SceneNode, depth: number) => Promise - } - ).generateNodeCode(child, 0) - expect(renderNodeMock).toHaveBeenCalled() - const result = await generator.generateResponsiveCode() + expect(mockGetTree).toHaveBeenCalled() + expect(mockRenderTree).toHaveBeenCalled() expect(result.startsWith('render:Box')).toBeTrue() - expect(nodeCode.startsWith('render:Box')).toBeTrue() }) it('merges breakpoints and adds display for missing child variants', async () => { - const onlyMobile = makeNode('OnlyMobile') - const sharedMobile = makeNode('Shared') - const sharedTablet = makeNode('Shared') + const onlyMobileChild: NodeTree = { + component: 'Box', + props: { id: 'OnlyMobile' }, + children: [], + nodeType: 'FRAME', + nodeName: 'OnlyMobile', + } + const sharedChild: NodeTree = { + component: 'Box', + props: { id: 'Shared' }, + children: [], + nodeType: 'FRAME', + nodeName: 'Shared', + } - const mobileRoot = makeNode('RootMobile', 320, [onlyMobile, sharedMobile]) - const tabletRoot = makeNode('RootTablet', 1000, [sharedTablet]) + // Mock different trees for different breakpoints + let callCount = 0 + mockGetTree.mockImplementation(async () => { + callCount++ + if (callCount === 1) { + // Mobile tree + return { + component: 'Box', + props: { id: 'RootMobile' }, + children: [onlyMobileChild, sharedChild], + nodeType: 'FRAME', + nodeName: 'RootMobile', + } + } + // Tablet tree + return { + component: 'Box', + props: { id: 'RootTablet' }, + children: [{ ...sharedChild }], + nodeType: 'FRAME', + nodeName: 'RootTablet', + } + }) + + const mobileRoot = makeNode('RootMobile', 320, []) + const tabletRoot = makeNode('RootTablet', 1000, []) const section = { type: 'SECTION', children: [mobileRoot, tabletRoot], @@ -94,46 +138,23 @@ describe('ResponsiveCodegen', () => { const generator = new ResponsiveCodegen(section) const result = await generator.generateResponsiveCode() - expect(getPropsMock).toHaveBeenCalled() + expect(mockGetTree).toHaveBeenCalledTimes(2) expect(renderNodeMock.mock.calls.length).toBeGreaterThan(0) expect(result.startsWith('render:Box')).toBeTrue() }) - it('returns empty display when all breakpoints present', async () => { + it('uses Codegen.renderTree for single breakpoint', async () => { + const child = makeNode('child', 320) const section = { type: 'SECTION', - children: [makeNode('RootMobile', 320)], + children: [child], } as unknown as SectionNode - const generator = new ResponsiveCodegen(section) - const displayProps = ( - generator as unknown as { - getDisplayProps: ( - present: Set, - all: Set, - ) => Record - } - ).getDisplayProps( - new Set(BREAKPOINT_ORDER), - new Set(BREAKPOINT_ORDER), - ) - expect(displayProps).toEqual({}) - }) - it('recursively generates node code', async () => { - const child = makeNode('child') - const parent = makeNode('parent', undefined, [child]) - const section = { - type: 'SECTION', - children: [parent], - } as unknown as SectionNode const generator = new ResponsiveCodegen(section) - const nodeCode = await ( - generator as unknown as { - generateNodeCode: (node: SceneNode, depth: number) => Promise - } - ).generateNodeCode(parent, 0) - expect(nodeCode.startsWith('render:Box')).toBeTrue() - expect(renderNodeMock).toHaveBeenCalled() + await generator.generateResponsiveCode() + + expect(mockGetTree).toHaveBeenCalled() + expect(mockRenderTree).toHaveBeenCalled() }) it('static helpers detect section and parent section', () => { diff --git a/src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts b/src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts index 15803ef..aaaffc7 100644 --- a/src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts +++ b/src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'bun:test' -import { type BreakpointKey, mergePropsToResponsive } from '../index' +import { + type BreakpointKey, + mergePropsToResponsive, + type Props, +} from '../index' describe('mergePropsToResponsive', () => { const cases: { @@ -95,7 +99,7 @@ describe('mergePropsToResponsive', () => { ['pc', { w: undefined }], ]), expected: { - w: [null, null, '10px'], + w: [null, null, '10px', 'initial'], }, }, { @@ -108,7 +112,7 @@ describe('mergePropsToResponsive', () => { ['pc', { w: undefined }], ]), expected: { - w: '10px', + w: ['10px', null, null, 'initial'], }, }, { @@ -144,11 +148,88 @@ describe('mergePropsToResponsive', () => { pos: [null, null, 'absolute', null, 'initial'], }, }, + { + name: 'mobile only value with tablet and pc breakpoints needs initial at tablet position', + input: new Map>([ + ['mobile', { textAlign: 'center' }], + ['tablet', { textAlign: undefined }], + ['pc', { textAlign: undefined }], + ]), + expected: { + textAlign: ['center', null, 'initial'], + }, + }, + { + name: 'display none at mobile, flex at tablet and pc should produce responsive array', + input: new Map>([ + ['mobile', { display: 'none' }], + ['tablet', { display: 'flex' }], + ['pc', { display: 'flex' }], + ]), + expected: { + display: ['none', null, 'flex'], + }, + }, + { + name: 'flexDir column at mobile, row at tablet and pc should produce responsive array', + input: new Map>([ + ['mobile', { flexDir: 'column' }], + ['tablet', { flexDir: 'row' }], + ['pc', { flexDir: 'row' }], + ]), + expected: { + flexDir: ['column', null, 'row'], + }, + }, + { + name: 'alignItems with default value at first should become null', + input: new Map>([ + ['mobile', { alignItems: 'flex-start' }], + ['tablet', { alignItems: 'center' }], + ['pc', { alignItems: 'center' }], + ]), + expected: { + alignItems: [null, null, 'center'], + }, + }, + { + name: 'justifyContent with default value at first should become null', + input: new Map>([ + ['mobile', { justifyContent: 'flex-start' }], + ['tablet', { justifyContent: 'center' }], + ['pc', { justifyContent: 'center' }], + ]), + expected: { + justifyContent: [null, null, 'center'], + }, + }, + { + name: 'flexDir with default value (row) at first should become null', + input: new Map>([ + ['mobile', { flexDir: 'row' }], + ['tablet', { flexDir: 'column' }], + ['pc', { flexDir: 'column' }], + ]), + expected: { + flexDir: [null, null, 'column'], + }, + }, + { + name: 'all default values should be omitted (empty result)', + input: new Map>([ + ['mobile', { alignItems: 'flex-start', justifyContent: 'flex-start' }], + ['tablet', { alignItems: 'flex-start', justifyContent: 'flex-start' }], + ['pc', { alignItems: 'flex-start', justifyContent: 'flex-start' }], + ]), + expected: {}, + }, ] cases.forEach(({ name, input, expected }) => { it(name, () => { - expect(mergePropsToResponsive(input)).toEqual(expected) + expect( + mergePropsToResponsive(input as unknown as Map), + ).toEqual(expected as unknown as Props) }) }) }) diff --git a/src/codegen/responsive/index.ts b/src/codegen/responsive/index.ts index 522b676..e1df774 100644 --- a/src/codegen/responsive/index.ts +++ b/src/codegen/responsive/index.ts @@ -1,3 +1,5 @@ +import { isDefaultProp } from '../utils/is-default-prop' + // Breakpoint thresholds (by width) // Array indices: mobile=0, sm=1, tablet=2, lg=3, pc=4 // Always 5 slots @@ -62,8 +64,16 @@ export function groupChildrenByBreakpoint( } type PropValue = boolean | string | number | undefined | null | object -type Props = Record -const SPECIAL_PROPS_WITH_INITIAL = new Set(['display', 'position', 'pos']) +export type Props = Record +const SPECIAL_PROPS_WITH_INITIAL = new Set([ + 'display', + 'position', + 'pos', + 'transform', + 'w', + 'h', + 'textAlign', +]) /** * Compare two prop values for equality. @@ -85,6 +95,7 @@ function isEqual(a: PropValue, b: PropValue): boolean { * 1. If only index 0 has a value and the rest are null, return single value. * 2. Consecutive identical values keep the first, later ones become null. * 3. Remove trailing nulls only. + * 4. If the first value is default for that prop, replace with null. * * Examples: * ["100px", null, null] -> "100px" (only first has value) @@ -93,9 +104,11 @@ function isEqual(a: PropValue, b: PropValue): boolean { * [null, null, "none"] -> [null, null, "none"] (keeps leading nulls) * [null, null, "none", null, null] -> [null, null, "none"] (trim trailing null) * ["100px", "200px", "200px"] -> ["100px", "200px"] (trailing equal treated as trailing null) + * ["flex-start", null, "center"] -> [null, null, "center"] (first value is default for alignItems) */ export function optimizeResponsiveValue( arr: (PropValue | null)[], + key?: string, ): PropValue | (PropValue | null)[] { const nonNullValues = arr.filter((v) => v !== null) if (nonNullValues.length === 0) return null @@ -115,11 +128,21 @@ export function optimizeResponsiveValue( } } + // If the first value is default for that prop, replace with null. + if (key && optimized[0] !== null && isDefaultProp(key, optimized[0])) { + optimized[0] = null + } + // Remove trailing nulls. while (optimized.length > 0 && optimized[optimized.length - 1] === null) { optimized.pop() } + // If empty array after optimization, return null. + if (optimized.length === 0) { + return null + } + // If only index 0 has value, return single value. if (optimized.length === 1 && optimized[0] !== null) { return optimized[0] @@ -133,8 +156,8 @@ export function optimizeResponsiveValue( * Always 5 slots: [mobile, sm, tablet, lg, pc]; trailing nulls trimmed. */ export function mergePropsToResponsive( - breakpointProps: Map, -): Props { + breakpointProps: Map>, +): Record { const result: Props = {} // If only one breakpoint, return props as-is. @@ -160,26 +183,49 @@ export function mergePropsToResponsive( return value ?? null }) - // For display/position family, fill last slot with 'initial' if empty after a change. + // For display/position family, add 'initial' at the first EXISTING breakpoint + // where the value changes to null (after a non-null value). + // This ensures proper reset for larger breakpoints. + let valuesToOptimize = values if (SPECIAL_PROPS_WITH_INITIAL.has(key)) { - const lastNonNull = (() => { - for (let i = values.length - 1; i >= 0; i--) { - if (values[i] !== null) return i + // Find the last non-null value position in original values + let lastNonNullIdx = -1 + for (let i = values.length - 1; i >= 0; i--) { + if (values[i] !== null) { + lastNonNullIdx = i + break + } + } + + // Only need 'initial' if the last non-null is not at the end (pc) + if (lastNonNullIdx >= 0 && lastNonNullIdx < BREAKPOINT_ORDER.length - 1) { + // Find the first EXISTING breakpoint after the last non-null value + // that has a null/undefined value (where we need to reset) + let initialInsertIdx = -1 + for (let i = lastNonNullIdx + 1; i < BREAKPOINT_ORDER.length; i++) { + const bp = BREAKPOINT_ORDER[i] + // Check if this breakpoint exists in input + if (breakpointProps.has(bp)) { + initialInsertIdx = i + break + } + } + + // Only add 'initial' if we found a position to insert + if (initialInsertIdx >= 0) { + // Work with original values array to preserve null positions + const newArr = [...values] + newArr[initialInsertIdx] = 'initial' + // Trim values after initialInsertIdx (they're not needed) + newArr.length = initialInsertIdx + 1 + valuesToOptimize = newArr } - return -1 - })() - const lastIndex = values.length - 1 - if ( - lastNonNull >= 0 && - lastNonNull < lastIndex && - values[lastIndex] === null - ) { - values[lastIndex] = 'initial' } } // Optimize: single when all same, otherwise array. - const optimized = optimizeResponsiveValue(values) + const optimized = optimizeResponsiveValue(valuesToOptimize, key) + if (optimized !== null) { result[key] = optimized } diff --git a/src/codegen/types.ts b/src/codegen/types.ts new file mode 100644 index 0000000..4db4e3c --- /dev/null +++ b/src/codegen/types.ts @@ -0,0 +1,17 @@ +export type Props = Record + +export interface NodeTree { + component: string // 'Flex', 'Box', 'Text', 'Image', or component name + props: Props + children: NodeTree[] + nodeType: string // Figma node type: 'FRAME', 'TEXT', 'INSTANCE', etc. + nodeName: string // Figma node name + isComponent?: boolean // true if this is a component reference (INSTANCE) + textChildren?: string[] // raw text content for TEXT nodes +} + +export interface ComponentTree { + name: string + tree: NodeTree + variants: Record +} diff --git a/src/codegen/utils/__tests__/paint-to-css.test.ts b/src/codegen/utils/__tests__/paint-to-css.test.ts index 5593607..8e781b8 100644 --- a/src/codegen/utils/__tests__/paint-to-css.test.ts +++ b/src/codegen/utils/__tests__/paint-to-css.test.ts @@ -571,4 +571,270 @@ describe('paintToCSS', () => { expect(res).not.toContain('$') expect(res).not.toContain('color-mix') }) + + test('converts radial gradient with color token and full opacity (line 223)', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + variables: { + getVariableByIdAsync: mock(() => + Promise.resolve({ name: 'radial-color-full' }), + ), + }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_RADIAL', + visible: true, + opacity: 1, + gradientTransform: [ + [1, 0, 0.5], + [0, 1, 0.5], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + boundVariables: { color: { id: 'var-radial' } }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('radial-gradient') + expect(res).toContain('$radialColorFull') + expect(res).not.toContain('color-mix') + }) + + test('returns null when linear gradient is not visible', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_LINEAR', + visible: false, + opacity: 1, + gradientTransform: [ + [1, 0, 0], + [0, 1, 0], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toBeNull() + }) + + test('converts image paint with FILL scaleMode', async () => { + const res = await paintToCSS( + { + type: 'IMAGE', + visible: true, + opacity: 1, + scaleMode: 'FILL', + } as unknown as ImagePaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toBe('url(/icons/image.png) center/cover no-repeat') + }) + + test('converts image paint with FIT scaleMode', async () => { + const res = await paintToCSS( + { + type: 'IMAGE', + visible: true, + opacity: 1, + scaleMode: 'FIT', + } as unknown as ImagePaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toBe('url(/icons/image.png) center/contain no-repeat') + }) + + test('converts image paint with CROP scaleMode', async () => { + const res = await paintToCSS( + { + type: 'IMAGE', + visible: true, + opacity: 1, + scaleMode: 'CROP', + } as unknown as ImagePaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toBe('url(/icons/image.png) center/cover no-repeat') + }) + + test('converts solid paint when last is false (transparent)', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + variables: { + getVariableByIdAsync: mock(() => Promise.resolve(null)), + }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'SOLID', + visible: true, + opacity: 0, + color: { r: 1, g: 0, b: 0 }, + } as unknown as SolidPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toBe('transparent') + }) + + test('converts pattern with CENTER alignment', async () => { + ;(globalThis as { figma?: unknown }).figma = { + getNodeByIdAsync: mock(() => + Promise.resolve({ name: 'patternNode' } as unknown as SceneNode), + ), + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'PATTERN', + visible: true, + opacity: 1, + sourceNodeId: '1', + spacing: { x: 0.5, y: 0.5 }, + horizontalAlignment: 'CENTER', + } as unknown as PatternPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('url(/icons/patternNode.png)') + expect(res).toContain('center') + expect(res).toContain('repeat') + }) + + test('converts gradient with token and finalAlpha = 1 (line 222)', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + variables: { + getVariableByIdAsync: mock(() => + Promise.resolve({ name: 'primary-color' }), + ), + }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_LINEAR', + visible: true, + opacity: 1, + gradientTransform: [ + [1, 0, 0], + [0, 1, 0], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + boundVariables: { color: { id: 'var-1' } }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('$primaryColor') + expect(res).not.toContain('color-mix') + }) + + test('converts pattern with START alignment and zero spacing (line 288)', async () => { + ;(globalThis as { figma?: unknown }).figma = { + getNodeByIdAsync: mock(() => + Promise.resolve({ name: 'patternStart' } as unknown as SceneNode), + ), + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'PATTERN', + visible: true, + opacity: 1, + sourceNodeId: '1', + spacing: { x: 0, y: 0 }, + horizontalAlignment: 'START', + } as unknown as PatternPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toBe('url(/icons/patternStart.png) repeat') + }) + + test('converts solid to linear gradient when not last and opacity > 0 (line 295-296)', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + variables: { + getVariableByIdAsync: mock(() => Promise.resolve(null)), + }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'SOLID', + visible: true, + opacity: 0.5, + color: { r: 1, g: 0, b: 0 }, + } as unknown as SolidPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('linear-gradient') + }) + + test('returns transparent when image is not visible', async () => { + const res = await paintToCSS( + { + type: 'IMAGE', + visible: false, + opacity: 1, + scaleMode: 'FILL', + } as unknown as ImagePaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toBe('transparent') + }) + + test('returns transparent when image opacity is 0', async () => { + const res = await paintToCSS( + { + type: 'IMAGE', + visible: true, + opacity: 0, + scaleMode: 'FILL', + } as unknown as ImagePaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toBe('transparent') + }) }) diff --git a/src/codegen/utils/__tests__/props-to-str.test.ts b/src/codegen/utils/__tests__/props-to-str.test.ts index ad95557..264ea93 100644 --- a/src/codegen/utils/__tests__/props-to-str.test.ts +++ b/src/codegen/utils/__tests__/props-to-str.test.ts @@ -34,4 +34,42 @@ describe('propsToString', () => { test('handles empty props', () => { expect(propsToString({})).toBe('') }) + + test('handles animationName with keyframes function', () => { + const res = propsToString({ + animationName: 'keyframes({"0%":{"opacity":0},"100%":{"opacity":1}})', + }) + expect(res).toContain('animationName={keyframes(') + expect(res).toContain('"0%"') + expect(res).toContain('"100%"') + }) + + test('handles animationName with invalid keyframes JSON', () => { + const res = propsToString({ + animationName: 'keyframes(invalid-json)', + }) + expect(res).toBe('animationName={keyframes(invalid-json)}') + }) + + test('handles animationName starting with keyframes but no parentheses match', () => { + const res = propsToString({ + animationName: 'keyframes(', + }) + expect(res).toBe('animationName={keyframes(}') + }) + + test('handles animationName without keyframes prefix', () => { + const res = propsToString({ + animationName: 'fadeIn', + }) + expect(res).toBe('animationName="fadeIn"') + }) + + test('handles object values', () => { + const res = propsToString({ + style: { color: 'red', fontSize: 16 }, + }) + expect(res).toContain('style={') + expect(res).toContain('"color": "red"') + }) }) diff --git a/src/codegen/utils/check-asset-node.ts b/src/codegen/utils/check-asset-node.ts index 778d568..9f319f8 100644 --- a/src/codegen/utils/check-asset-node.ts +++ b/src/codegen/utils/check-asset-node.ts @@ -1,5 +1,30 @@ +function hasSmartAnimateReaction(node: BaseNode | null): boolean { + if (!node || node.type === 'DOCUMENT' || node.type === 'PAGE') return false + if ( + 'reactions' in node && + node.reactions?.some((reaction) => + reaction.actions?.some( + (action) => + action.type === 'NODE' && action.transition?.type === 'SMART_ANIMATE', + ), + ) + ) + return true + return false +} + +function isAnimationTarget(node: SceneNode): boolean { + // Check if node itself has SMART_ANIMATE + if (hasSmartAnimateReaction(node)) return true + // Check if parent has SMART_ANIMATE (node is animation target as child) + if (node.parent && hasSmartAnimateReaction(node.parent)) return true + return false +} + export function checkAssetNode(node: SceneNode): 'svg' | 'png' | null { - if (node.type === 'TEXT') return null + if (node.type === 'TEXT' || node.type === 'COMPONENT_SET') return null + // if node is an animation target (has keyframes), it should not be treated as an asset + if (isAnimationTarget(node)) return null // vector must be svg if (['VECTOR', 'STAR', 'POLYGON'].includes(node.type)) return 'svg' // ellipse with inner radius must be svg @@ -25,7 +50,9 @@ export function checkAssetNode(node: SceneNode): 'svg' | 'png' | null { fill.type === 'IMAGE' && fill.scaleMode !== 'TILE', ) - ? 'png' + ? node.fills.length === 1 + ? 'png' + : null : node.fills.every( (fill: Paint) => fill.visible && fill.type === 'SOLID', ) @@ -50,7 +77,16 @@ export function checkAssetNode(node: SceneNode): 'svg' | 'png' | null { return null return checkAssetNode(children[0]) } - return children.every((child) => child.visible && checkAssetNode(child)) + const fillterdChildren = children.filter((child) => child.visible) + + // return children.every((child) => child.visible && checkAssetNode(child)) + // ? 'svg' + // : null + return fillterdChildren.every((child) => { + const result = checkAssetNode(child) + if (result === null) return false + return result === 'svg' + }) ? 'svg' : null } diff --git a/src/codegen/utils/get-page-node.ts b/src/codegen/utils/get-page-node.ts index 89ae8f2..ab6c5fe 100644 --- a/src/codegen/utils/get-page-node.ts +++ b/src/codegen/utils/get-page-node.ts @@ -1,6 +1,7 @@ export function getPageNode(node: BaseNode & ChildrenMixin) { if (!node.parent) return null switch (node.parent.type) { + case 'COMPONENT_SET': case 'SECTION': case 'PAGE': if (['SECTION', 'PAGE'].includes(node.type)) return null diff --git a/src/codegen/utils/is-default-prop.ts b/src/codegen/utils/is-default-prop.ts index 3f1bf50..b7e78b3 100644 --- a/src/codegen/utils/is-default-prop.ts +++ b/src/codegen/utils/is-default-prop.ts @@ -24,6 +24,8 @@ const DEFAULT_PROPS_MAP = { gap: /\b0(px)?\b/, } as const export function isDefaultProp(prop: string, value: unknown) { + // Don't filter arrays (responsive values) + if (Array.isArray(value)) return false return ( prop in DEFAULT_PROPS_MAP && DEFAULT_PROPS_MAP[prop as keyof typeof DEFAULT_PROPS_MAP].test( diff --git a/src/codegen/utils/is-page-root.ts b/src/codegen/utils/is-page-root.ts new file mode 100644 index 0000000..2e5b76f --- /dev/null +++ b/src/codegen/utils/is-page-root.ts @@ -0,0 +1,6 @@ +import { getPageNode } from './get-page-node' + +export function isPageRoot(node: BaseNode | null | undefined) { + if (!node) return false + return getPageNode(node as BaseNode & ChildrenMixin) === node +} diff --git a/src/codegen/utils/solid-to-string.ts b/src/codegen/utils/solid-to-string.ts index 59b6de5..912576a 100644 --- a/src/codegen/utils/solid-to-string.ts +++ b/src/codegen/utils/solid-to-string.ts @@ -10,5 +10,12 @@ export async function solidToString(solid: SolidPaint) { if (variable?.name) return `$${toCamel(variable.name)}` } if (solid.opacity === 0) return 'transparent' - return optimizeHex(rgbaToHex(figma.util.rgba(solid.color))) + return optimizeHex( + rgbaToHex( + figma.util.rgba({ + ...solid.color, + a: solid.opacity ?? 1, + }), + ), + ) } diff --git a/src/commands/devup/utils/__tests__/upload-devup-xlsx.test.ts b/src/commands/devup/utils/__tests__/upload-devup-xlsx.test.ts index b0bc4f9..d785967 100644 --- a/src/commands/devup/utils/__tests__/upload-devup-xlsx.test.ts +++ b/src/commands/devup/utils/__tests__/upload-devup-xlsx.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import type { Devup } from '../../types' import { uploadDevupXlsx } from '../upload-devup-xlsx' describe('uploadDevupXlsx', () => { @@ -83,6 +84,6 @@ describe('uploadDevupXlsx', () => { } const result = await promise - expect(result).toEqual(testData) + expect(result).toEqual(testData as unknown as Devup) }) })