본문 바로가기

프로젝트 개발일지/warrr-ui 디자인 시스템

[warrr-ui 디자인 시스템 개발기] 아이콘 자동화 피그마 플러그인 개발

이번 주 과제는 아이콘 자동화 피그마 플러그인 개발이었다.

이거까지 하게 될 줄은 몰랐는데, 당근, 채널톡 코드들을 좀 살펴보니 그렇게까지 무리한 도전은 아닐 거 같아서 그냥 시작해 버렸다.

 

일단, 피그마 플러그인이 돌아가는 로직에 대한 가벼운 이해가 필요해 그거부터 알아보도록 하자.

 

피그마 플러그인 로직

다음 사진은 피그마 플러그인이 돌아가는 로직을 한눈에 파악할 수 있는 좋은 레퍼런스이다.

간단히 말하자면, 피그마 플러그인은 두 계층에서 동작한다.

sandbox와 iframe인데 여기서 sandbox는 피그마 플러그인 관련 로직, 즉 비즈니스 로직 쪽과 관련이 있고, iframe은 우리가 보는 UI와 관련이 있다.

중요한 점은 sandbox에서는 브라우저 API 같은 건 호출하지 못하고 플러그인 API는 호출이 가능하다.

그리고 iframe에서는 브라우저 API는 호출되지만 플러그인 API는 호출이 불가능하다.

이 점을 꼭 생각하면서 개발해야 나중에 삽질하지 않는다...

 

그리고 또 한 가지 중요한 점이 있다.

위 두 계층 사이 정보를 주고받으려면 window 객체 사이 메시지를 전달할 때 사용되는 postMessage를 사용해야 한다.

여기서부터 나는 sandbox를 plugin으로, iframe은 ui로 부르려고 한다.

 

ui와 plugin에서 메시지를 주고받을 때 표현 방식이 살짝 다르다.

이 점을 유의하면서 개발해야 문제가 생기지 않는다...

// ui에서 plugin으로 메시지 전송
window.parent.postMessage({ pluginMessage: 'pluginMessage' }, '*')

// ui에서 plugin 메시지 수신
onmessage = (event) => {
  console.log(event.data.pluginMessage)
}

// plugin에서 ui로 메시지 전송
figma.ui.postMessage('pluginMessage')

// plugin에서 ui 메시지 수신
figma.ui.onmessage = (message) => {
  console.log(message)
}

 

이 정도만 알아도 개발하는데 큰 문제가 없다.

 

리액트 기반 보일러 플레이트 사용

평소에는 초기 세팅하는 거 정말 좋아하기는 하는데,,, 이번에는 피그마 플러그인을 빠르게 만들어보고 로직을 파악해 보는 데에 중점을 두었기 때문에 보일러 플레이트를 사용해서 빠르게 개발을 시작했다.

내가 사용한 보일러 플레이트 링크는 아래에 있다.

https://github.com/hseoy/figma-plugin-react-boilerplate

 

피그마 플러그인은 vanilla js로도, 리액트로도 개발이 가능하지만 나는 가장 많이 써본 리액트로 선택해서 편하게 개발을 시작했다.

 

주요 로직

일단, 로직을 알아보기에 앞서 완성된 플러그인을 한 번 보도록 하자.

디자인은 신경 쓰지 않고 기능 구현에 중점을 두었으니,,, 그걸 감안하고 보도록 하자.

 

페이지 구성은 이렇게 두 가지이다.

Deploy, Setting 페이지이다.

 

주요 로직은 다음과 같은데,

1. Setting 페이지에서 github owner, 해당 플러그인을 연결할 repository name, 해당 레포지토리의 github token, figma token 이렇게 네 가지 정보를 입력하고 save 버튼을 클릭한다.

2. 해당 정보를 plugin api를 사용해 clientStorage에 저장한다.

3. 피그마 상에서 export 할 svg 아이콘들을 선택한다.

4. Deploy 페이지에서 deploy 버튼을 클릭한다.

5. ui에서 github api 호출해서 pr을 생성한다.

 

코드는 아래 레포지토리 가면 확인할 수 있다.

https://github.com/ghdtjgus76/icon-figma-plugin

 

이렇게 개발하기 위해서 plugin, ui, shared 이렇게 세 가지 폴더 내부에서 각각의 로직을 구현했다.

여기서 주목할 점은 내가 사용한 api 호출 지점인데, clientStorage는 플러그인 api이기 때문에 plugin에서만 호출이 가능하고, github api는 브라우저 api이기 때문에 ui에서만 호출이 가능하다는 점이다.

그래서, ui에서 clientStorage에 저장된 정보를 가져오고 싶다면 postMessage를 통해서 plugin에 메시지를 주고받아야 하고, plugin에서 github api 호출이 필요하다면 ui에 해당 메시지를 보내서 ui 쪽에서 github api 호출을 해야 한다.

 

일단, Setting 페이지 로직부터 좀 더 자세히 보도록 하자.

Setting 페이지에서 사용자가 입력한 정보를 save 버튼을 통해 저장한다고 했다.

그리고 만약 Setting 페이지에 접근했을 때 사용자로부터 받은 정보가 있다면 바로 보여주도록 로직을 구성했다.

 

이렇게 하려면, 처음 접근했을 때 clientStorage에 접근해야 할 텐데, 그건 plugin에서만 가능하니, plugin에 postMessage를 보내 데이터를 가지고 온다.

그렇게 가져온 데이터를 사용자에게 보여주고, 만약 그 데이터들이 없다면 입력받도록 하는 것이다.

첫 번째는 ui 로직이고, 두 번째는 plugin 로직이다.

useEffect(() => {
  useData((userData: UserDataType) => {
    setGithubData({
      owner: userData.owner,
      repo: userData.repo,
      githubToken: userData.githubToken,
    });
    setFigmaToken(userData.figmaToken);
  });
}, []);
  
export default function useData(onSuccess: (userData: UserDataType) => void) {
  window.parent.postMessage(
    {
      pluginMessage: {
        type: MessageType.GetData,
      },
    },
    '*',
  );

  onmessage = (event: MessageEvent<UserDataPluginMessage>) => {
    const { type, payload } = event.data.pluginMessage;

    if (type === MessageType.UserData && payload) {
      const userData = payload;

      onSuccess(userData);
    }
  };
}
figma.ui.onmessage = async (message) => {
  if (message.type === MessageType.GetData) {
    const userData: UserDataType = await getStorageData(StorageKey.UserData);

    figma.ui.postMessage({
      type: MessageType.UserData,
      payload: userData || null,
    });
  }
};

export const getStorageData = async (
  key: StorageKey,
): Promise<UserDataType> => {
  const data = await figma.clientStorage.getAsync(key);
  return data;
};

 

이렇게 데이터를 가져왔다면, 사용자는 피그마 상에서 추출을 원하는 아이콘들을 선택해서 Deploy 버튼을 누르게 된다.

그러면 우리는 아이콘을 추출해야 하는데, 이 또한 플러그인 api를 사용해야 해서 ui에서 처리할 수가 없다.

그렇다면 ui에서 plugin에 아이콘을 추출해 달라는 메시지를 보내 plugin에서 아이콘을 추출하게 된다.

코드는 아래와 같다.

첫 번째는 ui 코드이고, 두 번째는 plugin 코드이다.

const handleClickDeploy = () => {
  window.parent.postMessage(
    {
      pluginMessage: {
        type: MessageType.ExtractIcon,
      },
    },
    '*',
  );
};
const extractIcons = async (): Promise<SvgIconsType> => {
  const currentNodes = figma.currentPage.selection;
  if (!currentNodes.length) {
    console.log('선택된 프레임이 없습니다.');
    return;
  }

  const svgIcons = await Promise.all(
    currentNodes.map(async (currentNode: SceneNode) => {
      const svg = await currentNode.exportAsync({ format: 'SVG_STRING' });
      const id = currentNode.name;

      return { svg, id };
    }),
  );

  const svgIconObj: SvgIconsType = svgIcons.reduce(
    (acc, { svg, id }) => ({
      ...acc,
      [id]: { svg, name: id },
    }),
    {},
  );

  return svgIconObj;
};

figma.ui.onmessage = async (message) => {
  if (message.type === MessageType.ExtractIcon) {
    const svgIcons = await extractIcons();
    const { githubToken, owner, repo } = await getStorageData(
      StorageKey.UserData,
    );

    figma.ui.postMessage({
      type: MessageType.CreatePullRequest,
      payload: { svgIcons: JSON.stringify(svgIcons), githubToken, owner, repo },
    });
  }
};

 

plugin에서 아이콘을 추출해달라는 메시지가 날아오면 extractIcons 함수를 통해 아이콘을 추출한다.

현재 선택된 항목을 불러와 svg 아이콘 정보를 추출하고 원하는 객체 형식으로 변환하는 함수이다.

 

이렇게 추출한 아이콘 정보를 ui에 보내줘야 할 텐데 위 코드를 보면 github token, owner, repository 정보도 함께 ui에 보내준다.

이는 github api는 브라우저 api이기 때문에 ui에서만 호출이 가능하고, pr을 생성하려면 필요한 정보들을 함께 보내주는 것이다.

그래서, pr 생성 시 필요한 기본 정보들 + 올라갈 파일 정보인 아이콘 정보를 함께 보내주고 ui에서 github api를 호출하게 되는 것이다.

 

아래 코드를 보면 이해될 것이다.

ui에서 풀 리퀘스트를 생성해 달라는 메시지와 그에 필요한 정보를 함께 받아서 github api를 호출하면 이제 우리가 원하는 것처럼 아이콘 정보가 담긴 PR이 생성된다.

useEffect(() => {
  onmessage = (event: MessageEvent<CreatePullRequestPluginMessage>) => {
    const {
      type,
      payload: { githubToken, owner, repo, svgIcons },
    } = event.data.pluginMessage;

    if (type === MessageType.CreatePullRequest) {
      const createPullRequest = githubApi({
        githubToken,
        owner,
        repo,
        svgIcons,
      });

      createPullRequest();
    }
  };
}, []);
  
export default function githubApi({
  githubToken,
  owner,
  repo,
  svgIcons,
}: GithubApiPropsType) {
  const MyOctokit = Octokit.plugin(createPullRequest);

  const octokit = new MyOctokit({
    auth: githubToken,
  });

  const { title, body, head, base, iconFilePath, commit, author, committer } =
    gitConfig;

  const createPR = async () => {
    octokit
      .createPullRequest({
        owner,
        repo,
        title,
        body,
        head,
        base,
        update: true,
        changes: [
          {
            files: { [iconFilePath]: svgIcons },
            commit,
            author,
            committer,
          },
        ],
      })
      .then((pr) => console.log(pr.data.id));
  };

  return createPR;
}
export const gitConfig = {
  title: 'Feature: 아이콘 변경사항 반영',
  body: '피그마상 아이콘 변경사항 반영했습니다.',
  head: 'feature/icon',
  base: 'main',
  iconFilePath: 'packages/icons/icons.json',
  commit: 'feature: 아이콘 변경사항 반영',
  author: {
    name: 'ghdtjgus76',
    email: 'ghdtjgus76@naver.com',
    date: new Date().toISOString(),
  },
  committer: {
    name: 'ghdtjgus76',
    email: 'ghdtjgus76@naver.com',
    date: new Date().toISOString(),
  },
};

 

이 과정을 거치면 아래처럼 PR이 생성되는 것을 볼 수 있다.

https://github.com/ghdtjgus76/warrrui-setting-test/pull/5

변경된 파일을 보면 svg 아이콘 정보도 우리가 원하는 대로 들어가 있는 것을 볼 수 있다.

 

postMessage를 통해 통신하는 걸 해보니 웹뷰가 생각이 났다.

사실 웹뷰 실습해 볼 당시에는 그냥 api 호출이 되는 곳에서 호출하고 우당탕탕 완성한 기억이 있는데, 이번에 좀 더 로직에 대해서 많이 생각해 보면서 공부해 볼 수 있었던 거 같다.

추후 웹뷰 공부도 다시 해보려고 하는데 이때 꼭 체계적으로 설계하고 해 봐야겠다.

 

참고 자료

https://www.figma.com/plugin-docs/plugin-quickstart-guide/

https://velog.io/@skgmlsla/%ED%94%BC%EA%B7%B8%EB%A7%88-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-%EB%8F%99%EC%9E%91-%EB%A1%9C%EC%A7%81

https://techblog.woowahan.com/8339/

https://github.com/daangn/icona/tree/main/figma-plugin

https://github.com/channel-io/bezier-react/tree/main/packages/bezier-figma-plugin

https://github.com/octokit/octokit.js