RoosterJs로 에디터 표 기능 만들기

정다빈·2025년 4월 8일
0
post-thumbnail

최근 전자문서+에 14종의 문서가 업데이트되었습니다! 🥳

새로운 문서들은 어린이집에서 서식을 자유롭게 수정할 수 있도록 에디터 기능을 제공하고 있는데요, 이 에디터는 RoosterJs 라이브러리를 사용해서 개발했어요.
RoosterJs는 마이크로소프트에서 활발하게 유지 보수하고 있는 오픈 소스 라이브러리지만, 문서가 친절하지 않고 레퍼런스가 없어서 단기간에 개발하는데 어려움이 있었어요.

제가 개발한 표 기능에 대해 레퍼런스를 만들어 보고자 이번 글을 작성하게 되었어요.

⚙️ 개발 환경

전자문서+는 리액트로 만들어졌기 때문에 앞으로 리액트를 기준으로 설명할게요.

RoosterJs에서 roosterjs-react 패키지를 제공하고 있지만, 이는 데모용 패키지이기 때문에 새로운 기능을 제공하지 않거나 종속성 문제가 발생할 수 있다고 안내하고 있어요.
따라서 rooster-react 패키지를 꼭 사용해야 하는 경우가 아니라면 기본적으로 rooster 패키지를 사용했어요.

에디터의 뼈대를 만들기 위해 <Rooster /> 컴포넌트를 추가하고 editorCreator props를 넣어주었어요. 이 함수에서 에디터 인스턴스를 리턴하면 에디터의 기본 기능이 만들어져요.
에디터 인스턴스는 new Editor()new EditorAdapter() 두 가지 방식으로 생성할 수 있어요. EditorAdapter가 더 다양한 인터페이스를 제공하기 때문에 전자문서+에서는 EditorAdapter를 사용했어요.

import { Rooster } from 'roosterjs-react';
import { EditorAdapter } from 'roosterjs-editor-adapter';

export default function Editor() {
  return <Rooster editorCreator={(div) => new EditorAdapter(div)} />;
}

지금은 뼈대만 만들어졌기 때문에, 아래처럼 아무런 기능도 사용할 수 없는 상태예요.

RoosterJs는 기능을 직접 구현할 수 있도록 다양한 함수를 제공해요. 이를 통해 글자 색을 바꾸거나, 이미지를 추가하는 기능을 만들 수 있어요.

또한 RoosterJs에서 제공하는 플러그인을 사용하면 원하는 기능을 쉽게 추가할 수 있어요.

import { Rooster } from 'roosterjs-react';
import { EditorAdapter } from 'roosterjs-editor-adapter';
import { WatermarkPlugin, ShortcutPlugin } from 'roosterjs-content-model-plugins';

export default function Editor() {
  const editorCreator = (div) => {
    const plugins = [
      new WatermarkPlugin('내용을 작성해 주세요.'), // placeholder를 추가하는 플러그인
      new ShortcutPlugin(), // Ctrl + Z와 같은 단축키를 지원하는 플러그인
    ];
      
    return new EditorAdapter(div, { plugins });
  };
  
  return <Rooster editorCreator={editorCreator} />;
}

🪄 표 만들기

insertTable 함수를 사용해서 표를 만들 수 있어요.

파라미터
1. editor: 에디터 인스턴스
2. columns: 열의 수
3. rows: 행의 수

import { insertTable } from 'roosterjs';

export default function InsertTableButton() {
  return (
    <button onClick={() => insertTable(editor, 5, 4)}>
      표 만들기
    </button>
  );
}

📡 셀 선택 여부 감지하기

getSelectedRegions 함수를 사용해서 셀 선택 여부를 알 수 있어요. 이를 통해 특정 기능들은 셀을 선택했을 때만 노출하도록 만들 수 있어요.

export default function getSelectedCells() {
  const cells = editor
    .getSelectedRegions() // 선택된 범위 가져오기
    .filter((cell) => ['TD', 'TH'].includes(cell.rootNode.nodeName)) // 선택된 범위 중 <th>, <td>만 가져오기
    .map((cell) => cell.rootNode); // 엘리먼트 리턴
  
  return cells;
}

🎞️ 행/열 추가 및 지우기

editTable 함수를 사용해서 행/열을 추가하거나 지울 수 있어요.

파라미터
1. editor: 에디터 인스턴스
2. operation: 적용할 작업 (insertAbove, deleteColumn 등)

import { editTable } from 'roosterjs';

export default function InsertRowButton() {
  return (
    <button onClick={() => editTable(editor, 'insertAbove')}>
      위쪽에 줄 추가하기
    </button>
  );
}

🍰 셀 합치기 및 나누기

editTable 함수를 사용해서 셀을 합치거나 나눌 수 있어요.

파라미터
1. editor: 에디터 인스턴스
2. operation: 적용할 작업 (mergeCells, splitVertically, splitHorizontally)

import { editTable } from 'roosterjs';

export default function MergeCellButton() {
  return (
    <button onClick={() => editTable(editor, 'mergeCells')}>
      셀 합치기
    </button>
  );
}

🖱️ 표 우클릭

createContextMenuProviderContextMenuPluginBase를 사용해서 표 우클릭 동작을 커스텀할 수 있어요.

1. 표 우클릭 시 노출할 메뉴를 선언합니다.

// constants.ts
import { editTable } from 'roosterjs';

const onClick = (editor, key) => {
  editTable(editor, key); // key: insertAbove, insertBelow 등 적용할 작업
};

const insertMenu = {
  key: 'insertMenu',
  unlocalizedText: '줄/칸 추가하기',
  subItems: {
    insertAbove: '위쪽에 줄 추가하기',
    insertBelow: '아래쪽에 줄 추가하기',
    insertLeft: '왼쪽에 칸 추가하기',
    insertRight: '오른쪽에 칸 추가하기',
  },
  onClick,
};

export const tableEditMenu = [insertMenu];

2. 우클릭 플러그인을 추가합니다.

// Editor.tsx
import ReactDOM from 'react-dom';

import { EditorAdapter } from 'roosterjs-editor-adapter';
import { Rooster, createContextMenuProvider } from 'roosterjs-react';
import { ContextMenuPluginBase } from 'roosterjs-content-model-plugins';

import { tableEditMenu } from './constants';

// 편집중인 표 정보 가져오기
const getEditingTable = (editor, node) => {
  const domHelper = editor.getDOMHelper();
  const td = domHelper.findClosestElementAncestor(node, 'TD,TH');
  const table = td && domHelper.findClosestElementAncestor(td, 'table');

  return table?.isContentEditable ? { table, td } : null;
};

export default function Editor() {
  const editorCreator = (div) => {
    const plugins = [
      // 표 우클릭 시 노출할 메뉴 정의
      createContextMenuProvider(
        'tableEdit',
        tableEditMenu,
        undefined,
        (editor, node) => !!getEditingTable(editor, node),
      ),
      
      // 표 우클릭 시 컴포넌트 노출
      new ContextMenuPluginBase({
        render: (container, items) => {
          ReactDOM.render(
            <Dropdown
              items={items}
              onClose={() => ReactDOM.unmountComponentAtNode(container)}
            />,
            container,
          );
        },
      }),
    ];

    return new EditorAdapter(div, { plugins });
  };
  
  return <Rooster editorCreator={editorCreator} />
}

renderitems는 아래와 같은 형태를 갖고 있어요.

[
  {
    key: 'insertMenu',
    text: '줄/칸 추가하기',
    data: {
      key: 'insertMenu',
      unlocalizedText: '줄/칸 추가하기',
      subItems: {
        insertAbove: '위쪽에 줄 추가하기',
        insertBelow: '아래쪽에 줄 추가하기',
        insertLeft: '왼쪽에 칸 추가하기',
        insertRight: '오른쪽에 칸 추가하기',
      },
      onClick,
    },
  },
]

3. 우클릭 컴포넌트를 만들어 줍니다.

// Dropdown.tsx
export default function Dropdown({ items, onClose }) {
  return (
    <div>
      {items.map((item) => (
        <div>
          <button>
            {item.text} /* 1 depth */
          </button>
          
          {Object.keys(item.data.subItems).map((key) => (
            <button onClick={() => item.data.onClick(editor, key)}>
              {item.data.subItems[key]} /* 2 dpeth */
            </button>
          ))}
        </div>
      ))}
    </div>
  );
}

🧮 표 정렬

editTable 함수를 사용해서 표를 정렬할 수 있어요.

파라미터
1. editor: 에디터 인스턴스
2. operation: 정렬 위치 (alignLeft, alignCenter, alignRight)

import { editTable } from 'roosterjs';

export default function AlignTableButton() {
  return (
    <button onClick={() => editTable(editor, 'alignCenter')}>
	  표 가운데 정렬하기
    </button>
  );
}

🪟 표 테두리

applyTableBorderFormat 함수를 사용해서 셀에 테두리를 적용할 수 있어요.

파라미터
1. editor: 에디터 인스턴스
2. border: 적용할 테두리 (color, style, width)
3. operation: 적용할 테두리 위치 (leftBorders, rightBorders, topBorders 등)

테두리 색상은 HEX 코드와 RGB 등 CSS에서 제공하는 표기법을 사용할 수 있으나, RoosterJs 내부적으로 RGB로 변환하기 때문에 실제로는 모두 RGB로 적용돼요. #FFFFFFrgb(255, 255, 255)

import { applyTableBorderFormat } from 'roosterjs';

export default function ApplyBorderButton() {
  return (
    <button onClick={() => {
      applyTableBorderFormat(
        editor,
        { color: '#FFFFFF', style: 'solid', width: '1pt' },
        'outsideBorders',
      );
    }}>
      표 테두리 적용하기
    </button>
  );
}

🎨 표 채우기

setTableCellShade 함수를 사용해서 셀에 배경색을 적용할 수 있어요.

파라미터
1. editor: 에디터 인스턴스
2: color: 적용할 색상 (색상을 제거하려면 null)

HEX 코드와 RGB 등 CSS에서 제공하는 표기법을 사용할 수 있으나, RoosterJs 내부적으로 RGB로 변환하기 때문에 실제로는 모두 RGB로 적용돼요. #FFFFFFrgb(255, 255, 255)

import { setTableCellShade } from 'roosterjs';

export default function FillColorButton() {
  return (
    <button onClick={() => setTableCellShade(editor, '#FFFFFF')}>
      표 채우기
    </button>
  );
}

👴🏻 마무리

RoosterJs처럼 레퍼런스가 없는 라이브러리는 처음 사용해 봤는데요, 처음에는 동작을 이해하기 어려워서 머리를 쥐어뜯곤 했지만... 시간이 지날수록 점점 이해도가 올라가서 적응이 되더라구요. 그리고 막상 완성된 에디터를 보니 힘들었던 기억이 싹~ 씻기진 않았지만 💦 뿌듯했어요!

RoosterJs 레퍼런스가 많이 생기길 바라며 여기서 마치겠습니다.

profile
Frontend Developer

0개의 댓글