From a88010a251dc80b2971bfbffb5c29976cc740697 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 22 Dec 2025 17:21:13 +0900 Subject: [PATCH] Add keyframes --- biome.json | 14 +- src/codegen/props/__tests__/reaction.test.ts | 687 +++++++++++++++++++ src/codegen/props/index.ts | 2 + src/codegen/props/reaction.ts | 663 ++++++++++++++++++ src/codegen/utils/add-px.ts | 12 +- src/codegen/utils/fmtPct.ts | 14 +- src/codegen/utils/props-to-str.ts | 20 + 7 files changed, 1403 insertions(+), 9 deletions(-) create mode 100644 src/codegen/props/__tests__/reaction.test.ts create mode 100644 src/codegen/props/reaction.ts diff --git a/biome.json b/biome.json index b68f99a..2c04128 100644 --- a/biome.json +++ b/biome.json @@ -21,5 +21,17 @@ "indentStyle": "space", "indentWidth": 2 } - } + }, + "overrides": [ + { + "includes": ["**/__tests__/**", "**/*.test.ts", "**/*.test.tsx"], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + } + } + } + } + ] } diff --git a/src/codegen/props/__tests__/reaction.test.ts b/src/codegen/props/__tests__/reaction.test.ts new file mode 100644 index 0000000..3837cac --- /dev/null +++ b/src/codegen/props/__tests__/reaction.test.ts @@ -0,0 +1,687 @@ +import { describe, expect, it, vi } from 'vitest' +import { getReactionProps } from '../reaction' + +// Mock figma global +const mockGetNodeByIdAsync = vi.fn() +;(global as any).figma = { + getNodeByIdAsync: mockGetNodeByIdAsync, +} + +describe('getReactionProps', () => { + it('should return empty object when node has no reactions', async () => { + const node = { + type: 'FRAME', + } as any + + const result = await getReactionProps(node) + expect(result).toEqual({}) + }) + + it('should return empty object when reactions array is empty', async () => { + const node = { + type: 'FRAME', + reactions: [], + } as any + + const result = await getReactionProps(node) + expect(result).toEqual({}) + }) + + it('should generate animation props for SMART_ANIMATE transition', async () => { + const fromNode = { + id: 'fromNode', + type: 'FRAME', + x: 0, + y: 0, + width: 100, + height: 100, + opacity: 1, + rotation: 0, + fills: [ + { + type: 'SOLID', + color: { r: 1, g: 0, b: 0 }, + opacity: 1, + }, + ], + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: '455:2021', + navigation: 'CHANGE_TO', + transition: { + type: 'SMART_ANIMATE', + easing: { + type: 'LINEAR', + }, + duration: 1.2, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0.5, + }, + }, + ], + } as any + + const toNode = { + id: '455:2021', + type: 'FRAME', + x: 100, + y: 50, + width: 200, + height: 150, + opacity: 0.5, + rotation: 45, + fills: [ + { + type: 'SOLID', + color: { r: 0, g: 1, b: 0 }, + opacity: 1, + }, + ], + } as any + + mockGetNodeByIdAsync.mockResolvedValue(toNode) + + const result = await getReactionProps(fromNode) + + expect(result).toHaveProperty('animationName') + expect(result).toHaveProperty('animationDuration', '1.2s') + expect(result).toHaveProperty('animationDelay', '0.5s') + expect(result).toHaveProperty('animationTimingFunction', 'linear') + expect(result).toHaveProperty('animationFillMode', 'forwards') + + // Check if animationName contains keyframes function call + expect(result.animationName).toContain('keyframes(') + expect(result.animationName).toContain('100%') + }) + + it('should handle opacity change', async () => { + const fromNode = { + id: 'fromNode', + type: 'FRAME', + opacity: 1, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: '123', + transition: { + type: 'SMART_ANIMATE', + duration: 0.3, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const toNode = { + id: '123', + type: 'FRAME', + opacity: 0.5, + } as any + + mockGetNodeByIdAsync.mockResolvedValue(toNode) + + const result = await getReactionProps(fromNode) + + expect(result.animationName).toBeDefined() + expect(result.animationName).toContain('opacity') + expect(result.animationName).toContain('0.5') + }) + + it('should handle position change', async () => { + const fromNode = { + id: 'fromNode', + type: 'FRAME', + x: 0, + y: 0, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: '123', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const toNode = { + id: '123', + type: 'FRAME', + x: 100, + y: 50, + } as any + + mockGetNodeByIdAsync.mockResolvedValue(toNode) + + const result = await getReactionProps(fromNode) + + expect(result.animationName).toBeDefined() + expect(result.animationName).toContain('transform') + expect(result.animationName).toContain('translate') + }) + + it('should handle background color change', async () => { + const fromNode = { + id: 'fromNode', + type: 'FRAME', + fills: [ + { + type: 'SOLID', + color: { r: 1, g: 0, b: 0 }, + opacity: 1, + }, + ], + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: '123', + transition: { + type: 'SMART_ANIMATE', + duration: 0.3, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const toNode = { + id: '123', + type: 'FRAME', + fills: [ + { + type: 'SOLID', + color: { r: 0, g: 1, b: 0 }, + opacity: 1, + }, + ], + } as any + + mockGetNodeByIdAsync.mockResolvedValue(toNode) + + const result = await getReactionProps(fromNode) + + expect(result.animationName).toBeDefined() + expect(result.animationName).toContain('bg') + expect(result.animationName).toContain('rgb(0, 255, 0)') + }) + + it('should return empty object when no changes detected', async () => { + const fromNode = { + id: 'fromNode', + type: 'FRAME', + x: 0, + y: 0, + opacity: 1, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: '123', + transition: { + type: 'SMART_ANIMATE', + duration: 0.3, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const toNode = { + id: '123', + type: 'FRAME', + x: 0, + y: 0, + opacity: 1, + } as any + + mockGetNodeByIdAsync.mockResolvedValue(toNode) + + const result = await getReactionProps(fromNode) + + expect(result).toEqual({}) + }) + + it('should handle different easing functions', async () => { + const testEasing = async (easingType: string, expected: string) => { + const fromNode = { + id: 'fromNode', + type: 'FRAME', + opacity: 1, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: '123', + transition: { + type: 'SMART_ANIMATE', + easing: { type: easingType }, + duration: 0.3, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const toNode = { + id: '123', + type: 'FRAME', + opacity: 0.5, + } as any + + mockGetNodeByIdAsync.mockResolvedValue(toNode) + + const result = await getReactionProps(fromNode) + expect(result.animationTimingFunction).toBe(expected) + } + + await testEasing('LINEAR', 'linear') + await testEasing('EASE_IN', 'ease-in') + await testEasing('EASE_OUT', 'ease-out') + await testEasing('EASE_IN_AND_OUT', 'ease-in-out') + }) + + it('should handle animation chains with multiple nodes', 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: 1, + 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: 100, + y: 100, + opacity: 0.5, + } 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('1s') // 0.5s + 0.5s + expect(result.animationName).toContain('50%') // intermediate keyframe + }) + + it('should prevent circular references', 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: 'node1', // circular reference back to 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 === 'node1') return node1 + return null + }) + + const result = await getReactionProps(node1) + + // Should stop at node2 and not loop back to node1 + expect(result.animationName).toBeDefined() + expect(result.animationDuration).toBe('0.5s') // Only one transition + }) + + it('should prevent infinite loops with self-referencing nodes', 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: 'node2', // self-reference + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + mockGetNodeByIdAsync.mockResolvedValue(node2) + + const result = await getReactionProps(node1) + + // Should handle self-reference gracefully + expect(result.animationName).toBeDefined() + expect(result.animationDuration).toBe('0.5s') + }) + + it('should match children by name and generate individual child animations', async () => { + const buttonChild = { + id: 'child1', + name: 'Button', + type: 'FRAME', + x: 0, + y: 0, + opacity: 1, + fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: 1 }], + parent: { id: 'frame1' }, + } as any + + const textChild = { + id: 'child2', + name: 'Text', + type: 'TEXT', + x: 50, + y: 50, + opacity: 1, + parent: { id: 'frame1' }, + } as any + + const frame1 = { + id: 'frame1', + type: 'FRAME', + name: 'Frame 1', + children: [buttonChild, textChild], + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'frame2', + transition: { + type: 'SMART_ANIMATE', + duration: 0.5, + }, + }, + ], + trigger: { + type: 'AFTER_TIMEOUT', + timeout: 0, + }, + }, + ], + } as any + + const frame2 = { + id: 'frame2', + type: 'FRAME', + name: 'Frame 2', + children: [ + { + id: 'child1-new', + name: 'Button', + type: 'FRAME', + x: 100, + y: 0, + opacity: 0.5, + fills: [{ type: 'SOLID', color: { r: 0, g: 1, b: 0 }, opacity: 1 }], + }, + { + id: 'child2-new', + name: 'Text', + type: 'TEXT', + x: 150, + y: 100, + opacity: 0.8, + }, + ], + } as any + + mockGetNodeByIdAsync.mockResolvedValue(frame2) + + // Parent should return empty object and populate cache + const parentResult = await getReactionProps(frame1) + expect(parentResult).toEqual({}) + + // Button child should get its animation from cache + const buttonResult = await getReactionProps(buttonChild) + expect(buttonResult.animationName).toBeDefined() + expect(buttonResult.animationDuration).toBe('0.5s') + expect(buttonResult.animationDelay).toBeUndefined() // No delay when timeout is 0 + expect(buttonResult.animationTimingFunction).toBe('linear') + expect(buttonResult.animationFillMode).toBe('forwards') + + const buttonAnimation = buttonResult.animationName as string + expect(buttonAnimation).toContain('keyframes(') + 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 + + // Text child should get its animation from cache + const textResult = await getReactionProps(textChild) + expect(textResult.animationName).toBeDefined() + expect(textResult.animationDuration).toBe('0.5s') + + const textAnimation = textResult.animationName as string + expect(textAnimation).toContain('translate(100px, 50px)') // Position change + expect(textAnimation).toContain('0.8') // Opacity change + }) + + it('should detect loop animations and add infinite iteration count', 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.5, + reactions: [ + { + actions: [ + { + type: 'NODE', + destinationId: 'node1', // loops back to node1 + 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.animationDuration).toBe('0.5s') + expect(result.animationIterationCount).toBe('infinite') + }) +}) diff --git a/src/codegen/props/index.ts b/src/codegen/props/index.ts index b31aad2..82eeee3 100644 --- a/src/codegen/props/index.ts +++ b/src/codegen/props/index.ts @@ -11,6 +11,7 @@ import { getObjectFitProps } from './object-fit' import { getOverflowProps } from './overflow' import { getPaddingProps } from './padding' import { getPositionProps } from './position' +import { getReactionProps } from './reaction' import { getTextAlignProps } from './text-align' import { getTextShadowProps } from './text-shadow' import { getTextStrokeProps } from './text-stroke' @@ -41,6 +42,7 @@ export async function getProps( ...getOverflowProps(node), ...(await getTextStrokeProps(node)), ...(await getTextShadowProps(node)), + ...(await getReactionProps(node)), } } diff --git a/src/codegen/props/reaction.ts b/src/codegen/props/reaction.ts new file mode 100644 index 0000000..35dbb03 --- /dev/null +++ b/src/codegen/props/reaction.ts @@ -0,0 +1,663 @@ +import { fmtPct } from '../utils/fmtPct' + +interface KeyframeData { + [percentage: string]: Record +} + +interface AnimationStep { + node: SceneNode + duration: number + easing: { type: string } | undefined + delay: number +} + +interface AnimationChainResult { + chain: AnimationStep[] + isLoop: boolean +} + +// Store animation data for children nodes by parent node ID +const childAnimationCache = new Map< + string, + Map> +>() + +// Format duration/delay values (up to 3 decimal places, remove trailing zeros) +function fmtDuration(n: number): string { + return (Math.round(n * 1000) / 1000) + .toFixed(3) + .replace(/\.000$/, '') + .replace(/(\.\d*?)0+$/, '$1') + .replace(/\.$/, '') +} + +export async function getReactionProps( + node: SceneNode, +): Promise> { + // Check if this node is a child that should have animation props + const parentAnimations = childAnimationCache.get(node.parent?.id || '') + if (parentAnimations) { + const childProps = parentAnimations.get(node.name) + if (childProps) { + return childProps + } + } + + // If this node has reactions, build animations for its children + if ( + !('reactions' in node) || + !node.reactions || + node.reactions.length === 0 + ) { + return {} + } + + // Find SMART_ANIMATE transitions from reactions array + for (const reaction of node.reactions) { + const actions = reaction.actions || [] + + for (const action of actions) { + // Check for SMART_ANIMATE transition + if ( + action.type === 'NODE' && + action.transition?.type === 'SMART_ANIMATE' + ) { + const transition = action.transition + const trigger = reaction.trigger + + // Generate keyframes for timer-based animations + if (trigger?.type === 'AFTER_TIMEOUT') { + const timeout = trigger.timeout || 0 + const destinationId = action.destinationId + + // Get destination node + if (destinationId) { + try { + const destinationNode = + await figma.getNodeByIdAsync(destinationId) + + // Check if node is a SceneNode (not DocumentNode or PageNode) + if ( + destinationNode && + 'type' in destinationNode && + destinationNode.type !== 'DOCUMENT' && + destinationNode.type !== 'PAGE' + ) { + // Build animation chain by following reactions + const { chain: animationChain, isLoop } = + await buildAnimationChain( + node, + destinationNode as SceneNode, + transition.duration || 0.3, + transition.easing, + timeout, + ) + + if (animationChain.length > 0) { + // Generate animations for each child + const childAnimations = await generateChildAnimations( + node, + animationChain, + isLoop, + ) + + // Store in cache for children to access + if (childAnimations.size > 0) { + childAnimationCache.set(node.id, childAnimations) + } else { + // Fallback: if node has no children, apply animation to node itself + const totalDuration = animationChain.reduce( + (sum, step) => sum + step.duration, + 0, + ) + const firstStep = animationChain[0] + const firstEasing = firstStep.easing || { type: 'LINEAR' } + + // Build simple keyframes for the node itself + const keyframes: KeyframeData = {} + + // First pass: collect all changes to know which properties animate + const allChanges: Record[] = [] + for (let i = 0; i < animationChain.length; i++) { + const step = animationChain[i] + const prevNode = + i === 0 ? node : animationChain[i - 1].node + const changes = await generateSingleNodeDifferences( + prevNode, + step.node, + ) + allChanges.push(changes) + } + + // Find all properties that change during animation + const animatedProperties = new Set() + for (const changes of allChanges) { + for (const key of Object.keys(changes)) { + 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 = {} + 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) { + const values = new Set() + for (const changes of allChanges) { + if (changes[key] !== undefined) { + values.add(JSON.stringify(changes[key])) + } + } + // Only include in 0% if property has multiple different values + propertyNeedsInitial.set(key, values.size > 1) + } + + // Get the starting values by comparing first destination back to source + // This gives us the values from the source node in the same format + const firstStep = animationChain[0] + const startingValues = + await generateSingleNodeDifferences( + firstStep.node, + node, + ) + + // 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] + } + } + } + + // Set initial keyframe with animated properties + keyframes['0%'] = initialValues + + // Determine which properties should be included + // Exclude properties that appear multiple times with the same value + const propertyHasMultipleValues = new Map() + for (const key of animatedProperties) { + const values = new Set() + let occurrenceCount = 0 + for (const changes of allChanges) { + if (changes[key] !== undefined) { + values.add(JSON.stringify(changes[key])) + occurrenceCount++ + } + } + // Include if: property has multiple different values OR appears only once + propertyHasMultipleValues.set( + key, + values.size > 1 || occurrenceCount === 1, + ) + } + + // Second pass: build keyframes with incremental changes + let accumulatedTime = 0 + let previousKeyframe: Record = { + ...initialValues, + } + for (let i = 0; i < animationChain.length; i++) { + const step = animationChain[i] + accumulatedTime += step.duration + + const percentage = Math.round( + (accumulatedTime / totalDuration) * 100, + ) + const percentageKey = `${percentage}%` + + const changes = allChanges[i] + + // Only include properties that changed from previous keyframe AND have multiple values + const incrementalChanges: Record = {} + for (const [key, value] of Object.entries(changes)) { + if ( + propertyHasMultipleValues.get(key) && + previousKeyframe[key] !== value + ) { + incrementalChanges[key] = value + } + } + + if (Object.keys(incrementalChanges).length > 0) { + keyframes[percentageKey] = incrementalChanges + previousKeyframe = { + ...previousKeyframe, + ...incrementalChanges, + } + } + } + + if (Object.keys(keyframes).length > 1) { + const props: Record = { + animationName: `keyframes(${JSON.stringify(keyframes)})`, + animationDuration: `${fmtDuration(totalDuration)}s`, + animationTimingFunction: getEasingFunction(firstEasing), + animationFillMode: 'forwards', + } + + // Only add delay if it's significant (>= 0.01s / 10ms) + if (timeout >= 0.01) { + props.animationDelay = `${fmtDuration(timeout)}s` + } + + // Add infinite iteration if it's a loop + if (isLoop) { + props.animationIterationCount = 'infinite' + } + + return props + } + } + } + } + } catch (e) { + console.error('Failed to get destination node:', e) + } + } + } + } + } + } + + return {} +} + +async function buildAnimationChain( + startNode: SceneNode, + currentNode: SceneNode, + duration: number, + easing: { type: string } | undefined, + delay: number, + visitedIds: Set = new Set(), +): Promise { + const chain: AnimationStep[] = [] + 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 } + } + + visitedIds.add(currentNodeId) + + // Add current step to chain + chain.push({ + node: currentNode, + duration, + easing, + delay, + }) + + // Check if current node has further reactions + if ('reactions' in currentNode && currentNode.reactions) { + for (const reaction of currentNode.reactions) { + const actions = reaction.actions || [] + + for (const action of actions) { + if ( + action.type === 'NODE' && + action.transition?.type === 'SMART_ANIMATE' && + reaction.trigger?.type === 'AFTER_TIMEOUT' + ) { + const nextDestinationId = action.destinationId + + if (nextDestinationId) { + // Check if next destination loops back to start + if (nextDestinationId === startNode.id) { + isLoop = true + break + } + + if (!visitedIds.has(nextDestinationId)) { + try { + const nextNode = await figma.getNodeByIdAsync(nextDestinationId) + + if ( + nextNode && + 'type' in nextNode && + nextNode.type !== 'DOCUMENT' && + nextNode.type !== 'PAGE' + ) { + // Recursively build chain + const result = await buildAnimationChain( + startNode, + nextNode as SceneNode, + action.transition.duration || 0.3, + action.transition.easing, + reaction.trigger.timeout || 0, + new Set(visitedIds), + ) + + chain.push(...result.chain) + if (result.isLoop) { + isLoop = true + } + } + } catch (e) { + console.error('Failed to get next node in chain:', e) + } + } + } + } + } + } + } + + return { chain, isLoop } +} + +async function generateChildAnimations( + startNode: SceneNode, + chain: AnimationStep[], + isLoop: boolean, +): Promise>> { + const childAnimationsMap = new Map>() + + if (chain.length === 0) return childAnimationsMap + + const totalDuration = chain.reduce((sum, step) => sum + step.duration, 0) + const firstStep = chain[0] + + // Get children from start node and destination nodes + if (!('children' in startNode)) return childAnimationsMap + + const startChildren = startNode.children as readonly SceneNode[] + const childrenByName = new Map() + + startChildren.forEach((child) => { + childrenByName.set(child.name, child) + }) + + // For each child, build its individual keyframes across the animation chain + for (const [childName] of childrenByName) { + const keyframes: KeyframeData = {} + + let accumulatedTime = 0 + let hasChanges = false + + // First pass: collect all changes to know which properties animate + const allChanges: Record[] = [] + for (let i = 0; i < chain.length; i++) { + const step = chain[i] + const prevNode = i === 0 ? startNode : chain[i - 1].node + const currentNode = step.node + + // Find matching child in current step by name + if ('children' in prevNode && 'children' in currentNode) { + const prevChildren = prevNode.children as readonly SceneNode[] + const currentChildren = currentNode.children as readonly SceneNode[] + + const prevChild = prevChildren.find((c) => c.name === childName) + const currentChild = currentChildren.find((c) => c.name === childName) + + if (prevChild && currentChild) { + const changes = await generateSingleNodeDifferences( + prevChild, + currentChild, + ) + allChanges.push(changes) + } else { + allChanges.push({}) + } + } else { + allChanges.push({}) + } + } + + // Find all properties that change during animation + const animatedProperties = new Set() + for (const changes of allChanges) { + for (const key of Object.keys(changes)) { + 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 = {} + 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) { + const values = new Set() + for (const changes of allChanges) { + if (changes[key] !== undefined) { + values.add(JSON.stringify(changes[key])) + } + } + // Only include in 0% if property has multiple different values + propertyNeedsInitial.set(key, values.size > 1) + } + + // Get the starting child from startNode + const startChild = startChildren.find((c) => c.name === childName) + + if (startChild) { + // Get the first step's matching child + const firstStep = chain[0] + if ('children' in firstStep.node) { + const firstChildren = firstStep.node.children as readonly SceneNode[] + const firstChild = firstChildren.find((c) => c.name === childName) + + if (firstChild) { + // Compare first destination back to source to get starting values + const startingValues = await generateSingleNodeDifferences( + firstChild, + startChild, + ) + + // 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] + } + } + } + } + } + } + + // Set initial keyframe with animated properties + keyframes['0%'] = initialValues + + // Determine which properties should be included + // Exclude properties that appear multiple times with the same value + const propertyHasMultipleValues = new Map() + for (const key of animatedProperties) { + const values = new Set() + let occurrenceCount = 0 + for (const changes of allChanges) { + if (changes[key] !== undefined) { + values.add(JSON.stringify(changes[key])) + occurrenceCount++ + } + } + // Include if: property has multiple different values OR appears only once + propertyHasMultipleValues.set( + key, + values.size > 1 || occurrenceCount === 1, + ) + } + + // Second pass: build keyframes with incremental changes + accumulatedTime = 0 + let previousKeyframe: Record = { ...initialValues } + for (let i = 0; i < chain.length; i++) { + const step = chain[i] + accumulatedTime += step.duration + + const percentage = Math.round((accumulatedTime / totalDuration) * 100) + const percentageKey = `${percentage}%` + + const changes = allChanges[i] + + // Only include properties that changed from previous keyframe AND have multiple values + const incrementalChanges: Record = {} + for (const [key, value] of Object.entries(changes)) { + if ( + propertyHasMultipleValues.get(key) && + previousKeyframe[key] !== value + ) { + incrementalChanges[key] = value + } + } + + if (Object.keys(incrementalChanges).length > 0) { + keyframes[percentageKey] = incrementalChanges + previousKeyframe = { ...previousKeyframe, ...incrementalChanges } + hasChanges = true + } + } + + // If this child has changes, add animation props + if (hasChanges && Object.keys(keyframes).length > 1) { + const firstEasing = firstStep.easing || { type: 'LINEAR' } + const delay = chain[0].delay + + const props: Record = { + animationName: `keyframes(${JSON.stringify(keyframes)})`, + animationDuration: `${fmtDuration(totalDuration)}s`, + animationTimingFunction: getEasingFunction(firstEasing), + animationFillMode: 'forwards', + } + + // Only add delay if it's significant (>= 0.01s / 10ms) + if (delay >= 0.01) { + props.animationDelay = `${fmtDuration(delay)}s` + } + + // Add infinite iteration if it's a loop + if (isLoop) { + props.animationIterationCount = 'infinite' + } + + childAnimationsMap.set(childName, props) + } + } + + return childAnimationsMap +} + +async function generateSingleNodeDifferences( + fromNode: SceneNode, + toNode: SceneNode, +): Promise> { + const changes: Record = {} + + // Check position changes + if ('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 + + changes.transform = `translate(${fmtPct(deltaX)}px, ${fmtPct(deltaY)}px)` + } + } + + // Check size changes + if ( + 'width' in fromNode && + 'width' in toNode && + 'height' in fromNode && + 'height' in toNode + ) { + if (fromNode.width !== toNode.width) { + changes.w = `${fmtPct(toNode.width)}px` + } + if (fromNode.height !== toNode.height) { + changes.h = `${fmtPct(toNode.height)}px` + } + } + + // Check opacity changes + if ('opacity' in fromNode && 'opacity' in toNode) { + if (fromNode.opacity !== toNode.opacity) { + changes.opacity = fmtPct(toNode.opacity) + } + } + + // Check background color changes + if ('fills' in fromNode && 'fills' in toNode) { + const fromFills = fromNode.fills + const toFills = toNode.fills + + if ( + Array.isArray(fromFills) && + fromFills.length > 0 && + Array.isArray(toFills) && + toFills.length > 0 + ) { + const fromFill = fromFills[0] + const toFill = toFills[0] + + if ( + fromFill.type === 'SOLID' && + toFill.type === 'SOLID' && + !isSameColor(fromFill.color, toFill.color) + ) { + changes.bg = rgbToString(toFill.color, toFill.opacity) + } + } + } + + // Check rotation changes + 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)` + } + } + + return changes +} + +function getEasingFunction(easing?: { type: string }): string { + if (!easing) return 'linear' + + switch (easing.type) { + case 'EASE_IN': + return 'ease-in' + case 'EASE_OUT': + return 'ease-out' + case 'EASE_IN_AND_OUT': + return 'ease-in-out' + default: + return 'linear' + } +} + +function isSameColor(color1: RGB, color2: RGB): boolean { + return ( + Math.abs(color1.r - color2.r) < 0.01 && + Math.abs(color1.g - color2.g) < 0.01 && + 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/utils/add-px.ts b/src/codegen/utils/add-px.ts index 890ad68..9e9c0ab 100644 --- a/src/codegen/utils/add-px.ts +++ b/src/codegen/utils/add-px.ts @@ -5,10 +5,14 @@ export function addPx( fallback: string | undefined = undefined, ) { if (typeof value !== 'number') return fallback - const fixed = value.toFixed(3) - const str = fixed.endsWith('.000') - ? String(Math.round(value)) - : fixed.replace(/\.?0+$/, '') + + // Round to 2 decimal places (same as fmtPct) + const rounded = Math.round(value * 100) / 100 + const fixed = rounded.toFixed(2) + + // Remove unnecessary trailing zeros + const str = fixed.replace(/\.00$/, '').replace(/(\.\d)0$/, '$1') + if (str === '0') return fallback return `${str}px` } diff --git a/src/codegen/utils/fmtPct.ts b/src/codegen/utils/fmtPct.ts index 91b5188..e296398 100644 --- a/src/codegen/utils/fmtPct.ts +++ b/src/codegen/utils/fmtPct.ts @@ -1,6 +1,12 @@ export function fmtPct(n: number) { - return (Math.round(n * 100) / 100) - .toFixed(2) - .replace(/\.00$/, '') - .replace(/(\.\d)0$/, '$1') + // Round to 2 decimal places + const rounded = Math.round(n * 100) / 100 + + // Format with 2 decimal places + const formatted = rounded.toFixed(2) + + // Remove unnecessary trailing zeros + // .00 -> remove entirely (156.00 -> 156) + // .X0 -> remove trailing 0 (156.30 -> 156.3) + return formatted.replace(/\.00$/, '').replace(/(\.\d)0$/, '$1') } diff --git a/src/codegen/utils/props-to-str.ts b/src/codegen/utils/props-to-str.ts index f3a043e..4481c20 100644 --- a/src/codegen/utils/props-to-str.ts +++ b/src/codegen/utils/props-to-str.ts @@ -11,6 +11,26 @@ export function propsToString(props: Record) { if (typeof value === 'boolean') return `${key}${value ? '' : `={${value}}`}` if (typeof value === 'object') return `${key}={${JSON.stringify(value, null, 2)}}` + // Special handling for animationName with keyframes function + if ( + key === 'animationName' && + typeof value === 'string' && + value.startsWith('keyframes(') + ) { + // Extract JSON from keyframes(...) and format it nicely + const match = value.match(/^keyframes\((.+)\)$/) + if (match) { + try { + const jsonData = JSON.parse(match[1]) + const formatted = JSON.stringify(jsonData, null, 2) + return `${key}={keyframes(${formatted})}` + } catch { + // If parsing fails, return as-is + return `${key}={${value}}` + } + } + return `${key}={${value}}` + } return `${key}="${value}"` })