День только начинался, ничего не предвещало беды, как вдруг в чате с дизайнерами появился вопрос: «Есть ли плагин в фигме, который при наведении на страницу показывает все используемые текстовые стили?..» Гугл такой плагин не нашел, поэтому я вызвалась его написать, предварительно поизучав, как вообще это делается.

В статье рассказываю, как написать плагин, и что это проще, чем звучит на первый взгляд. +1 идея для тех, кому не хватает идей для пет-проектов.

MVP

Театр начинается с вешалки, а разработка приложения с определения минимальных требований.

Выделяем требования

  • При выборе фрейма плагин должен выводить список привязанных текстовых стилей. Если стиль есть, то выводится его название, если стиля нет, то выводится название шрифта;

  • Должно выглядеть, как у привязанных стилей цвета.

При наведении на привязанный цвет появляется иконка с выделением этого слоя.
При наведении на привязанный цвет появляется иконка с выделением этого слоя.

Этих требований пока достаточно, чтобы двигаться дальше и делать жизнеспособную версию, которую потом можно улучшать.

Разбираемся, как устроены плагины в Фигме

Я не буду расписывать процесс запуска плагина, т. к. есть очень подробная пошаговая документация с примером.

Переходим к самой сути, плагин — это просто веб-страница, вставленная в редактор через фрейм ????. Магия разрушена, теперь можно работать с плагином как с обычной веб-страницей (ну почти), есть только свои нюансы по отправке запросов и обращению к окну редактора.

Получаем список нод у выделенного фрагмента

Начнем с простого: при выделении фрейма будем выводить в консоль список дочерних элементов этого фрейма. В дальнейшем такие элементы я буду называть нодами. Для каждой текстовой ноды создадим объект, который описывает нужные нам стили. У него будет поле name, которое содержит название стиля или название шрифта, fontSize — размер шрифта и lineHeight — высота строки.

Код
// получаю выбранный фрейм
const selection = figma.currentPage.selection[0];

const stylesList = selection
	.findAll(node => node.type === 'TEXT')
	.map(textNode => {
		// явно типизирую элемент, чтобы тайпскрипт не подсвечивал ошибку
		const element = textNode as TextNode;
		let name: string;

		// конвертировала в строку, т.к. fontSize - Symbol
		const fontSize = `${element.fontSize.toString()}px`;
		// вынесла утилиту getLineHeight в отдельный файл
		const lineHeight = getLineHeight(element.lineHeight as LineHeight);

		// если у текстовой ноды есть поле textStyleId, значит у нее есть привязанный текстовый стиль
		if (element.textStyleId) {
			name = figma
				.getLocalTextStyles() // встроенный в Фигму метод, который возвращает стили
				.find(style => style.id === element.textStyleId).name || '';
		} else {
			// выделяю дефисами название шрифта, чтобы он визуально отличался от названия стиля
			name = `(-${(element.fontName as FontName).family}-)`;
		}

	return {
		name,
		fontSize,
		lineHeight,
	};
});

console.log(stylesList);
// lineHeight - это объект, у которого есть обязательное поле unit
// если значение lineHeight в процентах или пикселях, то у этого объекта еще есть поле value
export const getLineHeight = (lhObj: LineHeight): string => {
  if (lhObj.unit === 'AUTO') {
    return 'auto';
  } else if (lhObj.unit === 'PERCENT') {
    return `${Math.floor(lhObj.value)}%`;
  } else {
    return `${Math.floor(lhObj.value)}px`;
  }
};

Осталось отправлять этот список в окно плагина. Для показа самого плагина у Фигмы есть методfigma.showUI().

Обмен информацией между ui-частью и логикой реализуется через отправку сообщений postMessage и подпиской на эти сообщения через onmessage.

Схематичное изображение обмена сообщениями.
Схематичное изображение обмена сообщениями.

Отправляем ноды в UI-часть

// событие выбора ноды у меня будет называться 'selectedElement'
figma.ui.postMessage({ type: 'selectedElement', stylesList });

Выводим ноды в интерфейсе плагина

У Фигмы есть свои цвета интерфейса, которые рекомендуется использовать, они вынесены в CSS-переменные. Берем их, чтобы плагин органичнее смотрелся в интерфейсе и цветовая тема менялась автоматически, в зависимости от выбранной юзером темы приложения (темной или светлой). Т. к. стилей немного, препроцессоры подключать не будем, обойдемся CSS.

Разметка и скрипт
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Used text styles plugin</title>
  <style>
    * {
      font-family: 'Inter', 'Helvetica', sans-serif;
      margin: 0;
      padding: 0;
    }

    body {
      background-color: var(--figma-color-bg);
      color: var(--figma-color-text);
      padding: 20px;
      min-height: 600px;
    }

    #styles {
      display: none;
      list-style: none;
      flex-direction: column;
      gap: 10px;
    }

    li {
      display: flex;
      align-items: flex-end;
      gap: 10px;
      font-size: 16px;
      line-height: 20px;
			// использую добавленный в CSS нестинг
      &::before {
        content: '????';
      }
    }

    span:last-of-type {
      font-size: 14px;
      line-height: 18px;
      opacity: 0.5;
    }
  </style>
</head>

<body>
  <p id="instruction">Select frame</p>
  <ul id="styles"></ul>

  <script>
		// здесь наш код
	</script>
</body>

</html>
// формирую список текстовых нод
// (от any избавляюсь на следующей итерации)
const createStylesList = (stylesArr: any[]) => {
  const listElement = document.getElementById("styles");
  listElement.textContent = "";

  const elements = stylesArr.reduce((acc, current) => {
    const element = `
      <li>
        <span>${current.name}</span>
        <span>${current.fontSize}/${current.lineHeight}</span>
      </li>
    `;

    acc += element;

    return acc;
  }, "");

  listElement.insertAdjacentHTML("afterbegin", elements);
  listElement.style.display = "flex";
  document.getElementById("instruction").style.display = "none";
};

// подписываюсь на все сообщения
window.onmessage = async (event) => {
  const message = event.data.pluginMessage;

	// если это было сообщение о выборе элемента, то отрисовываю ноды
  if (message.type === "selectedElement") {
    createStylesList(message.stylesList);
  }
};

Проверяем корнер-кейсы

Немного перепишем код: сначала будем открывать ui-часть, а всю логику вынесем в функцию, которая будет вызываться уже после открытия окна.

Также проверим логику работы. Сейчас выделение у нас работает для всех нод, даже тех, которые не содержат текст, например, геометрические фигуры. Это потенциальный баг, поэтому будем проверять выделенную ноду и, если она не содержит текстовых узлов, выходить из функции. Если при запуске нода не выбрана, будем тоже выходить из функции. Покажем пользователю сообщение, что надо выбрать ноду через встроенный метод figma.notify().

Итоговый код
const showPluginWindow = () => {
  const selection = figma.currentPage.selection[0];

  if (!selection) {
    figma.notify('Select frame or group', {
      timeout: 5000,
    });
    return;
  }

	// написала отдельную утилиту, чтобы проверить,
	// есть ли у ноды текстовые блоки
  if (!supportsChildrenWithText(selection)) {
    return;
  }

  const stylesList = selection
    .findAll(node => node.type === 'TEXT')
    .map(textNode => {
      const element = textNode as TextNode;
      let name: string;

      const fontSize = `${element.fontSize.toString()}px`;
      const lineHeight = getLineHeight(element.lineHeight as LineHeight);

      if (element.textStyleId) {
        name =
          figma
            .getLocalTextStyles()
            .find(style => style.id === element.textStyleId).name || '';
      } else {
        name = `(-${(element.fontName as FontName).family}-)`;
      }

      return {
        name,
        fontSize,
        lineHeight,
      };
    });

  figma.ui.postMessage({ type: 'selectedElement', stylesList });
};

figma.showUI(__html__, { themeColors: true, width: 320, height: 480 });

showPluginWindow();
// проверяею, что выделение работает только для
// фрейма, группы, компонента или его инстанса (копии)
export const supportsChildrenWithText = (
  node: SceneNode
): node is FrameNode | ComponentNode | InstanceNode => {
  return (
    node.type === 'FRAME' ||
    node.type === 'GROUP' ||
    node.type === 'COMPONENT' ||
    node.type === 'INSTANCE'
  );
};

Промежуточные итоги и выводы

Первая версия плагина готова, поэтому отправляем превью с работой плагина «заказчикам» – дизайнерам, которым он был нужен. Так мы соберем обратную связь, чтобы понять, что еще доработать перед публикацией.

Отзывы

Можно сделать выводы, что нужно улучшить ux-часть:

  1. Нужно, чтобы визуально была видна разница, название шрифта это или название стиля, чтобы не вчитываться.

  2. Пользователю будет удобнее при клике по стилю переходить к текстовому элементу, чтобы уже дальше с ним что-то делать, поэтому добавим кнопку перехода.

Улучшаем MVP на основе обратной связи

Настроим сборку, чтобы начать разбивать код на модули и так далее. Чтобы не разбивать сам процесс апгрейда плагина, я перенесла эту часть ближе к концу. Для MVP мы не использовали фреймворк, чтобы как можно быстрее показать его «заказчикам» и собрать обратную связь. Сейчас я тоже не буду его подключать, т. к. в нем нет особой практической необходимости: роутер и стор нам не нужны, мы всего лишь выводим на страницу список элементов, у нас нет сложного ui. Тем более мы почти все уже написали без него.

Добавляем визуальные различия стилей

В первую очередь нас интересует, у какого текста еще нет привязанного стиля, поэтому выделять цветом будем именно такие блоки. Для этого нужно как-то помечать, где name – это название шрифта, а где name – это название стиля. Можно добавить флаг, но мы пойдем другим путем: оставим поле name для стиля, а для шрифта добавим поле fontName. В ui-части будем обрабатывать этот объект и, если нам приходит объект без поля name, то к элементу списка будем добавлять класс .withoutStyle.

Стили .withoutStyle
/* я использую css-модули, поэтому все стили в camelCase */
.withoutStyle {
  font-weight: 500;
  color: var(--figma-color-text-brand);
}

В описании структуры приходящего объекта нам как раз очень поможет Typescript.

Типизация приходящего объекта (TextStyleType.ts)
// одинаковые настройки для обоих видов стилей
export type BaseText = {
  fontSize: string;
  fontWeight: number;
  lineHeight: string;
};

// тип с привязанным стилем
export type TextWithFontStyle = BaseText & {
  name: string;
};

// тип без привязанного стиля
type TextWithoutFontStyle = BaseText & {
  fontName: string;
};

// объект, который я получаю, когда создаю элемент списка
export type TextStyleType = TextWithFontStyle | TextWithoutFontStyle;

Как поменялся stylesList
// добавляю типизацию
const stylesList: TextStyleType[] = selection
    .findAll((node) => node.type === 'TEXT')
    .map((textNode) => {
      const element = textNode as TextNode;

      const styles = {
        id: element.id,
        fontSize: `${element.fontSize.toString()}px`,
        lineHeight: getLineHeight(element.lineHeight as LineHeight),
      };

      if (element.textStyleId) {
        const elementWithId = figma
          .getLocalTextStyles()
          .find((style) => style.id === element.textStyleId)!;
				// по какой-то причине фигма кидает ошибку, если использовать spread-оператор
				// поэтому я дополняю объект "по-старинке"
        return Object.assign(styles, { name: elementWithId.name });
      } else {
				// обновилось поле fontName
        return Object.assign(styles, { fontName: `(-${(element.fontName as FontName).family}-)` });
      }
    });

Вынесем создание элемента списка в отдельную утилиту, она будет возвращать строку с html-тегом.

createListElement.ts
import { TextStyleType } from '@/types';
import { hasTextStyle } from './hasTextStyle';

export const createListElement = (style: TextStyleType) => {
	// проверяю, какой тип приходит: TextWithFontStyle или TextWithoutFontStyle
  const name = hasTextStyle(style) ? style.name : style.fontName;

  return `
    <li>
      <span>${name}</span>
      <span>${style.fontSize}/${style.lineHeight}</span>
    </li>
  `;
};
import { TextStyleType, TextWithFontStyle } from '@/types';

export const hasTextStyle = (style: TextStyleType): style is TextWithFontStyle =>
  Object.hasOwn(style, 'name');

В результате рефакторинга обновленный скрипт выглядит так. Также вынесем типы сообщений в отдельный енам для автоподстановки, избегания случайных опечаток и отлавливания ошибок, если вдруг поле из енама мы удалим, а из кода забудем.

Обновленный скрипт
const createStylesList = (stylesArr: TextStyleType[]) => {
  const listElement = document.getElementById('styles')!;
  listElement.textContent = '';

  const elements = stylesArr.reduce((acc, current) => {
    const element = createListElement(current);
    return (acc += element);
  }, '');

  listElement.insertAdjacentHTML('afterbegin', elements);
  listElement.style.display = 'flex';
  document.getElementById('instruction')!.style.display = 'none';
};


window.onmessage = async (event) => {
  const message = event.data.pluginMessage;

	// вынесла типы сообщений в енам
  if (message.type === MessageTypeEnum.SELECTED_ELEMENT) {
    createStylesList(message.stylesList);
  }
};

Добавляем выбор элемента

Подумаем над логикой: нам нужно при клике по элементу как-то передать информацию об этом элементе скрипту, который отвечает за логику. И потом уже в скрипте обработать переход к элементу, т. к. из ui-части мы не можем управлять элементами Фигмы напрямую. Мы знаем, что у каждой ноды есть id, поэтому при клике мы можем его отправлять, но для этого его нужно сначала передать из части логики.

Обновленный объект style
// code.ts
const styles = {
        id: element.id,
        fontSize: `${element.fontSize.toString()}px`,
        lineHeight: getLineHeight(element.lineHeight as LineHeight),
      };
// TextStyleType.ts

// хорошо, что я типизировала через расширения типов, 
// не пришлось добавлять код в нескольких местах
export type BaseText = {
  id: string;
  fontSize: string;
  lineHeight: string;
};

Обновим также функцию createListElement. Обернем спаны кнопкой (так семантичнее) и добавим иконку. id надо где-то хранить, будем записывать его просто в дата-атрибут. Для написания стилей будем использовать CSS Modules, но можно подключить то, что вам больше нравится.

createListElement
const targetIcon = `
  <svg class="svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
    <path fill-rule="evenodd" stroke="none" d="M15.5 14v-2.975c-2.362.234-4.24 2.113-4.475 4.475H14v1h-2.975c.234 2.362 2.113 4.24 4.475 4.475V18h1v2.975c2.362-.234 4.24-2.113 4.475-4.475H18v-1h2.975c-.234-2.362-2.113-4.24-4.475-4.475V14h-1zm6.48 1.5c-.241-2.915-2.565-5.239-5.48-5.48V8h-1v2.02c-2.915.241-5.239 2.565-5.48 5.48H8v1h2.02c.241 2.915 2.565 5.239 5.48 5.48V24h1v-2.02c2.915-.241 5.239-2.565 5.48-5.48H24v-1h-2.02z"></path>
  </svg>`;

export const createListElement = (style: TextStyleType) => {
  let name: string;
  let textColorClass: string;

  if (hasTextStyle(style)) {
    name = style.name;
    textColorClass = '';
  } else {
    name = style.fontName;
    textColorClass = 'withoutStyle';
  }

  return `
    <li>
      <button class="${styles.button}" data-id="${style.id}">
        <span class="${styles.name} ${textColorClass}">${name}</span>
        <span class="${styles.sizes}">${style.fontSize}/${style.lineHeight}</span>
        ${targetIcon}
      </button>
    </li>
  `;
};

Теперь при клике нужно передать id обратно. Для этого навесим слушатель на весь список (чтобы не добавлять на каждую кнопку свой слушатель) и при клике на элемент будем отправлять id через тот же postMessage.

addListener
const postMessage = (id: string) => {
	// вынесла событие в енам
	// '*' - нужно для корректной работы, иначе Фигма заблокирует запрос
  parent.postMessage({ pluginMessage: { type: MessageTypeEnum.SCROLL_TO, id } }, '*');
};

export const addListener = (event: Event) => {
  const target = event.target as HTMLElement;

  if (!target) {
    return;
  }

	// проверяю, что это именно кнопка (только у нее есть data-id)
  if (target.closest('[data-id]')) {
    const button = target.closest('[data-id]') as HTMLElement;

    if (!button) {
      return;
    }

    postMessage(button.dataset.id!);
  }
};

Добавляем скролл к элементу

В логике подписываемся на событие и добавляем переход к элементу. Чтобы понять, какая функция отвечает за скролл, напишем в поиске по документации «scroll» и просто прочитаем все ссылки. Но при скролле все равно остается выделенным весь фрейм, а нам надо, чтобы был выделен элемент, который мы выбрали. Для этого у Фигмы есть очень неочевидный способ, который подробнее описан здесь, вынесем его сразу в отдельную утилиту, т. к. он нам еще пригодится скорее всего.

Подписка
figma.ui.onmessage = (message) => {
  if (message.type === MessageTypeEnum.SCROLL_TO) {
		// нахожу элемент по id
		// у Фигмы для этого своя функция
    const el = figma.currentPage.findOne((el) => el.id === message.id);

    if (!el) {
      return;
    }

    figma.viewport.scrollAndZoomIntoView([el]);
    updateSelection(figma.currentPage, el);
  }
};
export const updateSelection = (page: PageNode, node: SceneNode) => {
  page.selection = page.selection.concat(node).slice(1);
};

Мы научились скроллить к элементу, но появился баг. При скролле мы выделяем текстовую ноду, поэтому плагин возвращается к стартовому экрану. Вспоминаем про условие, что если нода не имеет дочерних текстовых блоков, мы сбрасываем состояние. Можно было бы добавить текстовые блоки тоже в ноды, но зачем, если их стили мы можем сразу посмотреть сбоку.

То самое условие
// из-за этого условия не выводится список нод
if (!supportsChildrenWithText(selection)) {
    return;
  }

Чтобы решить эту проблему, заведем переменную, куда будем сохранять последнее выделение, и при необходимости будем обращаться к ней.

code.ts
let lastSelectedNode: SceneNode;

const uiHandler = () => {
  const selection = figma.currentPage.selection[0];

  if (!selection) {
    figma.notify('Select node to inspect', {
      timeout: 5000,
    });
    return;
  }

  if (!supportsChildrenWithText(selection)) {
    return;
  }

  lastSelectedNode = selection;

  const stylesList: TextStyleType[] = selection
    .findAll((node) => node.type === 'TEXT')
    .map((textNode) => {
      const element = textNode as TextNode;

      const styles = {
        id: element.id,
        fontSize: `${parseInt(element.fontSize.toString())}px`,
        fontWeight: parseInt(element.fontWeight.toString()),
        lineHeight: getLineHeight(element.lineHeight as LineHeight),
      };

      if (element.removed) {
        figma.notify('This node has been removed', {
          timeout: 3000,
        });
        figma.ui.close();
      }

      if (element.textStyleId && typeof element.textStyleId === 'string') {
        const elementWithId = figma
          .getLocalTextStyles()
          .find((style) => style.id === element.textStyleId)!;

        return Object.assign(styles, { name: elementWithId.name });
      } else {
        return Object.assign(styles, { fontName: `${(element.fontName as FontName).family}` });
      }
    });

  figma.ui.postMessage({ type: MessageTypeEnum.SELECTED_ELEMENT, stylesList });
};

figma.showUI(__html__, { themeColors: true, width: 320, height: 480 });

uiHandler();

figma.ui.onmessage = (message) => {
  if (message.type === MessageTypeEnum.SCROLL_TO) {
    const el = figma.currentPage.findOne((el) => el.id === message.id);

    if (!el) {
      return;
    }

    figma.viewport.scrollAndZoomIntoView([el]);
    updateSelection(figma.currentPage, el);
  }
};

Настраиваем сборку

Приложение разрослось, поэтому самое время настроить сборщик. Тут проблемы и начнутся. Здесь я сразу опишу итог и потом только расскажу про процесс, в основном в мемах.

Сборки не собирались, поэтому работающую сборку я возьму отсюда. Это репозиторий прекрасного человека, который написал гайд по созданию плагина в Фигме. Плагин совсем другой и совсем другая статья, поэтому не делаю пометку, что моя статья - перевод. С его статьей можете ознакомиться тут.

Сборку делаем с помощью vite, т. к. он сейчас активно развивается и считается более быстрым, чем вебпак. Как раз разберемся в новом инструменте (если его кто-то еще не юзал).

Я и сборщики несколько дней
Я и сборщики несколько дней

Чтобы приложение работало, нужно брать все js и css-файлы, компилировать их и встраивать в html-документ. Если прикреплять как ссылку, то скрипт не будет работать.

ChatGPT в итоге выдал нужные пакеты, но они отказались работать с последней версией vite
ChatGPT в итоге выдал нужные пакеты, но они отказались работать с последней версией vite

Все закончилось работающей сборкой, и на этом спасибо.

Последние штрихи перед публикацией

Плагин готов, перед публикацией попробуем еще раз пройтись, но уже глазами тестировщика: что будет, если юзер первый раз видит наш плагин? Как сломать его работу? Заодно возьмем раздел Фигмы про публикацию плагина, там тоже советуют задать себе несколько вопросов.

Вопросы от нас:

  • что будет при привязке стиля?

  • что будет при отвязке стиля?

  • что будет при смене шрифта?

  • что будет при создании нового стиля?

  • что будет, если пользователь выбрал несколько нод одновременно?

Вопросы от Фигмы:

  • что будет при смене страницы?

  • что будет, если шрифт не найден?

  • что будет, если юзер использовал смешанные шрифты в одном текстовом блоке?

Сначала выясним, какие события у Фигмы отвечают за привязку и за отвязку стиля, за смену страницы. Для отсутствующего шрифта и смешанных шрифтов событий нет. Начнем с них, потому что это кажется проще.

Протестируем, что будет, если шрифт не найден, для этого найдем в сети какой-нибудь шаблон с «необычными шрифтами». Спойлер: все работает, переходим дальше.

Смешанные шрифты

Если юзер использовал смешанные шрифты, то отобразим ему названия всех использованных шрифтов, а вместо размеров шрифта и высоты строки укажем знаки вопроса. Если в одном блоке несколько текстовых шрифтов, то тип textStyleId - Symbol, поэтому добавим проверку на тип у textStyleId. Для того чтобы получить название шрифта (название стиля не отображается, даже если он привязан), воспользуемся встроенным методом getStyledTextSegments.

Обновленное условие
if (element.textStyleId && typeof element.textStyleId === 'string') {
  const elementWithId = figma
    .getLocalTextStyles()
    .find((style) => style.id === element.textStyleId)!;

  return Object.assign(styles, { name: elementWithId.name });
} else if (typeof element.fontName === typeof figma.mixed) {
  const elementFontName = element
    .getStyledTextSegments(['fontName'])
    .map((font) => font.fontName.family)
    .join('/');

  return {
    id: element.id,
    fontSize: '??',
    fontWeight: 0,
    lineHeight: '??',
    fontName: elementFontName,
  };
} else {
  return Object.assign(styles, { fontName: `${(element.fontName as FontName).family}` });
}

Выбор нескольких нод

Фигма может делать сложные вычисления, но для этого их надо разбивать на отдельные чанки. Можно сделать проще, просто не давая юзеры выбирать несколько нод, чтобы не нагружать Фигму. Разница в быстродействии будет заметна, если вы откроете дизайнерский файл с кучей компонентов, фреймов и т. д. - они дико тормозят, не нужно еще добавлять тормозов от обработки нод плагином.

Для этого просто добавим условие в uiHandler.

Запрет выбора нескольких нод
if (figma.currentPage.selection.length > 1) {
    figma.notify('Please, select only one node');
    return;
  }

Закрываем оставшиеся кейсы

Теперь разберемся со всеми событиями, которые могут происходить в документе. Для этого можно читать документацию или залезть в файл с определением типов plugin-api.d.ts, чтобы понять, какие вообще события есть.

Подсмотрели, какие есть события.
Подсмотрели, какие есть события.

Теперь выясним, какие события за что отвечают и как они вообще работают.

Нам нужно два события: documentchange и currentpagechange. Самое простое - смена страницы в документе, нам просто нужно обновлять ui. documentchange срабатывает каждый раз при обновлениях шрифта и тд, то есть всего, что меняется через панель свойств (правая панель).

// смена страницы
figma.on('currentpagechange', uiHandler);

В функцию-обработчик нам приходит ивент, у которого есть поле documentChanges. documentChanges - это массив событий, которые произошли при каком-то действии с документом. Первый элемент - это событие с элементом, на котором оно произошло, и которое запустило цепочку событий на остальных элементах. Например, при предпросмотре шрифта, если шрифт сильно больше текущего, элементы под текстом сдвинутся вниз и тоже попадут в массив. Назовем это первое событие триггером для более краткого изложения.

В оставшихся кейсах у нас три категории: смена стиля, смена шрифта и создание нового стиля. За смену отвечает тип PROPERTY_CHANGE. В этом случае у триггера тип PROPERTY_CHANGE, также у событий такого типа есть свойство properties - это свойства текущего элемента, которые были затронуты. Чтобы не писать много if, напишем условие, чтобы выходить из функции и не обновлять плагин, если нужные нам условия не выполняются.

Первое условие hasPropertyChanged, оно выполняется только если в массиве свойств есть и шрифт, и стиль (происходит при выборе шрифта без привязанного стиля). Сейчас, когда добавился предпросмотр шрифта, это работает иногда некорректно, и плагин иногда сбрасывается. Или при привязке и откреплении стиля в массиве properties есть только textStyleId, поэтому добавляем логическое ИЛИ.

Условие hasNewStyleCreated выполняется в том случае, когда пользователь выбрал текстовый элемент и решил создать новый текстовый стиль.

Обработка documentchange
figma.on('documentchange', (event) => {
  const trigger = event.documentChanges[0];

  const hasPropertyChanged =
    trigger.type === 'PROPERTY_CHANGE' &&
    (trigger.properties.every((p) => ['fontName', 'textStyleId'].indexOf(p) > -1) ||
      trigger.properties.includes('textStyleId'));

  const hasNewStyleCreated =
    event.documentChanges[0].type === 'STYLE_PROPERTY_CHANGE' &&
    event.documentChanges[1].type === 'STYLE_CREATE';

  if (!(hasPropertyChanged || hasNewStyleCreated)) {
    return;
  }

  updateSelection(figma.currentPage, lastSelectedNode);
});

Итоговый вариант

Все готово, можно публиковать. Это делается через меню плагина и кнопку «publish». Нам потребуется превью для плагина, можно также добавить видео его работы, иконка. Также потребуется обновить описание. После публикации ждем ответа от команды Фигмы, если они одобрили плагин, то он публикуется автоматически.

Результаты

Подведем итоги. Вот такой вот интересный опыт взаимодействия дизайнеров и разработчика вышел, все-таки они нас могут и вдохновлять, а не только «удивлять» макетами.

Отзывы про опубликованную версию

Оказалось, что для человека, который первый раз об этом слышит, плагин в Фигме звучит страшнее, чем кажется.

Ну и для тех, у кого закончились необычные идеи для пет-проектов, вот вам еще одна. Для более сложных плагинов можно подключить фреймворки, и, например, как раз попробовать новые на таких вот мини-проектах. А то все туду, да туду.

После публикации оказалось, что уже есть похожий плагин, хотя странно, почему его не нашел гугл. Поэтому небольшое сравнение плагинов:

Used text styles (мой)

Select by Font (существующий)

поддерживает смешанные текстовые блоки

поддерживает множественное выделение

поддерживает автопереключение темы

всегда показывает шрифт и размеры, можно скрывать и показывать привязанный стиль

Мой плагин выглядит чуть более органично, т. к. выполнен в цветах интерфейса Фигмы, но это, по сути, уже мелочи. Нельзя однозначно сказать, что какой-то лучше, поэтому каждый выбирает для себя.

Ссылка на плагин для тех, кому не хватало такого функционала у Фигмы.

Что дальше

Дальше поддержка плагина. Буду ждать, когда предпросмотр шрифтов у Фигмы станет стабильнее, потому что вылет окошка - довольно плавающий баг. Добавлю новые фичи, которые накидают в рамках обратной связи. Например, группировку текстовых элементов с одинаковыми параметрами, чтобы шрифт можно было поменять у всех текстовых блоков одновременно, а не делать это по отдельности. Также перепишу сборку, чтобы обновить устаревшие пакеты и получше разобраться в теме. Да, можно использовать вебпак, я с него начала, и он тоже не завелся.

Спасибо, что прочитали!

Комментарии (0)