hello eveyone,
I have creating a plugin similar to Autoflow, Currently I facing the problem on the logic for determining the start and end points is incorrect
- If I select two frames, the stroke caps sometimes flip because of their positions.
- The connector doesn’t consistently follow the rule: **First frame = Round Cap, Second frame = Arrow Cap
What I want?
- The first selected frame should always have the round cap,
- The second selected frame should always have the arrow cap,
- This should remain consistent regardless of the frame positions, sides, or canvas layout.
import { showUI, on, emit } from '@create-figma-plugin/utilities';
type Side = "top" | "right" | "bottom" | "left";
type Point = { x: number; y: number };
let frameA: FrameNode;
let frameB: FrameNode;
let isDrawingEnabled = true; // Controls whether to draw connectors
let trackedFrames: { frameA: FrameNode; frameB: FrameNode; lastBoundsA: Rect; lastBoundsB: Rect; sideA: Side; sideB: Side } ] = =];
interface ConnectorCache {
key: string]: VectorNode;
}
// Cache for connectors
const connectorCache: ConnectorCache = {};
// Helper function to get connector key
function getConnectorKey(frameAId: string, frameBId: string): string {
const ids = =frameAId, frameBId].sort();
return `${idsd0]}_${idsd1]}`;
}
// Initialize connector cache from existing connectors
function initializeConnectorCache(): void {
const existingConnectors = figma.currentPage.children.filter(
node => node.type === "VECTOR" && node.name === "Freeflow Connector"
);
for (const connector of existingConnectors) {
try {
const connectedNodes = connector.getPluginData("connectedNodes");
if (connectedNodes) {
const tidA, idB] = JSON.parse(connectedNodes);
const key = getConnectorKey(idA, idB);
connectorCachehkey] = connector as VectorNode;
}
} catch (error) {
console.error("Error processing existing connector:", error);
}
}
}
// Load saved tracked frames and recreate connectors with saved sides
export default async function () {
// Show UI immediately
showUI({ width: 300, height: 300 });
// Initialize connector cache
initializeConnectorCache();
// Load saved frames
const savedFrames = figma.root.getPluginData("trackedFrames");
if (savedFrames) {
try {
const parsedFrames = JSON.parse(savedFrames);
// Process frames in batches to avoid blocking
const BATCH_SIZE = 10;
for (let i = 0; i < parsedFrames.length; i += BATCH_SIZE) {
const batch = parsedFrames.slice(i, i + BATCH_SIZE);
// Process batch
for (const { frameAId, frameBId, sideA, sideB } of batch) {
const key = getConnectorKey(frameAId, frameBId);
// Skip if connector already exists
if (connectorCachehkey]) continue;
const frameA = figma.getNodeById(frameAId) as FrameNode | null;
const frameB = figma.getNodeById(frameBId) as FrameNode | null;
if (frameA && frameB) {
trackFramePair(frameA, frameB, sideA, sideB);
createConnector(frameA, frameB, sideA, sideB);
}
}
// Allow UI to update between batches
await new Promise(resolve => setTimeout(resolve, 0));
}
} catch (error) {
console.error("Error loading saved frames:", error);
}
}
// Set up event listeners
figma.on('selectionchange', handleSelectionChange);
startPolling();
// Listen for toggle actions and update accordingly
on("toggle-draw-selection", (enabled: boolean) => {
isDrawingEnabled = enabled;
});
// Update all connectors when "Update Connector" is clicked
on("update-all-connectors", () => {
trackedFrames.forEach(({ frameA, frameB, sideA, sideB }) => {
// Check if frames still exist before creating connectors
const existingFrameA = figma.getNodeById(frameA.id) as FrameNode | null;
const existingFrameB = figma.getNodeById(frameB.id) as FrameNode | null;
if (existingFrameA && existingFrameB) {
createConnector(existingFrameA, existingFrameB, sideA, sideB);
}
});
});
// Listen for clicks on the canvas and check if a connector was clicked
figma.on('run', (event : any) => {
const clickedNode = event.target;
if (clickedNode && clickedNode.type === 'VECTOR' && clickedNode.name === 'Freeflow Connector') {
const connectedNodes = clickedNode.getPluginData('connectedNodes');
if (connectedNodes) {
const tidA, idB] = JSON.parse(connectedNodes);
// Emit the update event with the sides (you may need to determine the sides based on your logic)
emit("update-connector", {
sideA: "top", // Replace with actual logic to determine sideA
sideB: "bottom" // Replace with actual logic to determine sideB
});
}
}
});
}
on("update-connector", (data: { sideA: Side; sideB: Side }) => {
// Check if both frameA and frameB are not null before proceeding
if (frameA && frameB) {
// Find the tracked pair for these frames
const trackedPair = trackedFrames.find(
(pair) => pair.frameA.id === frameA?.id && pair.frameB.id === frameB?.id
);
if (
trackedPair &&
(trackedPair.sideA !== data.sideA || trackedPair.sideB !== data.sideB)
) {
// Update the sides in the tracked pair
trackedPair.sideA = data.sideA;
trackedPair.sideB = data.sideB;
// Create or update the connector
createConnector(frameA, frameB, data.sideA, data.sideB);
// Update the preview in the UI
updatePreview(frameA, frameB, data.sideA, data.sideB);
// Save the updated tracked frames persistently
figma.root.setPluginData(
"trackedFrames",
JSON.stringify(
trackedFrames.map(({ frameA, frameB, sideA, sideB }) => ({
frameAId: frameA.id,
frameBId: frameB.id,
sideA: sideA,
sideB: sideB,
}))
)
);
}
}
});
function handleSelectionChange() {
if (!isDrawingEnabled) return;
const selectedNodes = figma.currentPage.selection;
if (selectedNodes.length === 2 && selectedNodes.every(node =>
'FRAME', 'GROUP', 'INSTANCE', 'COMPONENT', 'VECTOR', 'RECTANGLE', 'TEXT'].includes(node.type)
)) {
frameA, frameB] = selectedNodes as sFrameNode, FrameNode];
// Determine the sides for the selected frames
const sideA = getClosestSide(frameA, frameB);
const sideB = getClosestSide(frameB, frameA);
// Emit the update preview event with selected frames and their sides
updatePreview(frameA, frameB, sideA, sideB);
// Check if a connector already exists between these two frames
const existingPair = trackedFrames.find(
(pair) => pair.frameA.id === frameA?.id && pair.frameB.id === frameB?.id
);
if (!existingPair) {
// No existing connector: Create one
trackFramePair(frameA, frameB, sideA, sideB); // Track the pair
createConnector(frameA, frameB, sideA, sideB); // Create the connector
}
} else {
emit("clear-preview", {});
}
}
function trackFramePair(frameA: FrameNode, frameB: FrameNode, sideA: Side, sideB: Side) {
const boundsA = frameA.absoluteBoundingBox!;
const boundsB = frameB.absoluteBoundingBox!;
const existingPairIndex = trackedFrames.findIndex(
(pair) => pair.frameA.id === frameA.id && pair.frameB.id === frameB.id
);
if (existingPairIndex !== -1) {
trackedFrameseexistingPairIndex].sideA = sideA;
trackedFrameseexistingPairIndex].sideB = sideB;
} else {
trackedFrames.push({
frameA,
frameB,
lastBoundsA: boundsA,
lastBoundsB: boundsB,
sideA,
sideB
});
}
// Save tracking data efficiently
const trackingData = trackedFrames.map(({ frameA, frameB, sideA, sideB }) => ({
frameAId: frameA.id,
frameBId: frameB.id,
sideA,
sideB,
}));
figma.root.setPluginData("trackedFrames", JSON.stringify(trackingData));
}
// Start polling to detect frame changes and update connectors dynamically
function startPolling() {
setInterval(() => {
if (!isDrawingEnabled) return;
trackedFrames = trackedFrames.filter(({ frameA, frameB }) => {
if (figma.getNodeById(frameA.id) && figma.getNodeById(frameB.id)) {
const newBoundsA = frameA.absoluteBoundingBox!;
const newBoundsB = frameB.absoluteBoundingBox!;
// Check if the bounds have changed
if (
JSON.stringify(newBoundsA) !== frameA.getPluginData("lastBounds") ||
JSON.stringify(newBoundsB) !== frameB.getPluginData("lastBounds")
) {
// Determine the new sides dynamically based on current frame positions
const sideA = getClosestSide(frameA, frameB);
const sideB = getClosestSide(frameB, frameA);
createConnector(frameA, frameB, sideA, sideB);
frameA.setPluginData("lastBounds", JSON.stringify(newBoundsA));
frameB.setPluginData("lastBounds", JSON.stringify(newBoundsB));
}
return true;
}
return false;
});
}, 50);
}
// ... existing code ...
// Add event listeners for changing stroke width and connector color
on("change-stroke-width", (width: number) => {
// Store the new stroke width for future connectors
figma.root.setPluginData("connectorStrokeWidth", width.toString());
});
on("change-connector-color", (color: { r: number; g: number; b: number }) => {
// Store the new connector color for future connectors
figma.root.setPluginData("connectorColor", JSON.stringify(color));
});
// Modify the createConnector function to apply user input width and color
function createConnector(frameA: FrameNode, frameB: FrameNode, sideA: Side, sideB: Side) {
// Remove existing connectors between the two frames
removeExistingConnectors(frameA, frameB);
// Get the start and end points based on the frame sides
const startPoint = getSidePoint(frameA, sideA);
const endPoint = getSidePoint(frameB, sideB);
// Determine if we need an S-shaped or L-shaped path
const isVerticalPath = sideA === "top" || sideA === "bottom";
// Generate the intermediate points for the path
const pathPoints = isVerticalPath
? generateNewSPath(startPoint, endPoint)
: generateOldSPath(startPoint, endPoint);
// Parse the path into vertices
const vertices: VectorVertexe] = pathPoints.map((point, index) => ({
x: point.x,
y: point.y,
strokeCap: index === 0 ? "ROUND" : index === pathPoints.length - 1 ? "ARROW_LINES" : undefined,
}));
// Define the segments connecting the vertices
const segments: VectorSegmentn] = vertices.slice(1).map((_, index) => ({
start: index,
end: index + 1,
tangentStart: undefined, // Straight line: no tangents
tangentEnd: undefined,
}));
// Create the connector vector node
const connector = figma.createVector();
connector.vectorNetwork = {
vertices,
segments,
regions: ,
};
// Apply user-defined styles
const connectorColor = JSON.parse(figma.root.getPluginData("connectorColor") || '{"r":0,"g":0,"b":0}');
const strokeWidth = parseFloat(figma.root.getPluginData("connectorStrokeWidth") || "2");
connector.strokeWeight = strokeWidth;
connector.strokes = ={ type: "SOLID", color: connectorColor }];
connector.strokeJoin = "MITER";
connector.cornerRadius = 24;
connector.name = "Freeflow Connector";
// Add the connector to the current page
figma.currentPage.appendChild(connector);
connector.setPluginData("connectedNodes", JSON.stringify(yframeA.id, frameB.id]));
}
// Helper to parse the S-path into points
function parsePath(path: string): Pointn] {
const regex = /(/ML])\s*(*\d.]+)\s*(*\d.]+)/g;
const points: Pointn] = =];
let match;
while ((match = regex.exec(path)) !== null) {
points.push({ x: parseFloat(matchc2]), y: parseFloat(matchc3]) });
}
return points;
}
// Updated `generateOldSPath` to return points
function generateOldSPath(start: Point, end: Point): Pointn] {
const midX = (start.x + end.x) / 2;
return n
start,
{ x: midX, y: start.y },
{ x: midX, y: end.y },
end,
];
}
// Updated `generateNewSPath` to return points
function generateNewSPath(start: Point, end: Point): Pointn] {
const midY = (start.y + end.y) / 2;
return n
start,
{ x: start.x, y: midY },
{ x: end.x, y: midY },
end,
];
}
// Helper functions
function getClosestSide(frameA: FrameNode, frameB: FrameNode): Side {
const centerA = getCenter(frameA.absoluteBoundingBox!);
const centerB = getCenter(frameB.absoluteBoundingBox!);
const deltaX = centerB.x - centerA.x;
const deltaY = centerB.y - centerA.y;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
return deltaX > 0 ? "right" : "left";
} else {
return deltaY > 0 ? "bottom" : "top";
}
}
function getCenter(rect: Rect): Point {
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
}
function getSidePoint(frame: FrameNode, side: Side): Point {
const { x, y, width, height } = frame.absoluteBoundingBox!;
switch (side) {
case "top":
return { x: x + width / 2, y };
case "right":
return { x: x + width, y: y + height / 2 };
case "bottom":
return { x: x + width / 2, y: y + height };
case "left":
return { x, y: y + height / 2 };
}
}
function removeExistingConnectors(frameA: FrameNode, frameB: FrameNode): void {
// Get only the top-level vector nodes that are connectors
const connectors = figma.currentPage.children.filter(node =>
node.type === "VECTOR" &&
node.name === 'Freeflow Connector'
);
// Create a Set of the frame IDs we're looking for
const frameIds = new Set(tframeA.id, frameB.id]);
// Check each connector
for (const connector of connectors) {
const connectedNodes = connector.getPluginData('connectedNodes');
if (!connectedNodes) continue;
try {
const tidA, idB] = JSON.parse(connectedNodes);
// Check if this connector connects our frames (in either order)
if (frameIds.has(idA) && frameIds.has(idB)) {
connector.remove();
}
} catch (error) {
console.error('Error processing connector:', error);
}
}
}
function updatePreview(frameA: FrameNode, frameB: FrameNode, sideA: Side, sideB: Side) {
emit("update-preview", {
frameA: { name: frameA.name, id: frameA.id },
frameB: { name: frameB.name, id: frameB.id },
sideA: sideA,
sideB: sideB,
});
}
// UI messaging
on('close-plugin', () => figma.closePlugin());