개발팀 인턴의 좌충우돌 Figma i18n 플러그인 제작기

개발팀 인턴의 좌충우돌 Figma i18n 플러그인 제작기

Figma를 아시나요?

Figma

Figma는 UI 프로토타이핑 툴인데요. 위처럼 단순히 와이어프레임을 만들 수 있을 뿐 아니라,

이렇게 요소들의 상호작용도 표현할 수 있습니다.

무엇보다 이렇게 여러 사람이 함께 수정도 가능합니다. 게다가 웹 기반 툴이라 웹 브라우저에서 사용할 수 있어 OS에 구애받지 않습니다. 물론 데스크톱 앱도 있고요.  우리 회사도 Figma를 이용하고 있습니다. 😮

저는 인턴 첫 업무로 Figma API를 이용해 Figma에서 디자인만 하면 바로 디자인을 HTML, CSS 구조로 바꾸어 주는 프로그램을 제작했었는데요. 문제가 하나 있었습니다. 바로 다중 언어 지원입니다. 디자인을 바로 컨버팅해주는 구조이기 때문에 텍스트 역시 Figma에서 바로 가져오는데, 여기에 다중 언어까지 생각하려면 머리가 아팠습니다.

이렇게 각 언어 별 페이지를 만들기엔 한쪽에서 디자인을 수정하면 다른 쪽에도 일일히 반영해 주어야 해서 디자이너님이 너무 힘든데다, 컨버팅 할때도 그렇게 효율적이지 못했습니다.

그 다음에는 구글 스프레드 시트를 사용해 i18n 구조를 만들고 노드의 ID와 텍스트를 언어별로 저장해서 뽑아왔습니다. 하지만 디자인 상에서 노드가 추가 / 제거되는 등의 이슈가 생기면 해결하는 것은 손으로 해주어야 했습니다. 한국어와 영어를 다중 언어를 지원하는 웹 사이트를 만들어야 할 일은 앞으로도 많을 것이고, 그때마다 이렇게 관리하는 것은 효율성이 너무 낮아 보였습니다. 결정적으로, 이렇게 텍스트만 관리해서는 텍스트 내의 개별 스타일을 적용하는 것이 너무 비효율적입니다. 그래서 빨리 새로운 대안을 만들어야 했습니다.

있는거 쓰지 왜 직접 만들었냐?

이미 있는 플러그인들을 살펴본 결과,

  1. 직접 다른 언어를 입력하는 방식이 아닌 자동 번역이거나,
  2. 기존 서비스에 Figma 프로젝트를 연결하는 방식이나,
  3. json, csv 등 외부 파일에서 작성하여 import 해야만 하거나,
  4. 줄바꿈이나 스타일 적용 등의 기술이 미비했습니다.

그래서 기존 서비스에 바로 적용할 수 없었기 때문에 직접 만들기로 했습니다.


필요하면 직접 만들어야지 머....

보일러플레이트 소개

Figma는 웹 기반 툴이라서 플러그인도 웹 기술만 알고있다면 만들 수 있습니다. 저는 빠른 설정을 위해 아래 보일러플레이트를 클론했습니다.

aarongarciah/figma-plugin-typescript-boilerplate
Figma plugin TypeScript boilerplate to start developing right away - aarongarciah/figma-plugin-typescript-boilerplate

일단 해당 보일러플레이트의 구조를 뜯어봅시다.

소스 폴더에는 플러그인의 UI를 정해주는 ui.html, ui.css 파일과 함께 UI 단에서의 작동을 제어하는 ui.ts, 플러그인 단에서의 작동을 제어하는 plugin.ts 파일이 있습니다. types.ts 파일은 각 ts파일들에서 사용하는 타입의 정의들을 따로 빼 둔 것입니다.

내가 UI에서 사각형 생성 버튼을 누른 걸 플러그인에서 어떻게 알고 사각형을 생성하지?
function postMessage({ type, payload }: UIAction): void {
  parent.postMessage({ pluginMessage: { type, payload } }, '*');
}
ui.ts
function postMessage({ type, payload }: WorkerAction): void {
  figma.ui.postMessage({ type, payload });
}
plugin.ts

postMessage라는 API로 ui.ts와 plugin.ts 간 커뮤니케이션을 할 수 있습니다. 이 보일러플레이트에서는 각 단에서의 postMessage를 같은 형식으로 바꾸어 주었습니다. 예를 들어 UI에서 특정 버튼을 누르면 사각형을 생성하고 싶을 때,

‌function buttonListeners(): void {
  document.addEventListener('click', function (event: MouseEvent) {
    const target = event.target as HTMLElement; switch (target.id) {
      case 'rectangleBtn': 
        postMessage({ type: UIActionTypes.CREATE_RECTANGLE }); 
        break; 
    } 
  }); 
}
ui.ts

UI에서 이렇게 사각형 만드는 버튼 눌렸다! 하고 플러그인 단에 알려줍니다.

figma.ui.onmessage = function ({ type, payload }: UIAction): void {
  switch (type) { 
    case UIActionTypes.CREATE_RECTANGLE: 
      createRectangle(); 
      break; 
  }
};
plugin.ts

플러그인 단에서는 이렇게 onmessage로 메세지가 수신되면 type에 따라 어떤 행동을 할지 정의해줄 수 있습니다.

export enum UIActionTypes {
  CLOSE = 'CLOSE',
  NOTIFY = 'NOTIFY',
  CREATE_RECTANGLE = 'CREATE_RECTANGLE', 
}
type.ts

메세지의 type은 type.ts에서 정의해 주면 됩니다.

function createRectangle(): void { 
  const rect = figma.createRectangle(); 
  const width = 100; const height = 100; 
  rect.resize(width, height); 
  rect.x = figma.viewport.center.x - Math.round(width / 2); 
  rect.y = figma.viewport.center.y - Math.round(height / 2); 
  figma.currentPage.appendChild(rect); 
  figma.currentPage.selection = [rect]; 
  figma.viewport.scrollAndZoomIntoView([rect]); 
  postMessage({ type: WorkerActionTypes.CREATE_RECTANGLE_NOTIFY, payload: 'Rectangle created 👍' }); 
}
plugin.ts

createRectangle 함수는 이렇게 작성되어 있네요. figma 내의 API들을 통해 직접 요소들을 수정할 수도 있고, 추가하거나 삭제할 수도 있습니다.

플러그인에 어떤 기능이 필요하지?

본격적으로 i18n 플러그인을 만들기 위해서 어떤 기능이 필요한지 html과 css로 UI를 만들어 봅니다.

우선 플러그인을 크게 3가지 기능으로 분류했습니다. 언어를 추가 / 제거 / 수정하거나 전체 노드에 언어를 한번에 적용할 수 있는 Global Language 패널이 첫번째입니다. 디버깅을 용이하게 하기 위해서 노드의 ID가 표시되는 Selected Node ID 칸도 만들었는데, 의외로 이 기능이 강력합니다. 두 번째는 선택한 노드의 언어별 내용을 확인할 수 있는 Preview 패널이고, 세 번째는 작성된 각 언어별 내용을 import 및 export 할 수 있는 패널입니다. 대충 언어 목록, 노드 별·언어 별 내용을 설정하고, 불러오고 하는 기능이 주가 되겠네요.

그래서 어떻게 만들지?

이제 본격적으로 플러그인을 어떻게 만들었는지 이야기해 보겠습니다.

figma에서 플러그인을 실행시키고, 노드를 선택하면 떠야 하는 화면입니다. 노드의 ID를 표시하고, 현재 선택된 노드의 내용을 Preview에 보이게 해야 하겠네요.

figma.on('selectionchange', async () => { 
  const node = figma.currentPage?.selection[0]; 
  if (!node || node.type !== 'TEXT') { 
    postMessage({ type: WorkerActionTypes.SELECTED_NODE, payload: { id: id, type: node?.type, contents: null }, });
    return; 
  } 
  const id = node.id; 
  const contents = { characters: node.characters || null }; 
  postMessage({ type: WorkerActionTypes.SELECTED_NODE, payload: { id: id, type: node.type || null, contents: contents }, }); 
});
plugin.ts

이렇게 하면 현재 선택된 노드가 TEXT일 때 노드의 내용(characters)을 postMessage 안의 payload로 ui.ts에 전달해줄 수 있습니다. ui.ts에서는 이 정보를 바탕으로 preview 패널에 미리보기 텍스트를 표시해 줍니다. 여기까지는 정말 쉽습니다.

문제는 여러 언어 별로 작성한 내용을, 언어를 바꿀 때마다 불러와야 한다는 건데요. 대체 어디에, 어떻게 저장하고 가져오지? 라는 문제가 생깁니다.

(고민으로 억겁의 시간을 보내는 표정 및 자세)

‌ 다행히 Plugin에 setPluginData, getPluginData 라는 API가 있습니다. 각 플러그인 ID별로 key-value값을 저장하고 불러올 수 있습니다. figma.currentpage.setPluginData 로 각 페이지에 값을 저장할 수도 있고, node.setPluginData 로 각 노드에 값을 저장할 수도 있습니다. 🥺 각 노드의 언어 별 내용을 저장할 때는 node.setPluginData('content', node.characters) 이렇게 저장하고, 같은 키 값으로 getPluginData 해서 불러와주면 되겠군요. 🤓

const nodeInfo = JSON.parse(node.getPluginData('nodeInfo')); 
node.characters = nodeInfo.characters;
plugin.ts

이런 식으로요. ( pluginDatavalue 에는 string밖에 들어갈 수 없어서, JSON을 넣을 때는 JSON.stringify , 가져올 때는 JSON.parse 해줍니다.)

그런데 여기서 문제가 하나 더... Figma에서는 node.characters 등에 직접 접근해서 바로 내용을 바꾸어 줄 수 없습니다. 그 전에 폰트를 로드해야 하기 때문입니다.

const rangeFontNames = [] as FontName[];
for (let i = 0; i < node.characters.length; i++) {
  const fontName = node.getRangeFontName(i, i + 1) as FontName;
  if (
    rangeFontNames.some(
      (name) => name.family === fontName.family && name.style === fontName.style,
    )
  )
    continue;
  rangeFontNames.push(node.getRangeFontName(i, i + 1) as FontName);
}
postMessage({ type: WorkerActionTypes.SET_FONT_LOAD_STATUS, payload: false });
await Promise.all(rangeFontNames.map((name) => figma.loadFontAsync(name)));
postMessage({ type: WorkerActionTypes.SET_FONT_LOAD_STATUS, payload: true });
plugin.ts

Figma는 node.fontName 으로 폰트 이름을 바로 가져오면,

FontName.name은 같지만 FontName.style이 앞의 세 글자는 Bold, 뒤의 두 글자는 Regular이군요

이렇게 한 노드 안에 여러 FontName 이 섞여 있는 경우 figma.mixed (type: PluginAPI['mixed']) 라는 반환값을 내보내게 됩니다. 그래서 노드 안의 텍스트들을 하나하나 잘라서 글자 한개마다의 FontNamenode.getRangeFontName 으로 가져온 후 로드해주어야 합니다. 이렇게 텍스트를 하나하나 잘라서 로드하는 것이 Figma API 문서에 적혀있는 방법입니다. 저는 각 FontName 들을 겹치지 않게 node.getRangeFontName 함수에 넣고 배열을 만든 후 Promise.All 로 한번에 리졸브해주었습니다. (Figma 공식 문서에 의하면 이미 로드된 폰트는 다시 로드하지 않기 때문에 굳이 저처럼 겹치지 않게 신경쓸 필요는 없어 보입니다. 🤔) 덤으로 postMessage 를 통해 UI에 폰트가 로드 중인지 여부를 알려줍니다.

이제 언어를 추가하고 열심히 영어로 작성해봅시다. 한국어 버전과 비슷하게 스타일도 넣어줍니다. 물론 스타일은 저장한 적이 없으니 반영되지 않을 겁니다.

다른 언어로 바꿨다가 돌아오거나 껐다 켜서 다시 불러오게 되면 적용한 스타일은 사라집니다. 저장한 적이 없으니까 당연히...

여러 스타일이 섞여 있을 경우에도 저장 / 불러오기가 되도록 node.getRange... 를 이용해봅시다.

getRangeFontSize(start: number, end: number): number | PluginAPI['mixed'];
setRangeFontSize(start: number, end: number, value: number): void;
getRangeFontName(start: number, end: number): FontName | PluginAPI['mixed'];
setRangeFontName(start: number, end: number, value: FontName): void;
getRangeTextCase(start: number, end: number): TextCase | PluginAPI['mixed'];
setRangeTextCase(start: number, end: number, value: TextCase): void;
getRangeTextDecoration(start: number, end: number): TextDecoration | PluginAPI['mixed'];
setRangeTextDecoration(start: number, end: number, value: TextDecoration): void;
getRangeLetterSpacing(start: number, end: number): LetterSpacing | PluginAPI['mixed'];
setRangeLetterSpacing(start: number, end: number, value: LetterSpacing): void;
getRangeLineHeight(start: number, end: number): LineHeight | PluginAPI['mixed'];
setRangeLineHeight(start: number, end: number, value: LineHeight): void;
getRangeFills(start: number, end: number): Paint[] | PluginAPI['mixed'];
setRangeFills(start: number, end: number, value: Paint[]): void;
getRangeTextStyleId(start: number, end: number): string | PluginAPI['mixed'];
setRangeTextStyleId(start: number, end: number, value: string): void;
getRangeFillStyleId(start: number, end: number): string | PluginAPI['mixed'];
setRangeFillStyleId(start: number, end: number, value: string): void;
API

이 친구들을 이용해서 여러 스타일이 섞여 있는 경우 각 단어마다 스타일을 저장해주면 됩니다. 저장하는 방식은 characterStyleOverrides (type: Number[]) 배열과  styleOverrideTable (type: Map<Number,TypeStyle> 테이블로 구성되는 Figma REST API (Figma Plugin API와는 다릅니다) 의 방식을 참고했습니다.

characterStyleOverrides 는 텍스트의 각 문자에 대응하는 배열입니다. 배열의 각 요소는 styleOverrideTable 에 있는 스타일 번호를 나타내는데요.

안녕하세요 라는 내용을 가지고 있는 위 노드는 characterStyleOverrides[0, 1, 0, 1, 2] 이고 styleOverrideTable 은 다음과 같이 저장됩니다.

{
  "0": {
    "fontSize": 14,
    "fills": [{ "type": "SOLID", "rgb": { "r": 255, "g": 0, "b": 0 } }],
    "textDecoration": "UNDERLINE"
  },
  "1": {
    "fontSize": 18,
    "fills": [{ "type": "SOLID", "rgb": { "r": 0, "g": 0, "b": 0 } }],
    "textDecoration": "NONE"
  },
  "2": {
    "fontSize": 14,
    "fills": [{ "type": "SOLID", "rgb": { "r": 255, "g": 0, "b": 255 } }],
    "textDecoration": "UNDERLINE"
  }
}

이 모든 정보를 각 노드에 setPluginData 로 저장해주면 됩니다. 불러올 때도 마찬가지입니다.

스타일이 촤르륵 적용되는 모습

이제 기본적인 각 노드의 언어 별 내용을 저장 및 불러오기가 가능해졌습니다. 🤗 이걸 바탕으로 global Language도 설정해주고, (팀장님이) import 및 export도 만들 수 있었습니다.

실제 회사 프로젝트에도 이렇게 적용할 수 있답니다

배포

{
  "name": "Figma i18n Plugin",
  "api": "1.0.0",
  "id": "여기에 아이디가 들어갑니다.",
  "main": "dist/plugin.js",
  "ui": "dist/ui.html"
}
manifest.json

최상단에 위치한 manifast.json 파일은 우리 플러그인의 가장 핵심 파일입니다. 주목해야 할 부분은 id입니다. getPluginDatasetPluginData 에서 저장되는 데이터들은 플러그인의 id별로 관리되기 때문에 id를 변경하면 이전 데이터는 조회할 수 없습니다. (물론 id를 다시 원래대로 바꾸면 됩니다. 모든 플러그인에서 접근이 가능하게 하고 싶다면 getSharedPluginDatasetSharedPluginData 를 사용하세요.) 개발 단에서는 아무 텍스트나 사용해도 되지만, (저희는 require('uuid').v5.DNS.replace(/-/g,'') 으로 랜덤 uuid를 땄습니다. 팀장님 짱) 배포를 하게 되면 Figma에서 id를 지정해주기 때문에 그걸로 바꿔주어야 합니다.

Figma desktop app에서 Plugin 메뉴로 들어갑니다.

개발 중인 플러그인 옆의 메뉴를 눌러 Publish를 클릭하면 됩니다.

업데이트 화면이라 조금 다를 수 있습니다

아이콘과 커버 이미지, 설명글을 즐겁게 작성하고 Publish를 하면, 영업일 기준 5-10일, 즉 1-2주 이내로 Review를 받은 후 승인되면 배포가 됩니다. 제 경우에는 주말 포함 딱 7일 걸렸습니다.

언제 승인되나 하고 두근두근 기다리고 있으면

이렇게 메일이 옵니다. 이제 피그마 커뮤니티에서 배포한 플러그인을 찾아볼 수 있습니다! 🤗


읽어주셔서 감사합니다

Figma - Figma i18n Plugin | figma-i18n-plugin국제화를 위한 Figma 플러그인Figma plugin for i18n国際化のためのFigmaプラグインKOGlobal Language 관리: ...
Figma Community plugin — figma-i18n-plugin국제화를 위한 Figma 플러그인Figma plugin for i18n国際化のためのFigmaプラグインKOGlobal Language 관리: figma page에서 사용할 언어를 추가/삭제/변경한다.Apply all: 선택한 언어로 전체 Text를 변경한다.Node ID: 선택한 node의 ID를 조회한다.선택한 Node의 언어를 변경할 수 있다.JSON 파일로 i18n 파일을 내보내기 할 수 있다.내보내기 한 JSON파일을 import하여 i18n 적용할…
피그마 커뮤니티
r-4bb1t/figma-i18n-plugin
Contribute to r-4bb1t/figma-i18n-plugin development by creating an account on GitHub.
소스코드 (GitHub)

본문에 작성된 코드들은 포스팅의 용이성을 위해 실제 코드에서 수정을 거쳤습니다. figma-i18n-plugin은 위 링크에서 확인하실 수 있습니다. 😁