diff --git a/KEYFRAME_USAGE.md b/KEYFRAME_USAGE.md new file mode 100644 index 0000000..60f1f87 --- /dev/null +++ b/KEYFRAME_USAGE.md @@ -0,0 +1,284 @@ +# Smart Transition to Keyframe Generator + +이 기능은 Figma의 reactions(상호작용) 속성에서 SMART_ANIMATE 트랜지션을 감지하고 CSS 키프레임 애니메이션으로 변환합니다. + +## 개요 + +Figma에서 컴포넌트 세트(Component Set)의 variants 간에 SMART_ANIMATE 트랜지션을 설정하면, 이를 다음 두 가지 방식으로 변환할 수 있습니다: + +1. **CSS Transition** (기본값): hover, active 등의 상태 변화 시 자동으로 적용되는 트랜지션 +2. **CSS Keyframe Animation** (새로운 기능): 더 세밀한 제어가 가능한 키프레임 애니메이션 + +## 사용 방법 + +### 1. 기본 사용 (CSS Transition) + +```typescript +import { getSelectorProps } from './codegen/props/selector' + +// 기본 동작 - CSS transition 생성 +const result = await getSelectorProps(componentSetNode) + +// 결과: +// { +// props: { +// _hover: { opacity: "0.8" }, +// transition: "300ms ease-in-out", +// transitionProperty: "opacity" +// }, +// variants: { ... } +// } +``` + +### 2. 키프레임 애니메이션 사용 + +```typescript +import { getSelectorProps } from './codegen/props/selector' + +// useKeyframes 옵션 활성화 +const result = await getSelectorProps(componentSetNode, { useKeyframes: true }) + +// 결과: +// { +// props: { +// _hover: { opacity: "0.8" } +// }, +// variants: { ... }, +// keyframes: [ +// { +// name: "hover-animation-abc123", +// keyframes: "@keyframes hover-animation-abc123 { ... }", +// animation: "hover-animation-abc123 300ms ease-in-out forwards", +// properties: ["opacity"] +// } +// ] +// } +``` + +## 키프레임 모듈 API + +### `generateKeyframeFromTransition()` + +단일 트랜지션에서 키프레임 애니메이션을 생성합니다. + +```typescript +import { generateKeyframeFromTransition } from './codegen/props/keyframe' + +const keyframe = generateKeyframeFromTransition( + { opacity: '1' }, // defaultProps - 시작 상태 + { opacity: '0.8' }, // targetProps - 끝 상태 + { // transition - Figma 트랜지션 객체 + type: 'SMART_ANIMATE', + duration: 0.3, // 초 단위 + easing: { type: 'EASE_IN_OUT' } + }, + 'hover', // effect - 효과 타입 + 'node-id-123' // nodeId - 고유 ID +) + +// 결과: +// { +// name: "hover-animation-pemt2n", +// keyframes: `@keyframes hover-animation-pemt2n { +// from { +// opacity: 1; +// } +// to { +// opacity: 0.8; +// } +// }`, +// animation: "hover-animation-pemt2n 300ms ease-in-out forwards", +// properties: ["opacity"] +// } +``` + +### `generateKeyframesForEffects()` + +여러 효과(hover, active 등)에 대한 키프레임을 한 번에 생성합니다. + +```typescript +import { generateKeyframesForEffects } from './codegen/props/keyframe' + +const defaultProps = { opacity: '1', backgroundColor: '#ffffff' } +const effectProps = new Map([ + ['hover', { opacity: '0.8' }], + ['active', { opacity: '0.6', backgroundColor: '#eeeeee' }] +]) + +const animations = generateKeyframesForEffects( + defaultProps, + effectProps, + transition, + 'component-id' +) + +// 결과: KeyframeAnimation[] +// [ +// { name: "hover-animation-...", ... }, +// { name: "active-animation-...", ... } +// ] +``` + +### 유틸리티 함수 + +#### `isSmartAnimateTransition()` + +트랜지션이 SMART_ANIMATE 타입인지 확인합니다. + +```typescript +import { isSmartAnimateTransition } from './codegen/props/keyframe' + +if (isSmartAnimateTransition(transition)) { + // SMART_ANIMATE 트랜지션 처리 +} +``` + +#### `extractTransitionFromReactions()` + +Figma reactions 배열에서 트랜지션을 추출합니다. + +```typescript +import { extractTransitionFromReactions } from './codegen/props/keyframe' + +const transition = extractTransitionFromReactions(node.reactions) +``` + +## 생성되는 키프레임 구조 + +### KeyframeAnimation 인터페이스 + +```typescript +interface KeyframeAnimation { + /** 고유한 애니메이션 이름 (예: "hover-animation-abc123") */ + name: string + + /** CSS @keyframes 정의 문자열 */ + keyframes: string + + /** CSS animation 속성 값 */ + animation: string + + /** 애니메이션되는 속성 배열 */ + properties: string[] +} +``` + +### 생성 예시 + +```css +/* keyframes 속성 */ +@keyframes hover-animation-abc123 { + from { + opacity: 1; + transform: translateX(0px); + } + to { + opacity: 0.8; + transform: translateX(10px); + } +} + +/* animation 속성 */ +animation: hover-animation-abc123 300ms ease-in-out forwards; +``` + +## Figma 설정 방법 + +1. **Component Set 생성**: 버튼 등의 컴포넌트를 Component Set으로 만듭니다 +2. **Variants 추가**: Default, Hover, Active 등의 variants를 추가합니다 +3. **Properties 변경**: 각 variant에서 애니메이션할 속성(opacity, position 등)을 설정합니다 +4. **Prototype 설정**: + - Default variant를 선택 + - Prototype 패널에서 interaction 추가 + - Trigger: "On hover" 또는 "On press" 선택 + - Action: 대상 variant 선택 + - Animation: "Smart animate" 선택 + - Duration과 Easing 설정 + +## 지원되는 Trigger 타입 + +- `ON_HOVER` → `hover` 효과 +- `ON_PRESS` → `active` 효과 + +## 지원되는 Easing 타입 + +모든 Figma easing 타입이 지원됩니다: + +- `EASE_IN` → `ease-in` +- `EASE_OUT` → `ease-out` +- `EASE_IN_OUT` → `ease-in-out` +- `LINEAR` → `linear` +- 기타 등등 (언더스코어는 하이픈으로 변환됨) + +## 고급 사용 사례 + +### 1. 복잡한 속성 애니메이션 + +```typescript +const defaultProps = { + opacity: '1', + transform: 'scale(1) rotate(0deg)', + backgroundColor: '#ffffff', + boxShadow: '0 0 0 rgba(0,0,0,0)' +} + +const hoverProps = { + opacity: '0.9', + transform: 'scale(1.05) rotate(5deg)', + backgroundColor: '#f0f0f0', + boxShadow: '0 4px 8px rgba(0,0,0,0.2)' +} + +const keyframe = generateKeyframeFromTransition( + defaultProps, + hoverProps, + transition, + 'hover', + nodeId +) +``` + +### 2. 여러 상태 처리 + +```typescript +const effectProps = new Map([ + ['hover', { opacity: '0.8' }], + ['active', { opacity: '0.6' }], + ['focus', { opacity: '0.9', outline: '2px solid blue' }] +]) + +const animations = generateKeyframesForEffects( + defaultProps, + effectProps, + transition, + nodeId +) +``` + +## 테스트 + +키프레임 생성 기능은 완전한 단위 테스트를 포함합니다: + +```bash +bun test src/codegen/props/__tests__/keyframe.test.ts +``` + +테스트 커버리지: +- 함수 커버리지: 100% +- 라인 커버리지: 93.22% + +## 주의사항 + +1. **Duration 단위**: Figma의 duration은 초 단위이며, 자동으로 밀리초(ms)로 변환됩니다 +2. **고유 이름**: 각 애니메이션은 effect 타입, 속성, nodeId를 기반으로 고유한 이름을 생성합니다 +3. **animation-fill-mode**: 기본적으로 `forwards`가 적용되어 애니메이션 종료 상태가 유지됩니다 +4. **속성 필터링**: 시작 상태와 끝 상태가 동일한 속성은 자동으로 제외됩니다 + +## 향후 개선 사항 + +- [ ] 중간 키프레임 지원 (0%, 50%, 100% 등) +- [ ] animation-iteration-count 옵션 +- [ ] animation-direction 옵션 +- [ ] 커스텀 easing 함수 (cubic-bezier) +- [ ] 다중 애니메이션 체인 +- [ ] 조건부 애니메이션 (media query) diff --git a/src/codegen/props/__tests__/keyframe.test.ts b/src/codegen/props/__tests__/keyframe.test.ts new file mode 100644 index 0000000..fa5a918 --- /dev/null +++ b/src/codegen/props/__tests__/keyframe.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, test } from 'bun:test' +import { + extractTransitionFromReactions, + generateKeyframeFromTransition, + generateKeyframesForEffects, + isSmartAnimateTransition, +} from '../keyframe' + +describe('keyframe generation', () => { + const mockTransition = { + type: 'SMART_ANIMATE' as const, + duration: 0.3, + easing: { type: 'EASE_IN_OUT' as const }, + } + + describe('isSmartAnimateTransition', () => { + test('returns true for SMART_ANIMATE transition', () => { + expect(isSmartAnimateTransition(mockTransition)).toBe(true) + }) + + test('returns false for non-SMART_ANIMATE transition', () => { + const dissolveTransition = { + type: 'DISSOLVE', + duration: 0.3, + easing: { type: 'EASE_IN_OUT' }, + } as unknown as Transition + expect(isSmartAnimateTransition(dissolveTransition)).toBe(false) + }) + + test('returns false for undefined', () => { + expect(isSmartAnimateTransition(undefined)).toBe(false) + }) + }) + + describe('extractTransitionFromReactions', () => { + test('extracts transition from reactions', () => { + const reactions = [ + { + trigger: { type: 'ON_HOVER' as const }, + actions: [ + { + type: 'NODE' as const, + transition: mockTransition, + }, + ], + }, + ] as unknown as Reaction[] + + const result = extractTransitionFromReactions(reactions) + expect(result).toEqual(mockTransition) + }) + + test('returns undefined for empty reactions', () => { + const result = extractTransitionFromReactions([]) + expect(result).toBeUndefined() + }) + + test('returns undefined for reactions without transition', () => { + const reactions = [ + { + trigger: { type: 'ON_HOVER' as const }, + actions: [ + { + type: 'NODE' as const, + }, + ], + }, + ] as unknown as Reaction[] + + const result = extractTransitionFromReactions(reactions) + expect(result).toBeUndefined() + }) + }) + + describe('generateKeyframeFromTransition', () => { + test('generates keyframe animation for opacity change', () => { + const defaultProps = { opacity: '1' } + const targetProps = { opacity: '0.8' } + const effect = 'hover' + const nodeId = 'test-node-123' + + const result = generateKeyframeFromTransition( + defaultProps, + targetProps, + mockTransition, + effect, + nodeId, + ) + + expect(result.properties).toEqual(['opacity']) + expect(result.animation).toContain('300ms') + expect(result.animation).toContain('ease-in-out') + expect(result.animation).toContain('forwards') + expect(result.keyframes).toContain('@keyframes') + expect(result.keyframes).toContain('from') + expect(result.keyframes).toContain('to') + expect(result.keyframes).toContain('opacity: 1') + expect(result.keyframes).toContain('opacity: 0.8') + }) + + test('generates keyframe animation for multiple properties', () => { + const defaultProps = { opacity: '1', backgroundColor: '#ffffff' } + const targetProps = { opacity: '0.8', backgroundColor: '#000000' } + const effect = 'hover' + const nodeId = 'test-node-456' + + const result = generateKeyframeFromTransition( + defaultProps, + targetProps, + mockTransition, + effect, + nodeId, + ) + + expect(result.properties).toHaveLength(2) + expect(result.properties).toContain('opacity') + expect(result.properties).toContain('backgroundColor') + expect(result.keyframes).toContain('opacity: 1') + expect(result.keyframes).toContain('opacity: 0.8') + expect(result.keyframes).toContain('backgroundColor: #ffffff') + expect(result.keyframes).toContain('backgroundColor: #000000') + }) + + test('generates unique animation names for different effects', () => { + const defaultProps = { opacity: '1' } + const targetProps = { opacity: '0.8' } + const nodeId = 'test-node-789' + + const hoverResult = generateKeyframeFromTransition( + defaultProps, + targetProps, + mockTransition, + 'hover', + nodeId, + ) + + const activeResult = generateKeyframeFromTransition( + defaultProps, + targetProps, + mockTransition, + 'active', + nodeId, + ) + + expect(hoverResult.name).not.toBe(activeResult.name) + expect(hoverResult.name).toContain('hover') + expect(activeResult.name).toContain('active') + }) + + test('formats easing function correctly', () => { + const defaultProps = { opacity: '1' } + const targetProps = { opacity: '0.8' } + const transitionWithDifferentEasing = { + type: 'SMART_ANIMATE' as const, + duration: 0.5, + easing: { type: 'EASE_IN' as const }, + } + + const result = generateKeyframeFromTransition( + defaultProps, + targetProps, + transitionWithDifferentEasing, + 'hover', + 'node-id', + ) + + expect(result.animation).toContain('500ms') + expect(result.animation).toContain('ease-in') + }) + }) + + describe('generateKeyframesForEffects', () => { + test('generates multiple keyframe animations', () => { + const defaultProps = { opacity: '1', backgroundColor: '#ffffff' } + const effectProps = new Map([ + ['hover', { opacity: '0.8' }], + ['active', { opacity: '0.6' }], + ]) + const nodeId = 'test-node-multi' + + const results = generateKeyframesForEffects( + defaultProps, + effectProps, + mockTransition, + nodeId, + ) + + expect(results).toHaveLength(2) + expect(results[0].name).toContain('hover') + expect(results[1].name).toContain('active') + expect(results[0].properties).toEqual(['opacity']) + expect(results[1].properties).toEqual(['opacity']) + }) + + test('skips effects with no property differences', () => { + const defaultProps = { opacity: '1' } + const effectProps = new Map([ + ['hover', { opacity: '1' }], // Same as default + ['active', { opacity: '0.8' }], + ]) + const nodeId = 'test-node-skip' + + const results = generateKeyframesForEffects( + defaultProps, + effectProps, + mockTransition, + nodeId, + ) + + expect(results).toHaveLength(1) + expect(results[0].name).toContain('active') + }) + + test('returns empty array when no effects differ', () => { + const defaultProps = { opacity: '1' } + const effectProps = new Map([['hover', { opacity: '1' }]]) + const nodeId = 'test-node-empty' + + const results = generateKeyframesForEffects( + defaultProps, + effectProps, + mockTransition, + nodeId, + ) + + expect(results).toHaveLength(0) + }) + + test('handles complex property values', () => { + const defaultProps = { + transform: 'translateX(0px)', + boxShadow: '0 0 0 rgba(0,0,0,0)', + } + const effectProps = new Map([ + [ + 'hover', + { + transform: 'translateX(10px)', + boxShadow: '0 2px 4px rgba(0,0,0,0.1)', + }, + ], + ]) + const nodeId = 'test-node-complex' + + const results = generateKeyframesForEffects( + defaultProps, + effectProps, + mockTransition, + nodeId, + ) + + expect(results).toHaveLength(1) + expect(results[0].properties).toContain('transform') + expect(results[0].properties).toContain('boxShadow') + expect(results[0].keyframes).toContain('translateX(0px)') + expect(results[0].keyframes).toContain('translateX(10px)') + }) + }) +}) diff --git a/src/codegen/props/keyframe.ts b/src/codegen/props/keyframe.ts new file mode 100644 index 0000000..45a3d47 --- /dev/null +++ b/src/codegen/props/keyframe.ts @@ -0,0 +1,208 @@ +import { fmtPct } from '../utils/fmtPct' + +/** + * Represents a CSS keyframe animation + */ +export interface KeyframeAnimation { + /** Unique animation name */ + name: string + /** CSS @keyframes definition */ + keyframes: string + /** CSS animation property value */ + animation: string + /** Properties that will be animated */ + properties: string[] +} + +/** + * Generates a unique animation name based on effect type and property hash + */ +function generateAnimationName( + effect: string, + properties: string[], + nodeId: string, +): string { + const propHash = properties.sort().join('-') + const hash = simpleHash(`${effect}-${propHash}-${nodeId}`) + return `${effect}-animation-${hash}` +} + +/** + * Simple hash function for generating unique identifiers + */ +function simpleHash(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32bit integer + } + return Math.abs(hash).toString(36).substring(0, 8) +} + +/** + * Converts CSS property values to string format for keyframes + */ +function formatPropertyValue(value: unknown): string { + if (typeof value === 'string') { + return value + } + if (typeof value === 'number') { + return value.toString() + } + if (typeof value === 'object' && value !== null) { + return JSON.stringify(value) + } + return String(value) +} + +/** + * Generates CSS @keyframes string from property differences + */ +function generateKeyframesCSS( + animationName: string, + fromProps: Record, + toProps: Record, + properties: string[], +): string { + const fromStyles = properties + .map((prop) => { + const value = fromProps[prop] + return ` ${prop}: ${formatPropertyValue(value)};` + }) + .join('\n') + + const toStyles = properties + .map((prop) => { + const value = toProps[prop] + return ` ${prop}: ${formatPropertyValue(value)};` + }) + .join('\n') + + return `@keyframes ${animationName} { + from { +${fromStyles} + } + to { +${toStyles} + } +}` +} + +/** + * Generates a keyframe animation from Figma SMART_ANIMATE transition + * + * @param defaultProps - Properties of the default variant (from state) + * @param targetProps - Properties of the target variant (to state) + * @param transition - Figma transition configuration + * @param effect - Effect type ('hover', 'active', etc.) + * @param nodeId - Node ID for generating unique animation names + * @returns KeyframeAnimation object with animation details + */ +export function generateKeyframeFromTransition( + defaultProps: Record, + targetProps: Record, + transition: Transition, + effect: string, + nodeId: string, +): KeyframeAnimation { + // Find all properties that differ between states + const properties = Object.keys(targetProps).filter( + (key) => defaultProps[key] !== targetProps[key], + ) + + // Generate unique animation name + const animationName = generateAnimationName(effect, properties, nodeId) + + // Generate @keyframes CSS + const keyframes = generateKeyframesCSS( + animationName, + defaultProps, + targetProps, + properties, + ) + + // Format easing function + const easingFunction = transition.easing.type + .toLowerCase() + .replaceAll('_', '-') + + // Generate animation property value (convert seconds to milliseconds) + const durationMs = fmtPct(transition.duration * 1000) + const animation = `${animationName} ${durationMs}ms ${easingFunction} forwards` + + return { + name: animationName, + keyframes, + animation, + properties, + } +} + +/** + * Generates multiple keyframe animations for different effects + * + * @param defaultProps - Properties of the default variant + * @param effectProps - Map of effect types to their property sets + * @param transition - Figma transition configuration + * @param nodeId - Node ID for generating unique animation names + * @returns Array of KeyframeAnimation objects + */ +export function generateKeyframesForEffects( + defaultProps: Record, + effectProps: Map>, + transition: Transition, + nodeId: string, +): KeyframeAnimation[] { + const animations: KeyframeAnimation[] = [] + + for (const [effect, targetProps] of effectProps) { + // Find properties that differ from default + const diffProps = Object.entries(targetProps).reduce( + (acc, [key, value]) => { + if (defaultProps[key] !== value) { + acc[key] = value + } + return acc + }, + {} as Record, + ) + + // Only generate animation if there are differences + if (Object.keys(diffProps).length > 0) { + const animation = generateKeyframeFromTransition( + defaultProps, + diffProps, + transition, + effect, + nodeId, + ) + animations.push(animation) + } + } + + return animations +} + +/** + * Checks if a transition is a SMART_ANIMATE transition + */ +export function isSmartAnimateTransition( + transition: Transition | undefined, +): transition is Transition & { type: 'SMART_ANIMATE' } { + return transition?.type === 'SMART_ANIMATE' +} + +/** + * Extracts transition from Figma reactions + */ +export function extractTransitionFromReactions( + reactions: readonly Reaction[], +): Transition | undefined { + return reactions + .flatMap( + (reaction) => + reaction.actions?.find((action) => action.type === 'NODE')?.transition, + ) + .filter((t): t is Transition => t !== undefined)[0] +} diff --git a/src/codegen/props/selector.ts b/src/codegen/props/selector.ts index becc1e3..6cc5f65 100644 --- a/src/codegen/props/selector.ts +++ b/src/codegen/props/selector.ts @@ -1,15 +1,22 @@ import { fmtPct } from '../utils/fmtPct' import { getProps } from '.' +import { + extractTransitionFromReactions, + generateKeyframesForEffects, + isSmartAnimateTransition, + type KeyframeAnimation, +} from './keyframe' + +export interface SelectorPropsResult { + props: Record + variants: Record + keyframes?: KeyframeAnimation[] +} export async function getSelectorProps( node: ComponentSetNode | ComponentNode, -): Promise< - | { - props: Record - variants: Record - } - | undefined -> { + options?: { useKeyframes?: boolean }, +): Promise { if (node.type === 'COMPONENT' && node.parent?.type === 'COMPONENT_SET') { return getSelectorProps(node.parent) } @@ -47,24 +54,39 @@ export async function getSelectorProps( ) if (components.length > 0) { - const transition = node.defaultVariant.reactions - .flatMap( - (reaction) => - reaction.actions?.find((action) => action.type === 'NODE') - ?.transition, - ) - .flat()[0] + const transition = extractTransitionFromReactions(node.defaultVariant.reactions) const diffKeys = new Set() + const effectPropsMap = new Map>() + for (const [effect, props] of components) { if (!effect) continue const def = difference(props, defaultProps) if (Object.keys(def).length === 0) continue result.props[`_${effect}`] = def + effectPropsMap.set(effect, def) for (const key of Object.keys(def)) { diffKeys.add(key) } } - if (transition?.type === 'SMART_ANIMATE' && diffKeys.size > 0) { + + if (isSmartAnimateTransition(transition) && diffKeys.size > 0) { + const useKeyframes = options?.useKeyframes ?? false + + if (useKeyframes) { + // Generate keyframe animations + const keyframes = generateKeyframesForEffects( + defaultProps as Record, + effectPropsMap, + transition, + node.id, + ) + return { + ...result, + keyframes, + } + } + + // Default: Generate CSS transitions const keys = Array.from(diffKeys) keys.sort() result.props.transition = `${fmtPct(transition.duration)}ms ${transition.easing.type.toLocaleLowerCase().replaceAll('_', '-')}`