diff --git a/plugin/src/main/code.ts b/plugin/src/main/code.ts index 896e85d..a371ed9 100644 --- a/plugin/src/main/code.ts +++ b/plugin/src/main/code.ts @@ -35,7 +35,10 @@ type RequestType = | "remove_animation_style" | "apply_manual_keyframe_track" | "remove_manual_keyframe_track" - | "set_timeline_duration"; + | "set_timeline_duration" + | "create_component_from_node" + | "combine_as_variants" + | "set_reactions"; type ServerRequestParams = Record & { format?: "PNG" | "SVG" | "JPG" | "PDF"; @@ -368,6 +371,9 @@ const EDIT_REQUEST_TYPES = new Set([ "apply_manual_keyframe_track", "remove_manual_keyframe_track", "set_timeline_duration", + "create_component_from_node", + "combine_as_variants", + "set_reactions", ]); const requireEditorMode = (toolName: RequestType): void => { @@ -1608,6 +1614,145 @@ const handleRequest = async ( }, }; } + case "create_component_from_node": { + const nodeId = request.nodeIds && request.nodeIds[0]; + if (!nodeId) { + throw new Error( + "nodeIds is required for create_component_from_node" + ); + } + + const node = await getSceneNodeById(nodeId); + if (node.type === "COMPONENT" || node.type === "COMPONENT_SET") { + throw new Error(`Node is already a ${node.type}: ${nodeId}`); + } + + const component = figma.createComponentFromNode(node); + const name = request.params?.name; + if (typeof name === "string") { + component.name = name; + } + + return { + type: request.type, + requestId: request.requestId, + data: { + nodeId: component.id, + nodeName: component.name, + parentId: component.parent?.id, + x: component.x, + y: component.y, + width: component.width, + height: component.height, + }, + }; + } + case "combine_as_variants": { + if (!request.nodeIds || request.nodeIds.length < 2) { + throw new Error( + "At least 2 nodeIds are required for combine_as_variants" + ); + } + + const components = await Promise.all( + request.nodeIds.map((nodeId) => getSceneNodeById(nodeId)) + ); + for (const c of components) { + if (c.type !== "COMPONENT") { + throw new Error( + `combine_as_variants requires COMPONENT nodes; got ${c.type} for ${c.id}. Convert with create_component_from_node first.` + ); + } + } + + const explicitParentId = request.params?.parentId; + const parent = + typeof explicitParentId === "string" + ? await getParentNodeById(explicitParentId) + : figma.currentPage; + + const set = figma.combineAsVariants( + components as ComponentNode[], + parent + ); + + const name = request.params?.name; + if (typeof name === "string") { + set.name = name; + } + + // combineAsVariants stacks all children at (0,0) — lay them out and + // resize the set from actual child bounds, as Figma requires. + const layout = request.params?.layout === "COLUMN" ? "COLUMN" : "ROW"; + const gap = + typeof request.params?.gap === "number" ? request.params.gap : 100; + + let cursor = 0; + let maxCross = 0; + for (const child of set.children) { + if (layout === "ROW") { + child.x = cursor; + child.y = 0; + cursor += child.width + gap; + maxCross = Math.max(maxCross, child.height); + } else { + child.x = 0; + child.y = cursor; + cursor += child.height + gap; + maxCross = Math.max(maxCross, child.width); + } + } + cursor = Math.max(cursor - gap, 0); + + const setWidth = layout === "ROW" ? cursor + 40 : maxCross + 40; + const setHeight = layout === "ROW" ? maxCross + 40 : cursor + 40; + set.resizeWithoutConstraints(Math.max(setWidth, 1), Math.max(setHeight, 1)); + + return { + type: request.type, + requestId: request.requestId, + data: { + componentSetId: set.id, + nodeName: set.name, + parentId: set.parent?.id, + variantIds: set.children.map((c) => c.id), + x: set.x, + y: set.y, + width: set.width, + height: set.height, + }, + }; + } + case "set_reactions": { + const nodeId = request.nodeIds && request.nodeIds[0]; + if (!nodeId) { + throw new Error("nodeIds is required for set_reactions"); + } + const reactions = request.params?.reactions; + if (!Array.isArray(reactions)) { + throw new Error( + "params.reactions (array) is required for set_reactions" + ); + } + + const node = await getSceneNodeById(nodeId); + if (!("setReactionsAsync" in node)) { + throw new Error(`Node does not support reactions: ${nodeId}`); + } + const target = node as SceneNode & { + setReactionsAsync: (reactions: unknown[]) => Promise; + }; + await target.setReactionsAsync(reactions); + + return { + type: request.type, + requestId: request.requestId, + data: { + nodeId: node.id, + reactionCount: reactions.length, + }, + }; + } case "set_selection": { const ids = request.nodeIds ?? []; const nodes: SceneNode[] = []; diff --git a/plugin/src/ui/App.tsx b/plugin/src/ui/App.tsx index 1bda7d3..3df60cc 100644 --- a/plugin/src/ui/App.tsx +++ b/plugin/src/ui/App.tsx @@ -29,7 +29,10 @@ type RequestType = | "ungroup_node" | "set_selection" | "scroll_and_zoom_into_view" - | "delete_nodes"; + | "delete_nodes" + | "create_component_from_node" + | "combine_as_variants" + | "set_reactions"; type ServerRequest = { type: RequestType; diff --git a/server/src/schema.ts b/server/src/schema.ts index 7ec43f9..9824c22 100644 --- a/server/src/schema.ts +++ b/server/src/schema.ts @@ -820,6 +820,57 @@ export const toolInputSchemas = { duration: z.number().positive().describe("The new timeline duration in seconds (must be greater than zero)"), fileKey: fileKeyField, }), + + create_component_from_node: z.object({ + nodeId: createFigmaNodeIdSchema().describe( + "Frame (or other eligible node) to convert into a Component, in place. The node must not already be a component/component set and must not be nested inside one." + ), + name: z + .string() + .optional() + .describe( + "Optional new name for the component. Use this to encode a variant property before combine_as_variants, e.g. 'State=Public'." + ), + fileKey: fileKeyField, + }), + + combine_as_variants: z.object({ + nodeIds: z + .array(createFigmaNodeIdSchema()) + .min(2) + .describe( + "Component node IDs to combine into one Component Set. Each must already be type COMPONENT (use create_component_from_node first). Each component's current name should encode its variant property, e.g. 'State=Public' / 'State=Admin'." + ), + parentId: createFigmaNodeIdSchema() + .optional() + .describe( + "Optional explicit parent for the new Component Set. Defaults to the current page." + ), + name: z + .string() + .optional() + .describe("Optional name for the resulting Component Set, e.g. 'Landing'."), + layout: z + .enum(["ROW", "COLUMN"]) + .optional() + .describe( + "How to arrange the variants after combining — Figma stacks them at (0,0) by default and this tool lays them out automatically. Defaults to ROW." + ), + gap: z.number().optional().describe("Gap in pixels between variants. Defaults to 100."), + fileKey: fileKeyField, + }), + + set_reactions: z.object({ + nodeId: createFigmaNodeIdSchema().describe( + "Node to attach prototype reactions to (e.g. a button, link, or card)." + ), + reactions: z + .array(z.record(z.unknown())) + .describe( + "Full array of Figma Reaction objects to set on the node — this REPLACES all existing reactions, it is not a delta. Shape: [{ trigger: { type: 'ON_CLICK' | 'ON_HOVER' | ... }, actions: [{ type: 'NODE', destinationId: '', navigation: 'NAVIGATE' | 'CHANGE_TO' | ..., transition: null | {...} }] }]." + ), + fileKey: fileKeyField, + }), } as const; type ToolName = keyof typeof toolInputSchemas; @@ -878,6 +929,12 @@ const rpcToArgs: Record< apply_manual_keyframe_track: (nodeIds, params) => ({ ...params, nodeId: nodeIds?.[0] }), remove_manual_keyframe_track: (nodeIds, params) => ({ ...params, nodeId: nodeIds?.[0] }), set_timeline_duration: (nodeIds, params) => ({ ...params, nodeId: nodeIds?.[0] }), + create_component_from_node: (nodeIds, params) => ({ + ...params, + nodeId: nodeIds?.[0], + }), + combine_as_variants: (nodeIds, params) => ({ nodeIds, ...params }), + set_reactions: (nodeIds, params) => ({ ...params, nodeId: nodeIds?.[0] }), }; /** diff --git a/server/src/tools.ts b/server/src/tools.ts index 2637bc2..b6ac230 100644 --- a/server/src/tools.ts +++ b/server/src/tools.ts @@ -460,6 +460,44 @@ export function registerTools( } ); + server.tool( + "create_component_from_node", + "Convert an existing frame (or other eligible node) into a real Figma Component, in place — preserves children, position, and size. Use this before combine_as_variants to build a variant set, or standalone for a single reusable component. Throws if the node is already a component/component set or is nested inside one.", + toolInputSchemas.create_component_from_node.shape, + async ({ nodeId, name, fileKey }): Promise => { + return renderResponse(() => + node.sendWithParams("create_component_from_node", [nodeId], { name }, fileKey) + ); + } + ); + + server.tool( + "combine_as_variants", + "Combine two or more existing Components into a single Component Set (Figma's native Variants feature). Each input node must already be a COMPONENT — convert frames first with create_component_from_node. Automatically lays out and resizes the resulting set (raw combineAsVariants output stacks at (0,0), which this tool corrects).", + toolInputSchemas.combine_as_variants.shape, + async ({ nodeIds, parentId, name, layout, gap, fileKey }): Promise => { + return renderResponse(() => + node.sendWithParams( + "combine_as_variants", + nodeIds, + { parentId, name, layout, gap }, + fileKey + ) + ); + } + ); + + server.tool( + "set_reactions", + "Set Figma prototype reactions (interactions) on a node — e.g. on-click navigation to another frame, on-hover states. Replaces ALL existing reactions on the node with the given array (not a delta/append).", + toolInputSchemas.set_reactions.shape, + async ({ nodeId, reactions, fileKey }): Promise => { + return renderResponse(() => + node.sendWithParams("set_reactions", [nodeId], { reactions }, fileKey) + ); + } + ); + server.tool( "set_selection", "Set the current page selection to a list of node IDs. Pass an empty array to clear the selection. Works in both design editor and Dev Mode.",