Skip to main content

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



  1. If I select two frames, the stroke caps sometimes flip because of their positions.

  2. The connector doesn’t consistently follow the rule: **First frame = Round Cap, Second frame = Arrow Cap


What I want?



  1. The first selected frame should always have the round cap,

  2. The second selected frame should always have the arrow cap,

  3. 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());

Unfortunately the order of items in the selection array is somewhat random and not guaranteed to match your criteria.


To fix this, you need to keep the plugin open as user is selecting the items and track each selection:



  • User selects a single item

  • The plugin saves the item to a variable as the first selected item

  • User selects second item

  • The plugin now knows which item was selected first and second


Reply