TinyMCE 에 수학 수식 플러그인 설정(React + Typescript + Tiny Editor + Equation Editor)

iberis2·2024년 11월 6일
1

React와 TypeScript 프로젝트에서 TinyMCE에 수학 수식을 입력할 수 있는 Equation Editor 플러그인을 추가하고자 합니다. TinyMCE에서 Equation Editor 플러그인을 제공하는 공식 문서는 바닐라 JavaScript를 기준으로 설명되어 있어 React 환경에 맞춰 커스텀 설정이 필요했습니다.

이 글에서는 React와 TypeScript 환경에서 TinyMCE를 설정하고, 수식 입력을 위한 Equation Editor 플러그인을 적용하는 방법을 소개합니다.

TinyMCE 설치와 기본 설정

우선 tiny editor 를 사용하기 위해 필요한 라이브러리를 설치합니다.

tiny editor 공식문서

$ pnpm add tinymce @tinymce/tinymce-react

@tinymce/tinymce-react는 React 환경에서 TinyMCE를 쉽게 사용할 수 있도록 도와주는 라이브러리입니다.
tinymce 라이브러리는 TinyMCE 에디터 자체이며, Equation Editor 플러그인을 생성할 때 필요합니다.

TinyMCE API 키 발급 및 기본 적용

TinyMCE를 사용하기 위해 API 키가 필요합니다. TinyMCE API 키 발급에서 회원가입 후 키를 발급받으세요. TinyMCE를 React에 적용한 기본 예제는 아래와 같습니다.

// tiny-editor.tsx
import { useRef } from 'react';
import { Editor } from '@tinymce/tinymce-react';
import { Editor as TEditor } from 'tinymce';

export default function TinyEditor() {
  const editorRef = useRef<TEditor | null>(null);
  const log = () => {
    if (editorRef.current) {
      console.log(editorRef.current.getContent());
    }
  };

  return (
    <>
      <Editor
        apiKey="" // api key
        onInit={(_evt, editor) => {
          editorRef.current = editor;
        }}
        init={{
          base_url: '/node_modules/tinymce', // TinyMCE가 설치된 경로
          suffix: '.min', // minified 파일 로드
          height: 500,
          menubar: false,
          plugins: [
            'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
            'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
            'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
          ],
          toolbar:
            'undo redo | blocks | ' +
            'bold italic | alignleft aligncenter ' +
            'alignright alignjustify | bullist numlist outdent indent | ' +
            'removeformat link | help',
          content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }',
        }}
      />
      <button onClick={log}>Log editor content</button>
    </>
  );
}

Equation Plugin (for React)

수식 입력 기능을 추가하기 위해 Equation Editor 플러그인을 사용해야 합니다. Equation Editor는 npm 설치도 가능하지만, Vanilla JavaScript 기반이라 React에서 커스터마이징이 필요합니다.

이에 따라 JavaScript 파일을 가져와 TypeScript로 마이그레이션하고, TinyMCE 초기화 시 플러그인이 함께 실행되도록 설정했습니다.

// tiny-editor-plugin.tsx
import tinymce from 'tinymce';

// 플러그인 관련 클래스 타입 정의
declare namespace EqEditor {
  class Output {
    constructor(elementId: string);
    exportAs(format: string): string;
  }

  class TextArea {
    static link(elementId: string): TextArea;
    addOutput(output: Output): TextArea;
    addHistoryMenu(history: History): TextArea;
    insert(text: string, pos: number): void;
    clear(): void;
    pushToHistory(): void;
  }

  class History {
    constructor(elementId: string);
  }

  class Toolbar {
    static link(elementId: string): Toolbar;
    addTextArea(textArea: TextArea): Toolbar;
  }
}

// ----------------------------------------------------------------------

let output: any;
let textarea: any;
export const initEqnEditorPlugin = () => {
  tinymce.PluginManager.add('eqneditor', function (editor, url) {
    const host = 'latex.codecogs.com';
    const http = document.location.protocol === 'https:' ? 'https://' : 'http://';

    // CodeCogs Equation Editor의 JavaScript 로드
    const scriptLoader = new tinymce.dom.ScriptLoader();
    scriptLoader.add(`${http}${host}/js/eqneditor.api.min.js`);
    const loaded = scriptLoader.loadQueue();

    // Load custom CSS
    tinymce.DOM.loadCSS(`${http}${host}/css/eqneditor_1.css`);

    editor.ui.registry.addIcon('logo', logoIcon);
    editor.ui.registry.addIcon('menu', menuIcon);

    // 플러그인 아이콘과 UI 설정
    function showDialog(input = '') { // TinyMCE 에디터 내에서 수식 입력을 위한 다이얼로그 창을 설정
      const win = editor.windowManager.open({
        title: 'Equation Editor',
        size: 'medium',
        body: {
          type: 'panel',
          items: [
            {
              type: 'htmlpanel',
              html: `
                  <div id="equation-editor">
                    <div id="history"></div>
                    <div id="toolbar"></div>
                    <div id="latexInput" autocorrect="off"></div>
                    <div id="equation-output">
                      <img id="output"/>
                    </div>
                  </div>
                `,
            },
          ],
        },
        buttons: [
          {
            type: 'cancel',
            name: 'closeButton',
            text: 'Cancel',
          },
          {
            type: 'submit',
            name: 'submitButton',
            text: 'Ok',
            buttonType: 'primary',
          },
          {
            type: 'custom',
            name: 'logo',
            text: '',
            icon: 'logo',
            align: 'start',
          },
        ],
        onSubmit(w) {
          if (output && textarea) {
            const htmlContent = output.exportAs('html');
            if (htmlContent) {
              tinymce.activeEditor?.execCommand('mceInsertContent', false, htmlContent);
              textarea.pushToHistory();
            } else {
              console.error('Failed to export content as HTML');
            }
          } else {
            console.error('Output or TextArea is not initialized');
          }
          w.close();
          output = null;
          textarea = null;
        },
        onCancel(w) {
          w.close();
          output = null;
          textarea = null;
        },
        onAction(w, dets) {
          if (dets.name === 'logo') {
            window.open(`${http}editor.codecogs.com`, '_blank')?.focus();
          }
        },
      });
      // `loaded` 프로미스가 완료되었는지 확인 후 다이얼로그 실행
      loaded.then(() => {
        if (!output || !textarea) {
          output = new EqEditor.Output('output');
          textarea = EqEditor.TextArea.link('latexInput')
            .addOutput(output)
            .addHistoryMenu(new EqEditor.History('history'));
          EqEditor.Toolbar.link('toolbar').addTextArea(textarea);

          const nodes = document.getElementById('history')?.childNodes;
          nodes?.forEach((node) => ((node as HTMLElement).style.padding = 'revert'));
          textarea.clear();
          textarea.insert(input.replace(/&space;/g, ' '), input.length);
        }
      });
    }

    editor.ui.registry.addButton('eqneditor', {
      icon: 'menu',
      tooltip: 'Insert Equation',
      onAction: () => showDialog(),
    });

    editor.on('dblclick', (e) => {
      if (e.target.nodeName.toLowerCase() === 'img') {
        const sName = e.target.src.match(/(gif|svg)\.image\?(.*)/);
        if (sName) showDialog(sName[2]);
      }
    });

    return {
      getMetadata: () => ({
        name: 'EqnEditor',
        url: 'https://editor.codecogs.com/docs/3-Tiny_MCE_v4x.php',
      }),
    };
  });
};

// ----------------------------------------------------------------------
const logoIcon = `<svg> /* 아이콘 생략 */ </svg>`;
const menuIcon = `<svg>	/*아이콘 생략*/ </svg>`;

최종 결과: TinyMCE에 Equation Plugin 적용

위에서 만든 initEqnEditorPlugin을 TinyMCE 에디터에 적용하여 최종적으로 Equation 플러그인을 활성화합니다.

// tiny-editor.tsx
import { useRef } from 'react';
import { Editor } from '@tinymce/tinymce-react';
import { Editor as TEditor } from 'tinymce';

import { initEqnEditorPlugin } from './tiny-editor-plugin';
initEqnEditorPlugin();

export default function TinyEditor() {
  const editorRef = useRef<TEditor | null>(null);
  const log = () => {
    if (editorRef.current) {
      console.log(editorRef.current.getContent());
    }
  };

  return (
    <>
      <Editor
        apiKey="" // api key
        onInit={(_evt, editor) => {
          editorRef.current = editor;
          initEqnEditorPlugin();
        }}
        init={{
          base_url: '/node_modules/tinymce', // TinyMCE가 설치된 경로
          suffix: '.min', // minified 파일 로드
          height: 500,
          menubar: false,
          plugins: [
            'advlist',
            'autolink',
            'lists',
            'link',
            'image',
            'charmap',
            'preview',
            'anchor',
            'searchreplace',
            'visualblocks',
            'code',
            'fullscreen',
            'insertdatetime',
            'media',
            'table',
            'help',
            'wordcount',
            'eqneditor', // 플러그인 추가
          ],
          toolbar:
            'undo redo | blocks | ' +
            'bold italic | alignleft aligncenter ' +
            'alignright alignjustify | bullist numlist outdent indent | ' +
            'removeformat link image | eqneditor | help',
          content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }',
          image_title: true,
          automatic_uploads: true,
          file_picker_types: 'image',
          file_picker_callback: (cb, _value, _meta) => {
            const input = document.createElement('input');
            input.setAttribute('type', 'file');
            input.setAttribute('accept', 'image/*');

            input.addEventListener('change', (event) => {
              const file = (event.target as HTMLInputElement).files?.[0];
              if (file) {
                const reader = new FileReader();
                reader.onload = () => {
                  const id = 'blobid' + new Date().getTime();
                  const blobCache = (window as any).tinymce.activeEditor.editorUpload.blobCache;
                  const base64 = (reader.result as string).split(',')[1];
                  const blobInfo = blobCache.create(id, file, base64);
                  blobCache.add(blobInfo);

                  // Invoke callback with the base64 URI
                  cb(blobInfo.blobUri(), { title: file.name });
                };
                reader.readAsDataURL(file);
              }
            });

            input.click();
          },
        }}
      />
      <button onClick={log}>Log editor content</button>
    </>
  );
}

번외: TinyMCE 이미지 업로드 설정

TinyMCE의 기본 이미지 플러그인은 URL을 통해서만 이미지를 업로드합니다.

이미지 파일 업로드 기능 추가를 위해서는 init props 에 file_picker_types, file_picker_callback 을 설정해주어야 합니다.

<Editor
  init = {{
    // ...생략
    file_picker_types: 'image', // 'image', 'media', 'file' 타입 중 한 가지 
    file_picker_callback: (callback, _value, _meta) => {incodeImageToBase64(callback)} // 이미지 파일을 받아서 처리해줄 콜백 함수
  }}
 />

서버 업로드 없이 프론트에서 이미지를 처리해서 보여주기 위해 base64 문자열로 이미지를 인코딩하는 함수 incodeImageToBase64 를 추가하였습니다.

// 이미지를 base64 문자열로 인코딩
const incodeImageToBase64 = (cb: (value: string, meta?: Record<string, any>) => void) => {
  const input = document.createElement('input');
  input.setAttribute('type', 'file');
  input.setAttribute('accept', 'image/*');

  input.addEventListener('change', (event) => {
    const file = (event.target as HTMLInputElement).files?.[0];
    if (file) {
      const reader = new FileReader();
      reader.onload = () => {
        const id = 'blobid' + new Date().getTime();
        const blobCache = (window as any).tinymce.activeEditor.editorUpload.blobCache;
        const base64 = (reader.result as string).split(',')[1];
        const blobInfo = blobCache.create(id, file, base64);
        blobCache.add(blobInfo);

        // Invoke callback with the base64 URI
        cb(blobInfo.blobUri(), { title: file.name });
      };
      reader.readAsDataURL(file);
    }
  });

  input.click();
};

하지만 base64로 인코딩된 문자열은 매우 긴 문자열로, 만약 이미지를 여러개 사용하는 경우 서버에 업로드후 해당 주소만 받아와서 사용하는 callback 함수로 만들 수도 있을 것입니다.

결과물


참고
Equation Editor Plugin
TinyMCE 공식문서
[TIL] TinyMCE 에디터 적용하기

profile
React, Next.js, TypeScript 로 개발 중인 프론트엔드 개발자

0개의 댓글