React와 TypeScript 프로젝트에서 TinyMCE에 수학 수식을 입력할 수 있는 Equation Editor 플러그인을 추가하고자 합니다. TinyMCE에서 Equation Editor 플러그인을 제공하는 공식 문서는 바닐라 JavaScript를 기준으로 설명되어 있어 React 환경에 맞춰 커스텀 설정이 필요했습니다.
이 글에서는 React와 TypeScript 환경에서 TinyMCE를 설정하고, 수식 입력을 위한 Equation Editor 플러그인을 적용하는 방법을 소개합니다.
우선 tiny editor 를 사용하기 위해 필요한 라이브러리를 설치합니다.
$ pnpm add tinymce @tinymce/tinymce-react
@tinymce/tinymce-react는 React 환경에서 TinyMCE를 쉽게 사용할 수 있도록 도와주는 라이브러리입니다.
tinymce 라이브러리는 TinyMCE 에디터 자체이며, Equation Editor 플러그인을 생성할 때 필요합니다.
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 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>`;
위에서 만든 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의 기본 이미지 플러그인은 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 에디터 적용하기