Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 146 additions & 1 deletion plugin/src/main/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> & {
format?: "PNG" | "SVG" | "JPG" | "PDF";
Expand Down Expand Up @@ -368,6 +371,9 @@ const EDIT_REQUEST_TYPES = new Set<RequestType>([
"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 => {
Expand Down Expand Up @@ -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);
Comment on lines +1625 to +1630
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));
Comment on lines +1707 to +1709

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<void>;
};
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[] = [];
Expand Down
5 changes: 4 additions & 1 deletion plugin/src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
57 changes: 57 additions & 0 deletions server/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<nodeId>', navigation: 'NAVIGATE' | 'CHANGE_TO' | ..., transition: null | {...} }] }]."
),
fileKey: fileKeyField,
}),
} as const;

type ToolName = keyof typeof toolInputSchemas;
Expand Down Expand Up @@ -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] }),
};

/**
Expand Down
38 changes: 38 additions & 0 deletions server/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToolResult> => {
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<ToolResult> => {
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<ToolResult> => {
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.",
Expand Down