Skip to content

Commit 5405744

Browse files
committed
feat(m2): add prototype + component-nav write tools (48/52)
set_reactions (setReactionsAsync; convert.ts gains toFigmaReaction, a permissive pass-through meant for round-tripping get_reactions), remove_reactions (clears via empty array). swap_component (componentKey via importComponentByKeyAsync, or a local componentId) and detach_instance (returns the new frame). 397 tests pass (+9); typecheck / oxlint / plugin+server build all green. Verified by unit tests only — real-Figma smoke deferred to the end-of-M2 one-shot pass. PLAN.md M2 section synced.
1 parent 4a8d40c commit 5405744

15 files changed

Lines changed: 415 additions & 0 deletions

packages/plugin/src/code.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { createDeleteNodesHandler } from './handlers/delete-nodes.js';
2121
import { createDeletePageHandler } from './handlers/delete-page.js';
2222
import { createDeleteStyleHandler } from './handlers/delete-style.js';
2323
import { createDeleteVariableHandler } from './handlers/delete-variable.js';
24+
import { createDetachInstanceHandler } from './handlers/detach-instance.js';
2425
import { createFindReplaceTextHandler } from './handlers/find-replace-text.js';
2526
import { createGroupNodesHandler } from './handlers/group-nodes.js';
2627
import { createNavigateToPageHandler } from './handlers/navigate-to-page.js';
@@ -46,6 +47,7 @@ import { createScanTextNodesHandler } from './handlers/scan-text-nodes.js';
4647
import { createSearchNodesHandler } from './handlers/search-nodes.js';
4748
import { createSetLockedHandler } from './handlers/lock-nodes.js';
4849
import { createMoveNodesHandler } from './handlers/move-nodes.js';
50+
import { createRemoveReactionsHandler } from './handlers/remove-reactions.js';
4951
import { createRenameNodeHandler } from './handlers/rename-node.js';
5052
import { createRenamePageHandler } from './handlers/rename-page.js';
5153
import { createReorderNodesHandler } from './handlers/reorder-nodes.js';
@@ -57,12 +59,14 @@ import { createSetBlendModeHandler } from './handlers/set-blend-mode.js';
5759
import { createSetConstraintsHandler } from './handlers/set-constraints.js';
5860
import { createSetCornerRadiusHandler } from './handlers/set-corner-radius.js';
5961
import { createSetEffectsHandler } from './handlers/set-effects.js';
62+
import { createSetReactionsHandler } from './handlers/set-reactions.js';
6063
import { createSetFillsHandler } from './handlers/set-fills.js';
6164
import { createSetOpacityHandler } from './handlers/set-opacity.js';
6265
import { createSetStrokesHandler } from './handlers/set-strokes.js';
6366
import { createSetTextHandler } from './handlers/set-text.js';
6467
import { createSetVariableValueHandler } from './handlers/set-variable-value.js';
6568
import { createSetVisibleHandler } from './handlers/set-visible.js';
69+
import { createSwapComponentHandler } from './handlers/swap-component.js';
6670
import { createUngroupNodesHandler } from './handlers/ungroup-nodes.js';
6771
import { createUpdatePaintStyleHandler } from './handlers/update-paint-style.js';
6872

@@ -166,6 +170,11 @@ const handlers: SandboxHandlers = {
166170
delete_page: idempotent(idempotencyCache, createDeletePageHandler(figma)),
167171
rename_page: idempotent(idempotencyCache, createRenamePageHandler(figma)),
168172
navigate_to_page: idempotent(idempotencyCache, createNavigateToPageHandler(figma)),
173+
// Prototype + components
174+
set_reactions: idempotent(idempotencyCache, createSetReactionsHandler(figma)),
175+
remove_reactions: idempotent(idempotencyCache, createRemoveReactionsHandler(figma)),
176+
swap_component: idempotent(idempotencyCache, createSwapComponentHandler(figma)),
177+
detach_instance: idempotent(idempotencyCache, createDetachInstanceHandler(figma)),
169178
};
170179

171180
figma.ui.onmessage = (raw: unknown) => {

packages/plugin/src/handlers/convert.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import type {
2+
SerializedAction,
23
SerializedEffect,
34
SerializedLayoutGrid,
45
SerializedLineHeight,
6+
SerializedReaction,
7+
SerializedTrigger,
58
SerializedVariableValue,
69
} from '@figma-mcp-relay/shared';
710

@@ -66,3 +69,26 @@ export const toFigmaVariableValue = (value: SerializedVariableValue): VariableVa
6669
}
6770
return value;
6871
};
72+
73+
// Reactions: the Figma Action/Trigger types are strict discriminated unions, so we pass through the
74+
// fields we serialized and cast — set_reactions is meant for round-tripping get_reactions output.
75+
const toFigmaTrigger = (t: SerializedTrigger | null): Trigger | null => {
76+
if (t === null) return null;
77+
const out: Record<string, unknown> = { type: t.type };
78+
if (t.timeout !== undefined) out.timeout = t.timeout;
79+
if (t.delay !== undefined) out.delay = t.delay;
80+
return out as unknown as Trigger;
81+
};
82+
83+
const toFigmaAction = (a: SerializedAction): Action => {
84+
const out: Record<string, unknown> = { type: a.type };
85+
if (a.destinationId !== undefined) out.destinationId = a.destinationId;
86+
if (a.navigation !== undefined) out.navigation = a.navigation;
87+
if (a.url !== undefined) out.url = a.url;
88+
if (a.transition !== undefined) out.transition = a.transition;
89+
return out as unknown as Action;
90+
};
91+
92+
/** Wire reaction → Figma Reaction (modern `actions` array form). */
93+
export const toFigmaReaction = (r: SerializedReaction): Reaction =>
94+
({ trigger: toFigmaTrigger(r.trigger), actions: r.actions.map(toFigmaAction) }) as unknown as Reaction;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { CreateResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
5+
/** Detach an instance into a plain frame. Returns the resulting frame's id / name / type. */
6+
export const createDetachInstanceHandler =
7+
(figmaCtx: typeof figma): SandboxToolHandler =>
8+
async params => {
9+
const p = (params ?? {}) as { instanceId?: unknown };
10+
if (typeof p.instanceId !== 'string') {
11+
throw new TypeError('detach_instance: instanceId must be a string');
12+
}
13+
14+
const instance = await figmaCtx.getNodeByIdAsync(p.instanceId);
15+
if (instance === null || instance.type !== 'INSTANCE') {
16+
throw new Error(`detach_instance: node ${p.instanceId} is not an INSTANCE`);
17+
}
18+
const frame = (instance as InstanceNode).detachInstance();
19+
20+
const result: CreateResult = { ok: true, nodeId: frame.id, name: frame.name, type: frame.type };
21+
return result;
22+
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
5+
/** Clear all prototype reactions from a node. */
6+
export const createRemoveReactionsHandler =
7+
(figmaCtx: typeof figma): SandboxToolHandler =>
8+
async params => {
9+
const p = (params ?? {}) as { nodeId?: unknown };
10+
if (typeof p.nodeId !== 'string') throw new TypeError('remove_reactions: nodeId must be a string');
11+
12+
const node = await figmaCtx.getNodeByIdAsync(p.nodeId);
13+
if (node === null || typeof (node as { setReactionsAsync?: unknown }).setReactionsAsync !== 'function') {
14+
throw new Error(`remove_reactions: node ${p.nodeId} not found or cannot have reactions`);
15+
}
16+
await (node as ReactionMixin).setReactionsAsync([]);
17+
18+
const result: MutateResult = { ok: true, nodeId: node.id };
19+
return result;
20+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { MutateResult, SerializedReaction } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
import { toFigmaReaction } from './convert.js';
5+
6+
/** Replace a node's prototype reactions. Uses setReactionsAsync (required under dynamic-page). */
7+
export const createSetReactionsHandler =
8+
(figmaCtx: typeof figma): SandboxToolHandler =>
9+
async params => {
10+
const p = (params ?? {}) as { nodeId?: unknown; reactions?: unknown };
11+
if (typeof p.nodeId !== 'string') throw new TypeError('set_reactions: nodeId must be a string');
12+
if (!Array.isArray(p.reactions)) throw new TypeError('set_reactions: reactions must be an array');
13+
14+
const node = await figmaCtx.getNodeByIdAsync(p.nodeId);
15+
if (node === null || typeof (node as { setReactionsAsync?: unknown }).setReactionsAsync !== 'function') {
16+
throw new Error(`set_reactions: node ${p.nodeId} not found or cannot have reactions`);
17+
}
18+
await (node as ReactionMixin).setReactionsAsync(
19+
(p.reactions as SerializedReaction[]).map(toFigmaReaction),
20+
);
21+
22+
const result: MutateResult = { ok: true, nodeId: node.id };
23+
return result;
24+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
3+
import type { SandboxToolHandler } from '../dispatcher.js';
4+
5+
/**
6+
* Swap an instance's main component. Provide a published `componentKey` (imported via the API) or a
7+
* local `componentId`. Returns the (unchanged) instance id.
8+
*/
9+
export const createSwapComponentHandler =
10+
(figmaCtx: typeof figma): SandboxToolHandler =>
11+
async params => {
12+
const p = (params ?? {}) as { instanceId?: unknown; componentId?: unknown; componentKey?: unknown };
13+
if (typeof p.instanceId !== 'string') {
14+
throw new TypeError('swap_component: instanceId must be a string');
15+
}
16+
if (typeof p.componentId !== 'string' && typeof p.componentKey !== 'string') {
17+
throw new TypeError('swap_component: provide componentId or componentKey');
18+
}
19+
20+
const instance = await figmaCtx.getNodeByIdAsync(p.instanceId);
21+
if (instance === null || instance.type !== 'INSTANCE') {
22+
throw new Error(`swap_component: node ${p.instanceId} is not an INSTANCE`);
23+
}
24+
25+
let component: ComponentNode;
26+
if (typeof p.componentKey === 'string') {
27+
component = await figmaCtx.importComponentByKeyAsync(p.componentKey);
28+
} else {
29+
const node = await figmaCtx.getNodeByIdAsync(p.componentId as string);
30+
if (node === null || node.type !== 'COMPONENT') {
31+
throw new Error(`swap_component: component ${String(p.componentId)} not found`);
32+
}
33+
component = node as ComponentNode;
34+
}
35+
(instance as InstanceNode).swapComponent(component);
36+
37+
const result: MutateResult = { ok: true, nodeId: instance.id };
38+
return result;
39+
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { CreateResult } from '@figma-mcp-relay/shared';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import { createDetachInstanceHandler } from '../../src/handlers/detach-instance.js';
5+
6+
const withNode = (node: unknown): typeof figma =>
7+
({ getNodeByIdAsync: async () => node }) as unknown as typeof figma;
8+
9+
describe('detach_instance handler', () => {
10+
it('detaches an instance and returns the resulting frame', async () => {
11+
const frame = { id: 'F:1', name: 'Card', type: 'FRAME' };
12+
const detachInstance = vi.fn<() => typeof frame>(() => frame);
13+
const instance = { id: '1:1', type: 'INSTANCE', detachInstance };
14+
const f = { getNodeByIdAsync: async () => instance } as unknown as typeof figma;
15+
16+
const result = (await createDetachInstanceHandler(f)({ instanceId: '1:1' })) as CreateResult;
17+
expect(detachInstance).toHaveBeenCalledOnce();
18+
expect(result).toEqual({ ok: true, nodeId: 'F:1', name: 'Card', type: 'FRAME' });
19+
});
20+
21+
it('throws on bad input or non-instance', async () => {
22+
await expect(createDetachInstanceHandler(withNode({}))({})).rejects.toThrow(/instanceId/);
23+
await expect(
24+
createDetachInstanceHandler(withNode({ id: '1:1', type: 'FRAME' }))({ instanceId: '1:1' }),
25+
).rejects.toThrow(/not an INSTANCE/);
26+
});
27+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import { createRemoveReactionsHandler } from '../../src/handlers/remove-reactions.js';
5+
6+
const withNode = (node: unknown): typeof figma =>
7+
({ getNodeByIdAsync: async () => node }) as unknown as typeof figma;
8+
9+
describe('remove_reactions handler', () => {
10+
it('clears reactions by setting an empty array', async () => {
11+
const setReactionsAsync = vi.fn<() => Promise<void>>(async () => {});
12+
const node = { id: '1:1', setReactionsAsync };
13+
14+
const result = (await createRemoveReactionsHandler(withNode(node))({
15+
nodeId: '1:1',
16+
})) as MutateResult;
17+
expect(setReactionsAsync).toHaveBeenCalledWith([]);
18+
expect(result).toEqual({ ok: true, nodeId: '1:1' });
19+
});
20+
21+
it('throws on bad input or unsupported node', async () => {
22+
await expect(createRemoveReactionsHandler(withNode({}))({})).rejects.toThrow(/nodeId/);
23+
await expect(
24+
createRemoveReactionsHandler(withNode({ id: '1:1' }))({ nodeId: '1:1' }),
25+
).rejects.toThrow(/cannot have reactions/);
26+
});
27+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import { createSetReactionsHandler } from '../../src/handlers/set-reactions.js';
5+
6+
const withNode = (node: unknown): typeof figma =>
7+
({ getNodeByIdAsync: async () => node }) as unknown as typeof figma;
8+
9+
describe('set_reactions handler', () => {
10+
it('converts and applies reactions via setReactionsAsync', async () => {
11+
const setReactionsAsync = vi.fn<() => Promise<void>>(async () => {});
12+
const node = { id: '1:1', setReactionsAsync };
13+
const f = { getNodeByIdAsync: async () => node } as unknown as typeof figma;
14+
15+
const result = (await createSetReactionsHandler(f)({
16+
nodeId: '1:1',
17+
reactions: [
18+
{
19+
trigger: { type: 'ON_CLICK' },
20+
actions: [{ type: 'NODE', destinationId: '2:2', navigation: 'NAVIGATE' }],
21+
},
22+
],
23+
})) as MutateResult;
24+
25+
expect(setReactionsAsync).toHaveBeenCalledWith([
26+
{
27+
trigger: { type: 'ON_CLICK' },
28+
actions: [{ type: 'NODE', destinationId: '2:2', navigation: 'NAVIGATE' }],
29+
},
30+
]);
31+
expect(result).toEqual({ ok: true, nodeId: '1:1' });
32+
});
33+
34+
it('throws on bad input or unsupported node', async () => {
35+
await expect(createSetReactionsHandler(withNode({}))({ reactions: [] })).rejects.toThrow(/nodeId/);
36+
await expect(
37+
createSetReactionsHandler(withNode({}))({ nodeId: '1:1', reactions: 'x' }),
38+
).rejects.toThrow(/reactions/);
39+
await expect(
40+
createSetReactionsHandler(withNode({ id: '1:1' }))({ nodeId: '1:1', reactions: [] }),
41+
).rejects.toThrow(/cannot have reactions/);
42+
});
43+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { MutateResult } from '@figma-mcp-relay/shared';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import { createSwapComponentHandler } from '../../src/handlers/swap-component.js';
5+
6+
const withNode = (node: unknown): typeof figma =>
7+
({ getNodeByIdAsync: async () => node }) as unknown as typeof figma;
8+
9+
describe('swap_component handler', () => {
10+
it('swaps using a local componentId', async () => {
11+
const swapComponent = vi.fn<() => void>();
12+
const instance = { id: '1:1', type: 'INSTANCE', swapComponent };
13+
const component = { id: 'C:1', type: 'COMPONENT' };
14+
const lookup: Record<string, unknown> = { '1:1': instance, 'C:1': component };
15+
const f = {
16+
getNodeByIdAsync: async (id: string) => lookup[id] ?? null,
17+
} as unknown as typeof figma;
18+
19+
const result = (await createSwapComponentHandler(f)({
20+
instanceId: '1:1',
21+
componentId: 'C:1',
22+
})) as MutateResult;
23+
expect(swapComponent).toHaveBeenCalledWith(component);
24+
expect(result).toEqual({ ok: true, nodeId: '1:1' });
25+
});
26+
27+
it('swaps using a published componentKey', async () => {
28+
const swapComponent = vi.fn<() => void>();
29+
const instance = { id: '1:1', type: 'INSTANCE', swapComponent };
30+
const imported = { id: 'C:9', type: 'COMPONENT' };
31+
const importComponentByKeyAsync = vi.fn<() => Promise<unknown>>(async () => imported);
32+
const f = {
33+
getNodeByIdAsync: async () => instance,
34+
importComponentByKeyAsync,
35+
} as unknown as typeof figma;
36+
37+
await createSwapComponentHandler(f)({ instanceId: '1:1', componentKey: 'abc123' });
38+
expect(importComponentByKeyAsync).toHaveBeenCalledWith('abc123');
39+
expect(swapComponent).toHaveBeenCalledWith(imported);
40+
});
41+
42+
it('throws on bad input or non-instance', async () => {
43+
await expect(createSwapComponentHandler(withNode({}))({ componentId: 'C:1' })).rejects.toThrow(
44+
/instanceId/,
45+
);
46+
await expect(createSwapComponentHandler(withNode({}))({ instanceId: '1:1' })).rejects.toThrow(
47+
/componentId or componentKey/,
48+
);
49+
await expect(
50+
createSwapComponentHandler(withNode({ id: '1:1', type: 'FRAME' }))({
51+
instanceId: '1:1',
52+
componentId: 'C:1',
53+
}),
54+
).rejects.toThrow(/not an INSTANCE/);
55+
});
56+
});

0 commit comments

Comments
 (0)