Skip to content

06. Lexical Editor

pozafly edited this page Sep 13, 2023 · 1 revision

개요

Lexcial은 Editor 프레임워크입니다. React를 만든 facebook에서 개발했으며, 확장성을 특징으로 가지고 있다고 소개합니다. 하지만, 아직 0.12 버전으로 정식 1 버전이 되지는 않았지만 React와의 궁합이 좋고 React와 비슷한 성격을 가지고 있어 Jiary에 적용하게 되었습니다.

자신을 라이브러리가 아닌 프레임워크라고 말하고 있습니다. 코드를 개발자가 아닌 코드가 통제하고 있음을 알 수 있고, 내부적으로 많은 작업이 처리되고 있음을 알 수 있습니다. 특히 Editor plugin을 제작해보면서 프레임워크의 성격이 강하다는 것을 느낄 수 있었습니다.

아래는 작업을 하면서 알게 된 Lexical Editor의 특징 및 사용법입니다.


특징

Editor States

에디터가 표현하는 모든 정보를 가지고 있는 상태 값입니다. React의 useState 결과 값으로 나오는 것과 비슷하며, State가 업데이트되면 화면이 다시 그려집니다. 단, State를 업데이트 하려면 editor.update(() => {...}} 메서드를 통해서 가능합니다.

State는 JSON 형태로 직렬화 할 수 있으며, 반대로 역직렬화도 가능합니다. 따라서, Editor에 작성하는 모든 데이터를 JSON 형태로 만들고 Database에 저장할 수 있고, 반대로 Database로부터 직렬화 된 JSON 데이터를 받아 역직렬화하여 Lexical Editor에 표시할 수 있습니다.

Jiary는 Google Drive API를 사용해 다이어리에 적힌 사용자 데이터를 직렬화 된 JSON 형태로 Drive에 저장하는 메커니즘을 갖고 있습니다. 또한, 다이어리에 접근했을 때 Drive에서 직렬화된 데이터를 받아와 역직렬화 하여 Editor States로 만들어 에디터에 표현합니다.

DOM Reconciler

React에서도 Reconciler가 존재하며, 이는 React의 가상 DOM과 실제 DOM을 동기화 하는데 사용됩니다. Lexical에서도 마찬가지로 Editor States를 기반으로 실제 DOM과의 차이점을 계산해 변경이 필요한 실제 DOM 요소를 업데이트 합니다.

Listeners, Node Transforms and Commands

Editor States를 업데이트 하는 것 외에 Listener와 Commands 명령을 이용해 Editor States를 변경시킬 수 있습니다. 마치 React의 JSX에 이벤트 리스너를 부착하는 것과 같은 효과 입니다. 이 명령어를 통해 표현하고 있는 Node

Node & Custom Node

Lexical에서 말하는 Node는 Editor에 표현되는 DOM 입니다. Root Node 같은 경우 Editor 전체를 감싸고 있는 DOM이며, Paragraph Node는 <p> 태그와 같이 Paragraph Element로 표현됩니다. Custom Node는 Editor DOM에서 표현하고 싶은 Node를 직접 만들 수 있는 기능을 말합니다. Jiary에서는 MapInfoNode 라는 Custom Node를 만들어 사용했습니다. 기본 스타일을 지정할 수 있으며, 직렬화와 역직렬화할 때 어떤 형태를 가져야 하는지 지정할 수 있습니다.


Jiary에 적용한 Custom Node(MapInfoNode)

Jiary에서는 기본적인 Lexical Editor에는 없는 Node가 필요했습니다. 다이어리 페이지에 접속했을 때, 다이어리의 위치 정보를 가져와 마커를 표현해줍니다. 때문에 위치 정보 또한 Google Drive API를 사용해 저장이 되어야 했고, Editor States에 포함되어야 했기 때문입니다. 따라서, 지도에 표시되는 마커의 정보를 담은 새로운 Node가 필요했습니다.

Lexical에서 제공하는 TextNode를 상속받아 MapInfoNode를 만들었습니다. 아래는 주요한 MapInfoNode의 코드입니다.

1. constructor

export class MapInfoNode extends TextNode {
  map: google.maps.places.PlaceResult;
  text: string;

  constructor(text: string, map: google.maps.places.PlaceResult) {
    super(text);
    this.text = text;
    this.map = map;
    this.setTextContent(text);
    this.setMode('token');
    this.__type = 'map-info-node';
  }
  (...)
}

MapInfoNode를 정의할 때, TextNode를 상속 받아 진행합니다. Node 자체가 Lexical에 초기화 될 때 내부적으로 여러 메서드를 호출하기 때문에 Lexical에서 이를 권장하고 있습니다. constructor에서는 장소 정보가 에디터에 표현될 text를 받고 있으며, 지도에 마커가 표현될 정보인 map 즉, 장소 정보를 받고 있습니다.

2. createDOM() 메서드

export class MapInfoNode extends TextNode {
  (...)
  createDOM(): HTMLElement {
    const wrapper = document.createElement('div');
    const span = document.createElement('span');
    const info = document.createElement('div');

    span.style.display = 'inline-block';
    info.style.display = 'none';
    info.classList.add('map-info');
    (...)
    
    info.textContent = JSON.stringify(this.map);

    wrapper.appendChild(span);
    wrapper.appendChild(info);

    return wrapper;
  }
}

createDOM 메서드는 JavaScript를 이용해 DOM을 생성하는 단계입니다. Node가 화면에 표현되기 전에 DOM이 어떻게 표현되어야 하는지를 정의해줍니다. MapInfoNode는 위의 로직 덕분에 DOM에 표현될 때 아래와 같이 HTML로 표현됩니다.

<div>
  <span>[에디터에 표현될 문구]</span>
  <div style="display: none">[map의 위치 정보]</div>
</div>

이렇게 MapInfoNode가 화면에 그려지게 되면, 위치 정보를 담고 있는 DOM은 화면에 보이지는 않지만, Editor가 변경되어 Google Drive에 저장될 때 함께 직렬화되어 저장됩니다. 또한, Drive로 부터 직렬화된 정보를 역직렬화 할 때도 마찬가지로 위치 정보를 보존할 수 있습니다.

3. type

Lexical Node에서 type은 Node 자체를 구분할 수 있는 키워드로서 매우 중요합니다. Custom Node 이므로 map-info-node 라고 지정해주었습니다. 이는 Editor가 초기화 될 때 호출됩니다. 또한, 아래에서 살펴볼 exportJSON() 메서드에서도 이 type을 지정해주어야 합니다.

export class MapInfoNode extends TextNode {
  (...)
  static getType() {
    return 'map-info-node';
  }
}

만약 Node가 렌더링 되었을 경우, type이 맞지 않으면 error가 발생합니다. Lexical 내부적으로 이 type을 가지고 diff를 계산합니다.

4. importJSON(), exportJSON() 메서드

export class MapInfoNode extends TextNode {
  (...)
  static importJSON(serializedMapNode: SerializedMapNode): TextNode {
    return $createMapInfoNode(serializedMapNode.text, serializedMapNode.map);
  }

  exportJSON(): SerializedTextNode & {
    map: google.maps.places.PlaceResult;
    type: string;
  } {
    return {
      ...super.exportJSON(),
      type: 'map-info-node',
      map: this.map,
    };
  }
}

importJSON과 exportJSON 메서드는 Lecial Editor에서 직렬화와 역직렬화 할 경우 호출되는 메서드입니다. Custom Node를 생성할 경우 필수적으로 Override 해주어야 합니다. Custom Node에서 추가적으로 사용하는 정보들을 포함하고 있습니다.


Custom Node를 제작했다면, Lexical Editor의 가장 상위 컴포넌트의 initialConfig.nodes에 반드시 명시해주어야만 사용할 수 있습니다.


plugin

plugin은 Lexical Editor의 기능을 단위로 묶어 사용할 수 있도록 도와줍니다. Lexical은 tree-shaking을 위해 모든 단위를 plugin 단위로 나누었습니다. 실제 텍스트를 입력할 수 있는 RichTextPlugin 도 마찬가지로 plugin 형태로 만들어져 있습니다. 따라서, plugin을 직접 만들 수도 있으며 다른 사람이 만들어 둔 plugin을 import 해서 사용할 수도 있습니다. plugin은 아래 코드와 같이 Lexical Editor 상위 컴포넌트 내부에 어디에나 선언할 수 있습니다.

<LexicalComposer initialConfig={{...}}>
  <EditablePlugin documentData={documentData} />
  <ToolbarPlugin />
  <div className="editor-inner">
    <RichTextPlugin contentEditable={<ContentEditable className="editor-input" />} />
    <OnChangePlugin onChange={handleChange} ignoreSelectionChange />
    <InitalPlugin initValue={documentData} editorRef={editorRef} />
    <MarkerSetPlugin metaData={metaData} />
  </div>
</LexicalComposer>

Jiary에서 사용한 plugin

Lexcial Editor를 처음 렌더링 해보면, editor 위에 텍스트를 꾸밀 수 있는 toolbar가 존재하지 않았습니다. Jiary에서는 다른 사람이 생성해둔 toolbar plugin을 가져와 커스텀하여 사용했습니다. 그 중, 위치를 검색할 수 있는 map toolbar가 필요한 상황이었고, MapToolbarPlugin을 생성해 포함시켰습니다.

생김새는 React Component와 동일한 형태로 제작할 수 있습니다. 추가로, plugin 내부에서는 Lexical의 내부 메서드를 사용할 수 있으며 Editor 전체에서 접근할 수 있는 Context API 또한 사용할 수 있습니다.

const [editor] = useLexicalComposerContext();

context에서 editor 변수를 가져와 상태 값 혹은 update, command 메서드를 사용할 수 있습니다.