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
2 changes: 1 addition & 1 deletion figma-plugin/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"enableProposedApi": true,
"enablePrivatePluginApi": true,
"permissions": ["currentuser", "activeusers"],
"editorType": ["figma"],
"editorType": ["figma", "figjam"],
"networkAccess": {
"allowedDomains": ["*"],
"reasoning": "This plugin needs to connect to the local MCP server via WebSocket for write operations."
Expand Down
348 changes: 348 additions & 0 deletions figma-plugin/src/operations/manage-figjam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,348 @@
import { OperationResult } from '../types.js';
import { BaseOperation } from './base-operation.js';
import { findNodeById, formatNodeResponse, moveNodeToPosition, resizeNode } from '../utils/node-utils.js';
import { hexToRgb, createSolidPaint } from '../utils/color-utils.js';
import { findSmartPosition } from '../utils/smart-positioning.js';
import {
handleBulkError,
createBulkSummary
} from '../utils/bulk-operations.js';
import { normalizeToArray } from '../utils/paint-properties.js';

/**
* Handle MANAGE_FIGJAM operation
* FigJam-specific operations: create_sticky, create_connector, create_shape_with_text,
* update_sticky, update_shape_with_text
*/
export async function MANAGE_FIGJAM(params: any): Promise<OperationResult> {
return BaseOperation.executeOperation('manageFigjam', params, async () => {
if (!params.operation) {
throw new Error('operation parameter is required');
}

const validOperations = [
'create_sticky', 'create_connector', 'create_shape_with_text',
'update_sticky', 'update_shape_with_text'
];

if (!validOperations.includes(params.operation)) {
throw new Error(`Unknown figjam operation: ${params.operation}. Valid operations: ${validOperations.join(', ')}`);
}

switch (params.operation) {
case 'create_sticky':
return await createSticky(params);
case 'create_connector':
return await createConnector(params);
case 'create_shape_with_text':
return await createShapeWithText(params);
case 'update_sticky':
return await updateSticky(params);
case 'update_shape_with_text':
return await updateShapeWithText(params);
default:
throw new Error(`Unknown figjam operation: ${params.operation}`);
}
});
}

/**
* Add a node to a parent container or current page
*/
function addToParent(node: SceneNode, parentId?: string): void {
if (parentId) {
const parentNode = findNodeById(parentId);
if (!parentNode) {
throw new Error(`Parent node with ID ${parentId} not found`);
}
const containerTypes = ['PAGE', 'SECTION', 'FRAME', 'GROUP'];
if (!containerTypes.includes(parentNode.type)) {
throw new Error(`Parent node type '${parentNode.type}' cannot contain FigJam nodes. Valid: ${containerTypes.join(', ')}`);
}
(parentNode as BaseNode & ChildrenMixin).appendChild(node);
} else {
figma.currentPage.appendChild(node);
}
}

async function createSticky(params: any): Promise<OperationResult> {
const results: any[] = [];
const texts = normalizeToArray(params.text || params.name || 'Sticky');
const count = texts.length;

for (let i = 0; i < count; i++) {
try {
const sticky = figma.createSticky();

// Set text content
const text = Array.isArray(texts) ? texts[i] : texts;
await figma.loadFontAsync(sticky.text.fontName as FontName);
sticky.text.characters = String(text);

// Set name
const name = Array.isArray(params.name) ? params.name[i] : params.name;
if (name) sticky.name = name;

// Set sticky color
const color = Array.isArray(params.stickyColor) ? params.stickyColor[i] : params.stickyColor;
if (color) {
const validColors: StickyNode['color'] [] = ['GRAY', 'ORANGE', 'YELLOW', 'GREEN', 'CYAN', 'BLUE', 'VIOLET', 'PINK', 'RED', 'LIGHT_GRAY', 'TEAL'];
const upperColor = String(color).toUpperCase() as StickyNode['color'];
if (validColors.includes(upperColor)) {
sticky.color = upperColor;
}
}

// Set author visibility
const authorVisible = Array.isArray(params.authorVisible) ? params.authorVisible[i] : params.authorVisible;
if (authorVisible !== undefined) {
sticky.authorVisible = authorVisible;
}

addToParent(sticky, params.parentId);

// Position
const x = Array.isArray(params.x) ? params.x[i] : params.x;
const y = Array.isArray(params.y) ? params.y[i] : params.y;
if (x !== undefined || y !== undefined) {
moveNodeToPosition(sticky, x || 0, y || 0);
} else {
const smartPos = findSmartPosition({ width: 200, height: 200 }, figma.currentPage);
moveNodeToPosition(sticky, smartPos.x, smartPos.y);
}

// Font size
const fontSize = Array.isArray(params.fontSize) ? params.fontSize[i] : params.fontSize;
if (fontSize) {
await figma.loadFontAsync(sticky.text.fontName as FontName);
sticky.text.fontSize = fontSize;
}

results.push(formatNodeResponse(sticky));
} catch (error) {
handleBulkError(error, `sticky_${i}`, results);
}
}

return createBulkSummary(results, count);
}

async function createConnector(params: any): Promise<OperationResult> {
return BaseOperation.executeOperation('createConnector', params, async () => {
const connector = figma.createConnector();

// Connect start
if (params.startNodeId) {
const startNode = findNodeById(params.startNodeId);
if (!startNode) throw new Error(`Start node ${params.startNodeId} not found`);
connector.connectorStart = {
endpointNodeId: startNode.id,
magnet: params.startMagnet || 'AUTO'
};
}

// Connect end
if (params.endNodeId) {
const endNode = findNodeById(params.endNodeId);
if (!endNode) throw new Error(`End node ${params.endNodeId} not found`);
connector.connectorEnd = {
endpointNodeId: endNode.id,
magnet: params.endMagnet || 'AUTO'
};
}

// Connector type
if (params.connectorType) {
const validTypes = ['ELBOWED', 'STRAIGHT', 'CURVE'];
if (validTypes.includes(params.connectorType)) {
connector.connectorLineType = params.connectorType as ConnectorNode['connectorLineType'];
}
}

// Stroke color
if (params.strokeColor) {
const paint = createSolidPaint(params.strokeColor);
connector.strokes = [paint];
}

// Stroke weight
if (params.strokeWeight !== undefined) {
connector.strokeWeight = params.strokeWeight;
}

// Name
if (params.name) {
connector.name = params.name;
}

// Text on connector
if (params.text) {
await figma.loadFontAsync(connector.text.fontName as FontName);
connector.text.characters = params.text;
}

return formatNodeResponse(connector);
});
}

async function createShapeWithText(params: any): Promise<OperationResult> {
const results: any[] = [];
const texts = normalizeToArray(params.text || params.name || 'Shape');
const count = texts.length;

for (let i = 0; i < count; i++) {
try {
// Determine shape type
const shapeType = Array.isArray(params.shapeType) ? params.shapeType[i] : (params.shapeType || 'ROUNDED_RECTANGLE');
const validShapes = [
'SQUARE', 'ELLIPSE', 'ROUNDED_RECTANGLE', 'DIAMOND',
'TRIANGLE_UP', 'TRIANGLE_DOWN', 'PARALLELOGRAM_RIGHT', 'PARALLELOGRAM_LEFT',
'ENG_DATABASE', 'ENG_QUEUE', 'ENG_FILE', 'ENG_FOLDER'
];

let figmaShapeType = String(shapeType).toUpperCase();
if (!validShapes.includes(figmaShapeType)) {
figmaShapeType = 'ROUNDED_RECTANGLE';
}

const shape = figma.createShapeWithText();
shape.shapeType = figmaShapeType as any;

// Set text
const text = Array.isArray(texts) ? texts[i] : texts;
await figma.loadFontAsync(shape.text.fontName as FontName);
shape.text.characters = String(text);

// Name
const name = Array.isArray(params.name) ? params.name[i] : params.name;
if (name) shape.name = name;

// Fill color
const fillColor = Array.isArray(params.fillColor) ? params.fillColor[i] : params.fillColor;
if (fillColor) {
const paint = createSolidPaint(fillColor);
shape.fills = [paint];
}

addToParent(shape, params.parentId);

// Position
const x = Array.isArray(params.x) ? params.x[i] : params.x;
const y = Array.isArray(params.y) ? params.y[i] : params.y;
if (x !== undefined || y !== undefined) {
moveNodeToPosition(shape, x || 0, y || 0);
} else {
const smartPos = findSmartPosition({ width: 200, height: 100 }, figma.currentPage);
moveNodeToPosition(shape, smartPos.x, smartPos.y);
}

// Size
const width = Array.isArray(params.width) ? params.width[i] : params.width;
const height = Array.isArray(params.height) ? params.height[i] : params.height;
if (width || height) {
resizeNode(shape, width || 200, height || 100);
}

results.push(formatNodeResponse(shape));
} catch (error) {
handleBulkError(error, `shape_${i}`, results);
}
}

return createBulkSummary(results, count);
}

async function updateSticky(params: any): Promise<OperationResult> {
BaseOperation.validateParams(params, ['nodeId']);

const nodeIds = normalizeToArray(params.nodeId);
const results: any[] = [];

for (let i = 0; i < nodeIds.length; i++) {
try {
const node = findNodeById(nodeIds[i]);
if (!node) throw new Error(`Node ${nodeIds[i]} not found`);
if (node.type !== 'STICKY') throw new Error(`Node ${nodeIds[i]} is not a sticky (type: ${node.type})`);

const sticky = node as StickyNode;

// Update text
const text = Array.isArray(params.text) ? params.text[i] : params.text;
if (text !== undefined) {
await figma.loadFontAsync(sticky.text.fontName as FontName);
sticky.text.characters = String(text);
}

// Update color
const color = Array.isArray(params.stickyColor) ? params.stickyColor[i] : params.stickyColor;
if (color) {
sticky.color = String(color).toUpperCase() as StickyNode['color'];
}

// Update position
const x = Array.isArray(params.x) ? params.x[i] : params.x;
const y = Array.isArray(params.y) ? params.y[i] : params.y;
if (x !== undefined || y !== undefined) {
moveNodeToPosition(sticky, x ?? sticky.x, y ?? sticky.y);
}

// Update name
const name = Array.isArray(params.name) ? params.name[i] : params.name;
if (name) sticky.name = name;

results.push(formatNodeResponse(sticky));
} catch (error) {
handleBulkError(error, nodeIds[i], results);
}
}

return createBulkSummary(results, nodeIds.length);
}

async function updateShapeWithText(params: any): Promise<OperationResult> {
BaseOperation.validateParams(params, ['nodeId']);

const nodeIds = normalizeToArray(params.nodeId);
const results: any[] = [];

for (let i = 0; i < nodeIds.length; i++) {
try {
const node = findNodeById(nodeIds[i]);
if (!node) throw new Error(`Node ${nodeIds[i]} not found`);
if (node.type !== 'SHAPE_WITH_TEXT') throw new Error(`Node ${nodeIds[i]} is not a shape_with_text (type: ${node.type})`);

const shape = node as ShapeWithTextNode;

// Update text
const text = Array.isArray(params.text) ? params.text[i] : params.text;
if (text !== undefined) {
await figma.loadFontAsync(shape.text.fontName as FontName);
shape.text.characters = String(text);
}

// Update fill
const fillColor = Array.isArray(params.fillColor) ? params.fillColor[i] : params.fillColor;
if (fillColor) {
const paint = createSolidPaint(fillColor);
shape.fills = [paint];
}

// Update position
const x = Array.isArray(params.x) ? params.x[i] : params.x;
const y = Array.isArray(params.y) ? params.y[i] : params.y;
if (x !== undefined || y !== undefined) {
moveNodeToPosition(shape, x ?? shape.x, y ?? shape.y);
}

// Update name
const name = Array.isArray(params.name) ? params.name[i] : params.name;
if (name) shape.name = name;

results.push(formatNodeResponse(shape));
} catch (error) {
handleBulkError(error, nodeIds[i], results);
}
}

return createBulkSummary(results, nodeIds.length);
}
Loading