스마트에디터 Plugin 이야기

안녕하세요, SmartStudio 박진호입니다.

스마트에디터는 플러그인 시스템을 가지고 있지만, 아직은 많이 활성화되지 않았는데요. 좀 더 자유로운 플러그인 사용 방법을 통해 다양한 플러그인이 나왔으면 하는 바람으로 이 글을 적습니다.

스마트에디터 확장하기

스마트에디터는 기본적으로 플러그인 자체를 관리하는 시스템은 없습니다. 다만 몇 가지 커스터마이징을 할 수 있는 경로를 열어놨는데요, 이 기능을 가지고 정해진 규칙에 따라 플러그인 형태로 적용할 수 있도록 해보겠습니다.

먼저 스마트에디터에서 커스터마이징할 수 있는 기능은 어떤 게 있는지 한번 볼까요?

DocumentToolbar

DocumentToolbar 에는 몇 가지 버튼을 제어할 수 있는 기능이 있습니다.

  • 버튼 위치 변경
  • 버튼 삭제
  • 사용자 정의 버튼 추가

이 중에서 사용자 정의 버튼을 추가하는 코드를 보도록 하겠습니다.

productConfig: {
  documentToolbar: {
    buttonSet: [
      "social-media-image",
      "sticker",
      // 사용자 정의 버튼 삽입 위치
      "custom-count", 
        ...
    ],
    additionalButtons: [
      {
        "name": "custom-count",
        "imageUrl": "https://i.imgur.com/sojc9G2.png", // 버튼 이미지 주소
        "label": "카운트", // 버튼 레이블
        "logId": "count", // 버튼 nClicks 로그.
        "title": "카운트 첨부" // 버튼 요소의 title 값
      }
    ]
  }
}

productConfig.documentToolbar.additionalButtons 에 추가될 버튼을 정의한 다음, productConfig.documentToolbar.buttonSet 에 정의된 버튼의 이름name을 등록해줍니다.

Document Toolbar (카운트 버튼 추가)

DocumentToolbar 에 추가된 카운트 아이콘이 보이시나요? 이렇게 간단하게 Document Toolbar에 원하는 버튼을 추가할 수 있습니다.

그럼 버튼을 클릭하면 어떻게 동작할까요? 버튼에 클릭이벤트를 적용해보겠습니다. 버튼의 클릭은 에디터 전체의 클릭 이벤트로 정의가 되는데요, 아래 코드를 한번 보시죠.

events: {
  click: function (editorEvent) {
    var dataset = editorEvent.detail.dataset;
    console.log(dataset);
  }
}

버튼을 클릭하면 dataset 을 넘겨주는데, 이 dataset 에 있는 정보를 통해 클릭한 버튼이 어떤 버튼인지 판단할 수 있습니다.

속성설명
group해당 버튼이 위치한 도구 막대 이름documentToolbar
type버튼 유형을 알려주는 값custom
name해당 사용자 정의 버튼의 button namecustom-count
log버튼 생성 시 전달한 logIdcount

그럼 위에서 본 click 이벤트를 실행하는 코드를 해당 버튼을 클릭하는 코드로 조금 수정해보겠습니다.

events: {
  click: function (editorEvent) {
    var dataset = editorEvent.detail.dataset;
    if (dataset.group === "documentToolbar") {
      if(dataset.name === "custom-count") {
        seEditor.pauseDocumentEdit(); // 스마트에디터의 문서 편집을 막습니다.
        // 클릭 동작하기 
        console.log(dataset);
        seEditor.unpauseDocumentEdit(); // 스마트에디터의 문서 편집을 원래 상태로 변경합니다.
      }
    }
  }
}

이로써 간단하게 document toolbar 를 확장하는 방법을 알아보았습니다.

커스텀 컴포넌트

스마트에디터는 문서를 확장하는 방법으로 커스텀 컴포넌트를 만드는 기능을 제공합니다. 기본 문서 모델 이외에 다른 정보를 넣고 싶을 때, 커스텀 컴포넌트를 사용할 수 있습니다. 아래 0이라고 적힌 초록색 박스가 보이시나요? 이 박스가 바로 커스텀 컴포넌트입니다. 

count 커스텀 컴포넌트

그럼 실제로 커스텀 컴포넌트가 어떻게 정의되는지 보도록 하겠습니다.

plugins: {
  customComponents: [
    {
      type: "count", // 사용자 정의 컴포넌트 타입 정의
      onRender: function(payload) {
        var currentCount = payload.data.currentCount;
        var render = function(count) {
          var innerStyle = "border:1px solid red; width:500px; height:50px; text-align: center; display: flex; justify-content: center; align-items: center;"
          return "<div style='" + innerStyle + "'>" + count + "</div>";
        }
        return render(currentCount);
      }
    }
  ]
},

먼저 plugins.customComponents 속성에 몇 가지 값을 지닌 객체를 등록해줍니다.

속성설명
type사용자 정의 컴포넌트 타입count
onRenderhtml 코드 반환 함수<div>0</div>

이렇게 해서 커스텀한 모델을 가지고 커스텀한 뷰를 만들어줄 수 있게 되었습니다.

플러그인 매니저 만들기

이렇게 기능을 하나씩 추가하다 보면 관리하기가 만만치 않아질 수 있습니다. 그래서 이런 기능들을 통합해 관리할 수 있는 플러그인 매니저를 만들어 전체 플러그인을 관리해보겠습니다.

지금까지 총 세 가지 형태의 기능을 조합해 플러그인으로 사용할 수 있다는 것을 보았습니다.

  1. 툴바
  2. 이벤트
  3. 커스텀 컴포넌트

세 요소를 좀 더 정규화해서 사용하기 쉬운 패턴으로 만들어보도록 하겠습니다. 개발을 하기 전에 패턴을 어떤 식으로 만들면 좋을지 같이 고민해보면 좋을 것 같습니다.

productConfig: {
  documentToolbar: {
    additionalButtons: [
      // 플러그인 매니저에 등록된 플러그인들에서 추가될 버튼을 모아서 정의해줍니다. 
      ...PluginManager.buttons
    ]
  }
},
plugins: {
  customComponents: [
    // 플러그인 매니저에 등록된 커스텀 컴포넌트 리스트를 정의해줍니다. 
    ....PluginManager.components
  ]
},
events: {
  click: function (editorEvent) {
    // 플러그인 매니저에 등록된 개별 매니저에 메세지를 보냅니다. 
    PluginManager.fireEvent('click', editorEvent);
  }
}

저는 이렇게 기본 설정 구조를 정해보았습니다. 메인 코드를 고칠 필요 없이, PluginManager에 Plugin만 등록하면 커스텀 컴포넌트를 사용할 수 있습니다. 코드를 보니 PluginManager에 Plugin이 들어있어야 할 것 같습니다.

PluginManager.add(MyPlugin);

자, 이제 플러그인 매니저에 플러그인을 등록하고 사용할 수 있는 기본 형태가 갖추어졌습니다. 실제로 세 가지 동작에 대해서 PluginManager를 정의해보도록 하겠습니다.

export default class PluginManager {
  // 등록될 플러그인 리스트
  static plugins = [];
  // 플러그인 등록하기
  static add(pluginInstance) {
    PluginManager.plugins.push(pluginInstance);
  }
  // 추가될 버튼 리스트 구하기
  static get buttons() {
    return PluginManager.plugins.map((plugin) => plugin.button).filter(Boolean);
  }
  // 추가될 커스텀 컴포넌트 리스트 구하기
  static get components() {
    return PluginManager.plugins
      .map((plugin) => plugin.component)
      .filter(Boolean);
  }
  // 특정 이벤트 실행하기
  static fireEvent(eventName, dataset) {
    switch (eventName) {
      case 'ready':
        PluginManager.plugins.forEach((plugin) => plugin.emit(eventName));
        break;
      default:
        Plugin.fireEvent(eventName, dataset);
        return;
    }
  }
}

이로써 간단한 플러그인 매니저가 만들어졌습니다. 이제 진짜 플러그인을 만들어서 등록하면, 실제로 동작하는 형태가 될 수 있을 것 같습니다.
그렇다면 플러그인은 어떻게 만들어 볼 수 있을까요? 이것도 같이 생각해보면 좋겠습니다.
플러그인 매니저에서 사용되는 코드를 기준으로, 플러그인이 가져야 하는 몇 가지 기능을 정의해보도록 하겠습니다.

export default class Plugin {
  constructor(obj = {}) {
    this.obj = obj;
  }
  // DocumentToolbar 에서 사용될 버튼의 정의를 만들어줍니다.
  get button() {
    if (this.obj.noneButton) {
      return undefined;
    }
    // 문서 도구 막대 버튼 정의
    return {
      name: this.obj.type,
      imageUrl: this.obj.imageUrl || 'https://i.imgur.com/sojc9G2.png', // 버튼 이미지 주소
      label: this.obj.label, // 버튼 레이블
      logId: this.obj.logId, // 버튼 nClicks 로그.
      title: this.obj.title, // 버튼 요소의 title 값
    };
  }
  // 커스텀 컴포넌트가 될 정보를 정의합니다.
  // 이 때 onRender 함수를 분리해서 정의할 수 있게 해줍니다.
  get component() {
    const self = this;
    return {
      type: this.obj.type, // 사용자 정의 컴포넌트 타입 정의
      propertyToolbar: this.obj.propertyToolbar || [],
      contextToolbar: this.obj.contextToolbar || [],
      onRender: function (payload) {
        return self.render(payload.data, this);
      },
    };
  }
  // render 결과 리턴
  render(data, component) {
    if (this.obj.render) {
      setTimeout(() => {
        // setTimeout 으로 시간축을 다르게 해서 render 이후에 함수를 실행 할 수 있도록 합니다.
        // 커스텀 컴포넌트에 부가 기능 넣을 때 유용합니다.
        this.emit('afterRender', data, component);
      }, this.obj.delayRender || 0);
      return this.obj.render(this, data, component);
    }
  }
  // 플러그인 에서 사용될 간단한 이벤트 시스템을 만듭니다.
  static fireEvent(eventName, dataset) {
    const func = Plugin.getCallback(eventName, dataset);
    if (func) {
      func();
    }
  }
}

간단하게 플러그인을 정의해봤습니다. 플러그인 인스턴스를 생성할 때 전달하는 obj를 통해, 플러그인의 속성을 정의하는 것을 볼 수 있습니다. Plugin 속성은 아래의 표와 같은 형태로 정의됩니다. 이런 속성을 가진 플러그인 인스턴스가 있다면 PluginManager에 등록해 쉽게 스마트에디터에 기능을 추가할 수 있습니다.

속성설명사용하는 곳
type버튼 유형을 알려주는 값button, component
imageUrl버튼 이미지 주소button
label버튼 레이블button
logId버튼 생성 시 전달한 logIdbutton
title버튼 요소의 title 값button
renderhtml 코드 반환 함수component
Plugin 속성

이제 간단한 클릭 카운터 플러그인을 만들어보면서 플러그인에 어떤 기능들이 필요하고, 어떻게 구현하는지 보도록 하겠습니다. 클릭 카운터 플러그인은 아래처럼 정의할 수 있습니다.

PluginManager.add(new Plugin({
  type: 'clickCounter',
  imageUrl: "https://i.imgur.com/sojc9G2.png", // 버튼 이미지 주소
  label: "클릭 카운터", // 버튼 레이블
  logId: "clickCounter", // 버튼 nClicks 로그.
  title: "클릭 카운터", // 버튼 요소의 title 값,
  render: (instance, { count }, { id }) => {
    return `
      <button type="button">
        ${count} 
      </button>
    `;
  }
})

위의 표에 있는 플러그인 속성을 사용해 정의했습니다. 하지만 실제로 동작해야 하는 기능이 몇 가지 빠져있습니다. 어떤 기능이 필요한지 같이 생각해볼까요?

첫 번째로 DocumentToolbar 에 버튼은 추가되었지만, 클릭 이벤트를 어떻게 전달할지 알 수 없습니다.
자동으로 render 함수를 수행해주는게 아니냐고 물어볼 수도 있는데요. 클릭 카운터의 경우 현재 count 를 값으로 표현해주고 있어서 누군가는 초기 설정을 해줘야 합니다. 그래서 DocumentToolbar 에 정의된 버튼을 클릭하는 시점에 count를 초기화하고 커스텀 컴포넌트를 넣어줘야 합니다.

두 번째로 Custom Component와 DocumentToolbar의 버튼은 실질적인 연관이 없습니다.
버튼을 클릭하는 시점에 커스텀 컴포넌트를 넣을 수도 있고, 아닐 수도 있기 때문입니다. 클릭하는 행위와 컴포넌트를 입력하는 행위는 별도로 이루어져야 합니다.

세 번째로 커스텀 컴포넌트는 단순히 html만을 정의합니다.
따라서 렌더링 결과물에서 이벤트를 실행하기 위해서는 이벤트를 정의할 수 있어야 합니다.

실제 동작하는 플러그인 이벤트 정의하기

먼저 DocumentToolbar의 버튼을 클릭할 수 있도록 함수를 정의해보도록 하겠습니다. 다른 코드가 나열된 것처럼 사용하는 부분을 먼저 정의해봅니다.

PluginManager.add(new Plugin({
  ...,
  // 다른 속성과 실행되는 함수를 분리하기 위해서 callbacks 라는 그룹으로 관리하고자 합니다. 
  callbacks: {  
    // documentToolbar 에서 버튼이 클릭될 때 실행되는 함수
    'click': (instance) => {
      console.log(`${instance.type} button is clicked`);
    }
  }
})  

플러그인에 callback 이 추가 됐으니 실제로 플러그인 매니저에 실행로직을 정의해보겠습니다.

class PluginManager {
  ...,
  static fireEvent(eventName, dataset) {
    if (dataset.group === 'documentToolbar') {
      PluginManager.plugins.filter(plugin => plugin.type === dataset.name).forEach(plugin => {
        plugin.emit('click', dataset)
      });
    }
  }
}

플러그인 매니저에서 DocumentToolbar 버튼을 클릭한 시점에 플러그인으로 메세지를 보낼 준비가 되었습니다. 이제 플러그인의 emit을 정의해, 플러그인의 callbacks.click 함수를 연결하도록 하겠습니다.

class Plugin {
  ...,
  emit (eventName, ...args) {
    if (typeof this.obj.callbacks[eventName] === 'function') {
      this.obj.callbacks[eventName](this, ...args);
    }
  }  
}

DocumentToolbar 에서 정의된 버튼을 클릭하면 callbacks에 있는 click 함수가 실행되도록 정의하였습니다. click 함수가 실행될 때 커스텀 컴포넌트를 넣어볼게요.

PluginManager.add(new Plugin({
  ...,
  callbacks: {
    click: (instance) => {
      // 플러그인 인스턴스에 정의된 api 를 통해서 커스텀 컴포넌트를 추가 
      instance.insertCustomComponent({ 
        count: 0
      })
    }
  }   
})

이제 커스텀 컴포넌트를 넣을 수 있게 되었습니다. insertCustomComponent를 통해서 실행하면 플러그인에서 정의한 type과 render 함수를 통해 에디터에 해당 컴포넌트가 렌더링 됩니다.

마지막으로 렌더링 된 컴포넌트를 변경할 수 있는 이벤트 정의해보도록 하겠습니다. 먼저 렌더링 함수를 살펴볼까요?

PluginManager.add(new Plugin({
  ...,
  render: (instance, { count }, { id }) => {
    return `
      <button type="button">
        ${count}   
      </button> 
    `;
  }
})

render() 함수가 dom이 아닌 html 코드만을 리턴하는 것을 알 수 있습니다. 그렇기 때문에 커스텀 컴포넌트가 그려지는 시점에서는 html 코드가 들어가고 렌더링 된 이후에 적용된 dom 을 가지고 올 수 있어야 이벤트를 설정할 수 있게 됩니다.

render 함수 마지막 파라미터로 id를 가지고 있는 객체를 넘겨줍니다. id는 생성된 커스텀 객체 고유의 id이기 때문에 에디터 문서구조에서 유일한 상태입니다.

이 id를 가지고 실제 dom을 조회하도록 바꿔보겠습니다. 간단한 click 이벤트를 추가해보도록 하겠습니다.

PluginManager.add(new Plugin({
  ...,
  // render 이후 실행 되는 함수 
  afterRender: (instance, {count}, {id} ) => {
    const $el = document.getElementById('custom-component-' + id);
    if ($el) {
      $el.addEventListener('click', () => {
        insert.updateCustomData({ count: count + 1 })
      });
    }  
  },
  render: (instance, { count }, { id }) => {
    return `
      <div id="custom-component-${id}">
        <button type="button">${count}</button>
      </div>
    `;
  }
})

id를 기반으로 $dom 을 추적해서 이벤트를 적용해보았습니다. 이 방식대로라면 html로 표현할 수 없는 다른 컴포넌트도 넣을 수 있게 되는데요. 리액트 컴포넌트도 한번 넣어보도록 하겠습니다.

PluginManager.add(new Plugin({
  ...,
  // render 이후 실행 되는 함수 
  afterRender: (instance, {count}, {id} ) => {
    const $el = document.getElementById('custom-component-' + id);
    if ($el) {
      // ReactDOM.render 를 사용해서 간단히 컴포넌트를 렌더링 할 수도 있습니다. 
      ReactDOM.render(
        <div onClick={() => instance.update(id, { count: count + 1 } )}>
          {count}
        </div>,
        $el
      );
    }
  },
  render: (instance, { count }, { id }) => {
    return `<div id="custom-component-${id}"></div>`;
  }  
})

마무리

이번 글에서는 플러그인을 통해 스마트에디터를 외부에서도 쉽게 확장할 수 있는 방법을 알아보았습니다. Plugin객체를 통해 원하는 기능을 설계하고, PluginManager를 통해 플러그인을 등록하여 스마트에디터에 원하는 컴포넌트를 추가할 수 있었습니다.

기초적인 공사를 했으니 다음에는 좀 더 다양하고 멋진 플러그인으로 찾아뵙도록 하겠습니다. 재미난 플러그인 이야기로 다시 돌아올게요~ ^^ 긴 글 읽어주셔서 감사합니다.

박진호 | Alto TF 
박진호 | Alto TF 

행복 개발자