Skip to main content
Question

"Start and End Point Logic Issue in Figma Plugin: Need Help!"

  • December 1, 2024
  • 1 reply
  • 66 views

Vignesh8

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 `${ids[0]}_${ids[1]}`;
}

// 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 [idA, idB] = JSON.parse(connectedNodes);
        const key = getConnectorKey(idA, idB);
        connectorCache[key] = 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 (connectorCache[key]) 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 [idA, 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 [FrameNode, 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) {
    trackedFrames[existingPairIndex].sideA = sideA;
    trackedFrames[existingPairIndex].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: VectorVertex[] = 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: VectorSegment[] = 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([frameA.id, frameB.id]));
}

// Helper to parse the S-path into points
function parsePath(path: string): Point[] {
  const regex = /([ML])\s*([\d.]+)\s*([\d.]+)/g;
  const points: Point[] = [];
  let match;
  while ((match = regex.exec(path)) !== null) {
    points.push({ x: parseFloat(match[2]), y: parseFloat(match[3]) });
  }
  return points;
}

// Updated `generateOldSPath` to return points
function generateOldSPath(start: Point, end: Point): Point[] {
  const midX = (start.x + end.x) / 2;
  return [
    start,
    { x: midX, y: start.y },
    { x: midX, y: end.y },
    end,
  ];
}

// Updated `generateNewSPath` to return points
function generateNewSPath(start: Point, end: Point): Point[] {
  const midY = (start.y + end.y) / 2;
  return [
    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([frameA.id, frameB.id]);
  
  // Check each connector
  for (const connector of connectors) {
    const connectedNodes = connector.getPluginData('connectedNodes');
    if (!connectedNodes) continue;
    
    try {
      const [idA, 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());

1 reply

Gleb
  • Power Member
  • 4706 replies
  • December 1, 2024

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


Cookie policy

We use cookies to enhance and personalize your experience. If you accept you agree to our full cookie policy. Learn more about our cookies.

 
Cookie settings