diff --git a/src/extractors/built-in.ts b/src/extractors/built-in.ts index 5173c5d6..ee86e9f0 100644 --- a/src/extractors/built-in.ts +++ b/src/extractors/built-in.ts @@ -14,6 +14,7 @@ import { hasTextStyle, isTextNode, } from "~/transformers/text.js"; +import { buildSimplifiedInteractions } from "~/transformers/interaction.js"; import { hasValue, isRectangleCornerRadii } from "~/utils/identity.js"; import { generateVarId } from "~/utils/common.js"; import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec"; @@ -83,7 +84,10 @@ export const visualsExtractor: ExtractorFn = (node, result, context) => { // fills if (hasValue("fills", node) && Array.isArray(node.fills) && node.fills.length) { - const fills = node.fills.map((fill) => parsePaint(fill, hasChildren)).reverse(); + const fills = node.fills + .filter((fill): fill is NonNullable => fill != null) + .map((fill) => parsePaint(fill, hasChildren)) + .reverse(); const styleName = getStyleName(node, context, ["fill", "fills"]); if (styleName) { context.globalVars.styles[styleName] = fills; @@ -158,6 +162,16 @@ export const componentExtractor: ExtractorFn = (node, result, _context) => { } }; +/** + * Extracts prototype interaction data (triggers and actions) from nodes. + */ +export const interactionExtractor: ExtractorFn = (node, result, _context) => { + const interactions = buildSimplifiedInteractions(node); + if (interactions) { + result.interactions = interactions; + } +}; + // Helper to fetch a Figma style name for specific style keys on a node function getStyleName( node: FigmaDocumentNode, @@ -181,7 +195,13 @@ function getStyleName( /** * All extractors - replicates the current parseNode behavior. */ -export const allExtractors = [layoutExtractor, textExtractor, visualsExtractor, componentExtractor]; +export const allExtractors = [ + layoutExtractor, + textExtractor, + visualsExtractor, + componentExtractor, + interactionExtractor, +]; /** * Layout and text only - useful for content analysis and layout planning. diff --git a/src/extractors/index.ts b/src/extractors/index.ts index 05e6892e..b70134af 100644 --- a/src/extractors/index.ts +++ b/src/extractors/index.ts @@ -19,6 +19,7 @@ export { textExtractor, visualsExtractor, componentExtractor, + interactionExtractor, // Convenience combinations allExtractors, layoutAndText, diff --git a/src/extractors/node-walker.ts b/src/extractors/node-walker.ts index c3938a2f..784a772a 100644 --- a/src/extractors/node-walker.ts +++ b/src/extractors/node-walker.ts @@ -76,6 +76,7 @@ function processNodeWithExtractors( // Use the same pattern as the existing parseNode function if (hasValue("children", node) && node.children.length > 0) { const children = node.children + .filter((child): child is NonNullable => child != null) .filter((child) => shouldProcessNode(child, options)) .map((child) => processNodeWithExtractors(child, extractors, childContext, options)) .filter((child): child is SimplifiedNode => child !== null); diff --git a/src/extractors/types.ts b/src/extractors/types.ts index 271b451e..78c81b52 100644 --- a/src/extractors/types.ts +++ b/src/extractors/types.ts @@ -8,6 +8,7 @@ import type { SimplifiedComponentDefinition, SimplifiedComponentSetDefinition, } from "~/transformers/component.js"; +import type { SimplifiedInteraction } from "~/transformers/interaction.js"; export type StyleTypes = | SimplifiedTextStyle @@ -90,6 +91,8 @@ export interface SimplifiedNode { // for rect-specific strokes, etc. componentId?: string; componentProperties?: ComponentProperties[]; + // prototype interactions + interactions?: SimplifiedInteraction[]; // children children?: SimplifiedNode[]; } diff --git a/src/index.ts b/src/index.ts index 03677197..48e32e50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ export { textExtractor, visualsExtractor, componentExtractor, + interactionExtractor, allExtractors, layoutAndText, contentOnly, diff --git a/src/services/figma.ts b/src/services/figma.ts index bd8e0e78..9b1c5443 100644 --- a/src/services/figma.ts +++ b/src/services/figma.ts @@ -1,5 +1,6 @@ import path from "path"; import type { + Node as FigmaDocumentNode, GetImagesResponse, GetFileResponse, GetFileNodesResponse, @@ -278,21 +279,69 @@ export class FigmaService { } /** - * Get raw Figma API response for specific nodes (for use with flexible extractors) + * Get raw Figma API response for specific nodes (for use with flexible extractors). + * + * Fetches the full file rather than using `ids` or the `/nodes` endpoint. + * Both `/files?ids=` and `/files/nodes` nullify interaction destinationIds + * when the destination falls outside the queried subtree. Fetching the full + * file preserves all prototype interaction data. + * + * The `depth` parameter is NOT forwarded to the API—it is only used by the + * extractor pipeline to limit traversal. This ensures the target node and + * its interaction destinations are always present in the response. */ async getRawNode( fileKey: string, nodeId: string, depth?: number | null, ): Promise { - const endpoint = `/files/${fileKey}/nodes?ids=${nodeId}${depth ? `&depth=${depth}` : ""}`; + const endpoint = `/files/${fileKey}`; Logger.log( `Retrieving raw Figma node: ${nodeId} from ${fileKey} (depth: ${depth ?? "default"})`, ); - const response = await this.request(endpoint); - writeLogs("figma-raw.json", response); + const fileResponse = await this.request(endpoint); + writeLogs("figma-raw.json", fileResponse); - return response; + const targetNode = findNodeInTree(fileResponse.document, nodeId); + if (!targetNode) { + throw new Error(`Node ${nodeId} not found in file ${fileKey}`); + } + + return { + name: fileResponse.name, + role: fileResponse.role, + lastModified: fileResponse.lastModified, + editorType: fileResponse.editorType, + thumbnailUrl: fileResponse.thumbnailUrl ?? "", + version: fileResponse.version, + nodes: { + [nodeId]: { + document: targetNode, + components: fileResponse.components ?? {}, + componentSets: fileResponse.componentSets ?? {}, + schemaVersion: fileResponse.schemaVersion, + styles: fileResponse.styles ?? {}, + }, + }, + }; } } + +/** + * Recursively searches a Figma document tree for a node with the given ID. + * Guards against null/undefined entries that may appear in pruned API responses. + */ +function findNodeInTree(node: FigmaDocumentNode, targetId: string): FigmaDocumentNode | null { + if (!node || typeof node !== "object") return null; + if (node.id === targetId) return node; + + const children = "children" in node && Array.isArray(node.children) ? node.children : []; + for (const child of children) { + if (!child) continue; + const found = findNodeInTree(child as FigmaDocumentNode, targetId); + if (found) return found; + } + + return null; +} diff --git a/src/tests/interaction.test.ts b/src/tests/interaction.test.ts new file mode 100644 index 00000000..b4e6f970 --- /dev/null +++ b/src/tests/interaction.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect } from "vitest"; +import { buildSimplifiedInteractions } from "~/transformers/interaction.js"; +import { interactionExtractor } from "~/extractors/built-in.js"; +import { extractFromDesign } from "~/extractors/node-walker.js"; +import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec"; +import type { SimplifiedNode, TraversalContext } from "~/extractors/types.js"; + +function makeNode(overrides: Record = {}): FigmaDocumentNode { + return { + id: "1:1", + name: "Button", + type: "FRAME", + visible: true, + ...overrides, + } as unknown as FigmaDocumentNode; +} + +describe("buildSimplifiedInteractions", () => { + it("returns undefined for nodes without interactions", () => { + const node = makeNode(); + expect(buildSimplifiedInteractions(node)).toBeUndefined(); + }); + + it("returns undefined for nodes with empty interactions array", () => { + const node = makeNode({ interactions: [] }); + expect(buildSimplifiedInteractions(node)).toBeUndefined(); + }); + + it("extracts ON_CLICK trigger with NODE navigate action", () => { + const node = makeNode({ + interactions: [ + { + trigger: { type: "ON_CLICK" }, + actions: [ + { + type: "NODE", + destinationId: "4:1539", + navigation: "NAVIGATE", + transition: { + type: "DISSOLVE", + duration: 300, + easing: { type: "EASE_OUT" }, + }, + preserveScrollPosition: false, + }, + ], + }, + ], + }); + + const result = buildSimplifiedInteractions(node); + expect(result).toHaveLength(1); + expect(result![0].trigger).toEqual({ type: "ON_CLICK" }); + expect(result![0].actions).toHaveLength(1); + expect(result![0].actions[0]).toEqual({ + type: "NODE", + destinationId: "4:1539", + navigation: "NAVIGATE", + transition: { + type: "DISSOLVE", + duration: 300, + easing: "EASE_OUT", + }, + }); + }); + + it("extracts ON_HOVER trigger", () => { + const node = makeNode({ + interactions: [ + { + trigger: { type: "ON_HOVER" }, + actions: [ + { + type: "NODE", + destinationId: "10:1", + navigation: "OVERLAY", + transition: null, + }, + ], + }, + ], + }); + + const result = buildSimplifiedInteractions(node); + expect(result![0].trigger.type).toBe("ON_HOVER"); + expect(result![0].actions[0]).toEqual({ + type: "NODE", + destinationId: "10:1", + navigation: "OVERLAY", + }); + }); + + it("extracts AFTER_TIMEOUT trigger with timeout value", () => { + const node = makeNode({ + interactions: [ + { + trigger: { type: "AFTER_TIMEOUT", timeout: 2000 }, + actions: [{ type: "NODE", destinationId: "5:1", navigation: "NAVIGATE", transition: null }], + }, + ], + }); + + const result = buildSimplifiedInteractions(node); + expect(result![0].trigger).toEqual({ type: "AFTER_TIMEOUT", timeout: 2000 }); + }); + + it("extracts MOUSE_ENTER trigger with delay", () => { + const node = makeNode({ + interactions: [ + { + trigger: { type: "MOUSE_ENTER", delay: 500 }, + actions: [{ type: "BACK" }], + }, + ], + }); + + const result = buildSimplifiedInteractions(node); + expect(result![0].trigger).toEqual({ type: "MOUSE_ENTER", delay: 500 }); + expect(result![0].actions[0]).toEqual({ type: "BACK" }); + }); + + it("extracts URL action", () => { + const node = makeNode({ + interactions: [ + { + trigger: { type: "ON_CLICK" }, + actions: [{ type: "URL", url: "https://example.com" }], + }, + ], + }); + + const result = buildSimplifiedInteractions(node); + expect(result![0].actions[0]).toEqual({ + type: "URL", + url: "https://example.com", + }); + }); + + it("extracts BACK and CLOSE actions", () => { + const node = makeNode({ + interactions: [ + { + trigger: { type: "ON_CLICK" }, + actions: [{ type: "BACK" }, { type: "CLOSE" }], + }, + ], + }); + + const result = buildSimplifiedInteractions(node); + expect(result![0].actions).toEqual([{ type: "BACK" }, { type: "CLOSE" }]); + }); + + it("extracts directional transition with direction", () => { + const node = makeNode({ + interactions: [ + { + trigger: { type: "ON_CLICK" }, + actions: [ + { + type: "NODE", + destinationId: "2:1", + navigation: "NAVIGATE", + transition: { + type: "SLIDE_IN", + direction: "LEFT", + duration: 200, + easing: { type: "EASE_IN_AND_OUT" }, + matchLayers: false, + }, + }, + ], + }, + ], + }); + + const result = buildSimplifiedInteractions(node); + const action = result![0].actions[0] as { transition: { type: string; direction: string } }; + expect(action.transition.type).toBe("SLIDE_IN"); + expect(action.transition.direction).toBe("LEFT"); + }); + + it("extracts SMART_ANIMATE transition", () => { + const node = makeNode({ + interactions: [ + { + trigger: { type: "ON_CLICK" }, + actions: [ + { + type: "NODE", + destinationId: "3:1", + navigation: "NAVIGATE", + transition: { + type: "SMART_ANIMATE", + duration: 500, + easing: { type: "EASE_IN" }, + }, + }, + ], + }, + ], + }); + + const result = buildSimplifiedInteractions(node); + const action = result![0].actions[0] as { transition: { type: string; easing: string } }; + expect(action.transition.type).toBe("SMART_ANIMATE"); + expect(action.transition.easing).toBe("EASE_IN"); + }); + + it("handles multiple interactions on a single node", () => { + const node = makeNode({ + interactions: [ + { + trigger: { type: "ON_CLICK" }, + actions: [ + { type: "NODE", destinationId: "1:1", navigation: "NAVIGATE", transition: null }, + ], + }, + { + trigger: { type: "ON_HOVER" }, + actions: [ + { type: "NODE", destinationId: "2:2", navigation: "OVERLAY", transition: null }, + ], + }, + ], + }); + + const result = buildSimplifiedInteractions(node); + expect(result).toHaveLength(2); + expect(result![0].trigger.type).toBe("ON_CLICK"); + expect(result![1].trigger.type).toBe("ON_HOVER"); + }); + + it("filters out interactions with null trigger", () => { + const node = makeNode({ + interactions: [ + { trigger: null, actions: [{ type: "BACK" }] }, + { + trigger: { type: "ON_CLICK" }, + actions: [ + { type: "NODE", destinationId: "1:1", navigation: "NAVIGATE", transition: null }, + ], + }, + ], + }); + + const result = buildSimplifiedInteractions(node); + expect(result).toHaveLength(1); + expect(result![0].trigger.type).toBe("ON_CLICK"); + }); +}); + +describe("interactionExtractor", () => { + it("adds interactions to SimplifiedNode", () => { + const node = makeNode({ + interactions: [ + { + trigger: { type: "ON_CLICK" }, + actions: [ + { type: "NODE", destinationId: "4:1", navigation: "NAVIGATE", transition: null }, + ], + }, + ], + }); + + const result: SimplifiedNode = { id: "1:1", name: "Button", type: "FRAME" }; + const context: TraversalContext = { globalVars: { styles: {} }, currentDepth: 0 }; + + interactionExtractor(node, result, context); + + expect(result.interactions).toBeDefined(); + expect(result.interactions).toHaveLength(1); + expect(result.interactions![0].trigger.type).toBe("ON_CLICK"); + }); + + it("does not add interactions when node has none", () => { + const node = makeNode(); + const result: SimplifiedNode = { id: "1:1", name: "Box", type: "FRAME" }; + const context: TraversalContext = { globalVars: { styles: {} }, currentDepth: 0 }; + + interactionExtractor(node, result, context); + + expect(result.interactions).toBeUndefined(); + }); +}); + +describe("interactionExtractor integration with extractFromDesign", () => { + it("includes interactions in extracted nodes", () => { + const nodes = [ + makeNode({ + interactions: [ + { + trigger: { type: "ON_CLICK" }, + actions: [ + { type: "NODE", destinationId: "4:1", navigation: "NAVIGATE", transition: null }, + ], + }, + ], + }), + ]; + + const { nodes: extracted } = extractFromDesign(nodes, [interactionExtractor]); + + expect(extracted).toHaveLength(1); + expect(extracted[0].interactions).toBeDefined(); + expect(extracted[0].interactions![0].trigger.type).toBe("ON_CLICK"); + }); +}); diff --git a/src/transformers/effects.ts b/src/transformers/effects.ts index b901634a..ba7e1d6a 100644 --- a/src/transformers/effects.ts +++ b/src/transformers/effects.ts @@ -16,7 +16,7 @@ export type SimplifiedEffects = { export function buildSimplifiedEffects(n: FigmaDocumentNode): SimplifiedEffects { if (!hasValue("effects", n)) return {}; - const effects = n.effects.filter((e) => e.visible); + const effects = n.effects.filter((e): e is NonNullable => e != null && e.visible); // Handle drop and inner shadows (both go into CSS box-shadow) const dropShadows = effects diff --git a/src/transformers/interaction.ts b/src/transformers/interaction.ts new file mode 100644 index 00000000..284a2adc --- /dev/null +++ b/src/transformers/interaction.ts @@ -0,0 +1,190 @@ +import type { + Node as FigmaDocumentNode, + Interaction, + Action, + Trigger, + NodeAction, + Transition, +} from "@figma/rest-api-spec"; +import { hasValue } from "~/utils/identity.js"; + +export interface SimplifiedInteraction { + trigger: SimplifiedTrigger; + actions: SimplifiedAction[]; +} + +export interface SimplifiedTrigger { + type: string; + delay?: number; + timeout?: number; + keyCodes?: number[]; + device?: string; + mediaHitTime?: number; +} + +export type SimplifiedAction = + | SimplifiedNavigateAction + | SimplifiedOpenURLAction + | SimplifiedBackCloseAction + | SimplifiedMediaAction + | SimplifiedSetVariableAction + | SimplifiedSetVariableModeAction + | SimplifiedConditionalAction; + +export interface SimplifiedNavigateAction { + type: "NODE"; + destinationId: string | null; + navigation: string; + transition?: SimplifiedTransition; + preserveScrollPosition?: boolean; + resetScrollPosition?: boolean; + resetInteractiveComponents?: boolean; +} + +export interface SimplifiedOpenURLAction { + type: "URL"; + url: string; +} + +export interface SimplifiedBackCloseAction { + type: "BACK" | "CLOSE"; +} + +export interface SimplifiedMediaAction { + type: "UPDATE_MEDIA_RUNTIME"; + destinationId?: string | null; + mediaAction: string; +} + +export interface SimplifiedSetVariableAction { + type: "SET_VARIABLE"; + variableId: string | null; +} + +export interface SimplifiedSetVariableModeAction { + type: "SET_VARIABLE_MODE"; + variableCollectionId?: string | null; + variableModeId?: string | null; +} + +export interface SimplifiedConditionalAction { + type: "CONDITIONAL"; +} + +export interface SimplifiedTransition { + type: string; + duration: number; + easing?: string; + direction?: string; +} + +/** + * Extracts prototype interactions from a Figma node. + * Returns undefined when the node has no interactions. + * + * Figma's REST API can return nulls in unexpected places (null triggers, null + * entries in the actions array, unknown action types), so every access is + * guarded defensively. + */ +export function buildSimplifiedInteractions( + node: FigmaDocumentNode, +): SimplifiedInteraction[] | undefined { + if (!hasValue("interactions", node)) return undefined; + + const raw = node.interactions; + if (!Array.isArray(raw) || !raw.length) return undefined; + + const result: SimplifiedInteraction[] = []; + for (const interaction of raw) { + if (!interaction?.trigger?.type) continue; + + const actions = Array.isArray(interaction.actions) ? interaction.actions : []; + const simplified = actions.filter(isValidAction).map(simplifyAction); + + result.push({ + trigger: simplifyTrigger(interaction.trigger as Trigger), + actions: simplified, + }); + } + + return result.length ? result : undefined; +} + +function isValidAction(action: unknown): action is Action { + return typeof action === "object" && action !== null && "type" in action && action.type != null; +} + +function simplifyTrigger(trigger: Trigger): SimplifiedTrigger { + const result: SimplifiedTrigger = { type: trigger.type }; + + if ("delay" in trigger && trigger.delay) result.delay = trigger.delay; + if ("timeout" in trigger && trigger.timeout) result.timeout = trigger.timeout; + if ("keyCodes" in trigger) result.keyCodes = trigger.keyCodes; + if ("device" in trigger) result.device = trigger.device; + if ("mediaHitTime" in trigger) result.mediaHitTime = trigger.mediaHitTime; + + return result; +} + +function simplifyAction(action: Action): SimplifiedAction { + switch (action.type) { + case "NODE": + return simplifyNodeAction(action); + case "URL": + return { type: "URL", url: (action as { url: string }).url ?? "" }; + case "BACK": + case "CLOSE": + return { type: action.type }; + case "UPDATE_MEDIA_RUNTIME": + return { + type: "UPDATE_MEDIA_RUNTIME", + destinationId: action.destinationId ?? null, + mediaAction: action.mediaAction, + }; + case "SET_VARIABLE": + return { type: "SET_VARIABLE", variableId: action.variableId }; + case "SET_VARIABLE_MODE": + return { + type: "SET_VARIABLE_MODE", + variableCollectionId: action.variableCollectionId, + variableModeId: action.variableModeId, + }; + case "CONDITIONAL": + return { type: "CONDITIONAL" }; + default: + return { type: (action as { type: string }).type } as SimplifiedAction; + } +} + +function simplifyNodeAction(action: NodeAction): SimplifiedNavigateAction { + const result: SimplifiedNavigateAction = { + type: "NODE", + destinationId: action.destinationId, + navigation: action.navigation, + }; + + if (action.transition) { + result.transition = simplifyTransition(action.transition); + } + if (action.preserveScrollPosition) result.preserveScrollPosition = true; + if (action.resetScrollPosition) result.resetScrollPosition = true; + if (action.resetInteractiveComponents) result.resetInteractiveComponents = true; + + return result; +} + +function simplifyTransition(transition: Transition): SimplifiedTransition { + const result: SimplifiedTransition = { + type: transition.type, + duration: transition.duration, + }; + + if (transition.easing?.type) { + result.easing = transition.easing.type; + } + if ("direction" in transition) { + result.direction = transition.direction; + } + + return result; +} diff --git a/src/transformers/style.ts b/src/transformers/style.ts index de82cfe9..4c7e5d96 100644 --- a/src/transformers/style.ts +++ b/src/transformers/style.ts @@ -228,7 +228,10 @@ export function buildSimplifiedStrokes( ): SimplifiedStroke { let strokes: SimplifiedStroke = { colors: [] }; if (hasValue("strokes", n) && Array.isArray(n.strokes) && n.strokes.length) { - strokes.colors = n.strokes.filter(isVisible).map((stroke) => parsePaint(stroke, hasChildren)); + strokes.colors = n.strokes + .filter((s): s is NonNullable => s != null) + .filter(isVisible) + .map((stroke) => parsePaint(stroke, hasChildren)); } if (hasValue("strokeWeight", n) && typeof n.strokeWeight === "number" && n.strokeWeight > 0) {