<- Back to all posts

Building Ditto's Figma plugin

Jolena Ma
|
April 21, 2021

In January of 2020, we launched Ditto's Figma plugin. Since then, it's been used by thousands of people to manage text in their mockups, keep track of edit history and progress, and design with real content!

When we started building Ditto's Figma plugin over a year ago, Figma's plugin development platform was relatively new. We very quickly faced challenges when trying to implement functionality that few other plugins at the time had, like user authentication or frequent calls to a third-party API. Today, the plugin has become a key part of how we create a seamless experience for our users — they can count on Ditto as the central source of copy truth whether they're working out of our web app or their Figma file.

In this post, we'll share our approach to building Ditto's Figma plugin and do a deep dive into how it works under-the-hood.

A tale of 2 APIs

One of the earliest product decisions we made at Ditto was choosing Figma as our initial integration. As the first realtime collaborative design tool for product teams, Figma made sense as a starting point for achieving our vision at Ditto: to help teams establish a single source of copy truth and make copy a first-class citizen when building product.

In order for Ditto to be a reliable source of truth, we knew that we would need to keep the information in Ditto in sync with what was in Figma. This meant the integration would need to be 2-way: we'd have to be able to send data from Ditto to Figma, and vice versa.

Using Figma's read-only REST API, which had been out for over a year, we could easily fetch information from Figma files. But without write functionality (and no plans from Figma to implement it any time soon), the REST API wasn't going to be enough.

Enter Figma plugins, which had just opened up for beta testing. After some brief exploration, we found that plugins had both read and write access to the Figma file, which meant we would be able to build a plugin to push users' changes in Ditto back to their Figma files. Using Figma's REST API and their plugin API, we'd be able to allow our users to work in web app or the Figma file, without worrying about changes getting out-of-sync or content getting outdated. Within a few days, we got access to the plugin beta, and the rest is history!

Making tradeoffs

We decided to build out an initial version of the plugin using plain javascript. But unlike the simple utility plugins provided as examples by Figma, our plugin needed to support the complex features our existing web app had — things like user authentication, navigation between several pages, regular calls to our Ditto API to read and write data, and dynamic and frequent rendering of large amounts of information in the UI. As a result, our code quickly became unmanageable.

After several conversations and a few painful weeks of developing in plain Javascript, we made the decision to rebuild the entire plugin from the ground up, this time in React. While this set us back several weeks, it was a tradeoff we felt comfortable making to optimize for long-term scalability and faster development down the road. Switching to React allowed us to replace specialized code with reusable components and increase efficiency of UI updates by letting React decide the best way to handle them. It also allowed us to reuse code from our existing web app (which we had built in React) with just a few small tweaks.

Looking back, the time we spent reworking our app has definitely paid off! We've been able to iterate more quickly and maintain a steady stream of improvements for our customers over the past year as a direct result of that decision.

Taking a peek under the hood

Like web development, sorta

One of the first concepts we had to wrap our minds around when we started building was the Figma plugin execution model. While most aspects of plugin development resemble programming for the web, there is one key difference: plugin code runs in a "sandbox" inside of Figma, rather than the browser itself. As a result, this sandbox thread doesn't have access to the DOM or browser APIs and can't make network requests.

This is where Figma gets creative — we can create an iframe that has access to the browser, which then doubles as the plugin window UI. We can pass messages between our sandbox thread (which has access to the Figma plugin API) and our UI thread (which has access to the browser and all of its APIs). Communication between these two threads is bidirectional, so you can do things like:

  • Listen for a user selection in the Figma file, and then display information about that selection in the plugin interface (🏖 sandbox → 🌐 UI)
  • Fetch information from a third-party API, and then update the contents of the Figma file using that info (🌐 UI → 🏖 sandbox)

We take advantage of both directions of communication frequently in Ditto's plugin. To abstract and simplify this message-passing, we use an event emitter utility created by some fellow plugin developers (thanks Matt and Gleb!). This makes our code cleaner, especially when doing asynchronous messaging via async/await.

Building blocks of Figma plugin development

Almost every feature in our plugin involves some combination of the following actions:

  • accessing the contents of the Figma file
  • making a network request
  • programmatically editing the Figma file

Each of these can only be done in one of the two threads (sandbox or UI), and we structure our codebase to make that clear. We have three main top-level directories:

  • app (renders the UI via React and has access to browser APIs),
  • plugin (renders the sandbox thread that has access to Figma's plugin API), and
  • shared (contains code that can be used by either thread).

We use webpack to bundle everything into a single file at runtime.

Below, we'll explain how each of these three processes works in detail by walking through a feature that's core to Ditto — something we call resyncing.

Resyncing

Whenever a user opens Ditto's Figma plugin, we automatically do a "resync" to fetch the latest changes from the web app and update any outdated text in the Figma file. This involves comparing what's in Ditto to what's in Figma so we can keep the two in sync.

Engineering-wise, resyncing can be broken down into three steps:

  1. 🏖 reading the contents of the Figma file (using Figma's plugin API in sandbox thread),
  2. 🌐 sending it to Ditto's API (which returns information on any updates that need to be made) in the UI thread,
  3. 🏖 using the response to update the actual text boxes in the Figma file (using Figma's plugin API in the sandbox thread)

Step 1 of Resyncing: Reading and parsing the contents of the Figma file

Figma's plugin API surfaces the contents of a Figma file via a global object, aptly called figma. The file is represented as a node tree, where each node is a layer that has a type (ex: Page, Frame, Text, etc.) and an ID that's unique within the file.

For Ditto, we mainly care about text nodes and the page and top-level frame that each is on. Starting at the root node, we walk the file tree recursively to find all the nodes of type TEXT, making sure to keep track of the containing PAGE and FRAME nodes.

// sandbox thread
function parseFigmaData() {
	const data = {};
  figma.root.children.forEach(pageNode => {
    data[pageId] = {
      id: pageId,
      frames: []
    };
    pageNode.children.forEach((frameNode) => {
      if (
        frameNode.type === "FRAME" ||
        frameNode.type === "COMPONENT" ||
        frameNode.type === "INSTANCE"
      ) {
        let frameInfo = {
          id: frameNode.id,
          text: [],
        };
        let walker = walkTree(frameNode, true);
        extractTextOnFrame(walker, frameInfo); // gets all text nodes on the frame
        data[pageId].frames.push(frameInfo);
      }
    });
  });
	return data;
}

Step 2 of Resyncing: Querying Ditto's API

Once we have the contents of the Figma file, we need to send it via network request to Ditto's resync API endpoint. One problem — you can't actually make network requests from the plugin code! Because of the two-threaded plugin execution model we mentioned earlier, we actually need to pass the data to the UI thread to make the network request.

Using the utility script, we can do that like so:

// UI thread
io.send("export-figma-data", ""); // ask sandbox for figma data
// sandbox thread
(async () => {
	...
	// listen for request from UI thread, get data, and send it back
	io.on("export-figma-data", (data: string) => {
		const figmaData = parseFigmaData(); // get data using function from above
		io.send("export-figma-data-response", JSON.stringify({ figmaData })
	  );
	});
})();

In the UI thread, we take the message response and send it to Ditto's API's resync endpoint.

// UI thread
// listen for figma data from sandbox thread
const figmaData = await io.async("export-figma-data-response");
// pass it to Ditto API
const response = await fetch("/api/resyncFromFigma/" + docID + "/" + fileID,
	{
    method: "PUT",
    headers,
    body: JSON.stringify({figmaData}),
  });
);

It's important to note is that because a Figma plugin runs inside an iframe that has a null origin, it can only make network requests to APIs that allow access to any origin.

Step 3 of Resyncing: Updating the Figma file

The main reason we built a Figma plugin was so users could update their Figma files with the latest Ditto edits. Figma's plugin API has super powerful write functionality — you can programmatically update almost any layer in a Figma file.

For our purposes, we need to update specific text nodes in a Figma file based on the information we receive from the Ditto API.

The Ditto API's resync endpoint returns an object that looks something like this:

// UI thread
// response from Ditto API:
{
	updates: [
		{
			text: ,
			figmaNodeId: ,
		},
		{...},
	]
}

Each element of the updates array is an object with the unique ID of the Figma node that needs to get changed and what text it should be changed to.

To update the Figma file, we first send the response to the sandbox thread.

// UI thread
io.send("update-file-after-resync", response);

In the sandbox thread, we iterate through the array of updates and use the plugin API to modify the text.

// sandbox thread
await io.async("update-file-after-resync", response => {
	for (const textInfo of response) {
    let { figmaNodeId, text } = textInfo;
		await changeNodeText(figmaNodeId, text);
  }
});

Figma provides two ways to update a text box programmatically: you can set the characters property, or update a specific range of characters. The former sets the styling of all characters to match the first one in the text box, so we do the latter to preserve existing styling as much as possible.

// sandbox thread
const diff = diffChars(text_before, new_text);
let currentIndex = 0;

for (const part of charDiff) {
  if (part.added) {
    (node as TextNode).insertCharacters( currentIndex, part.value, "AFTER" );
    currentIndex += part.count;
  } else if (part.removed) {
    (node as TextNode).deleteCharacters(currentIndex, currentIndex + part.count);
  } else {
    currentIndex += part.count;
  }
}

There are a few subtleties here. We first get get character-level diffs between the existing text and the new text using the diffChars method, and then use the Figma plugin API's insertCharacters() and deleteCharacters()functions to update the text node accordingly. We pass 'AFTER' to insertCharacters() so that the style of inserted characters is copied from the following character.

Before modifying text, we have to check whether the user is missing fonts, and if not, load in the necessary fonts. This is a function of Figma being web-based design tool: because font files can be large in number and file size, fonts are loaded at the time of editing.

Once we've finished updating all the nodes, we can send a message back to the UI thread to let any listeners know that we're done (for example, to indicate that resyncing has finished).

// sandbox thread
io.send('update-file-after-resync-finished', '');

// UI thread
await io.async('update-file-after-resync-finished');
// resyncing has finished and any outdated text has been updated!

With that, we've finished the 3 steps of syncing users' edits in Ditto back to their Figma files! These three processes form the basis of many of the other features of Ditto's plugin, such as the ability to select text in the file and view its Ditto metadata (like status, notes, and tags), batch update text layers, and view and edit all text layers in a selection at once.

Final Thoughts

Much of what we've built for Ditto's Figma plugin has been uncharted territory, both in terms of copy tooling and Figma plugin development. It's been an exciting journey to grow alongside Figma's own plugin platform and even help shape it as an early plugin in the community.

Ultimately, we believe that design tool integrations are fundamentally redefining how product teams work, and we're so excited that we get to build one that helps make copy part of that conversation! We're incredibly thankful for all our users who have stuck with us and use our plugin every day, and can't wait to show you what else we have in store. :)

If being on the frontlines of design tooling sounds fun to you, learn more about Ditto here — we're hiring!