에디터를 선택하기 위해 다양한 옵션을 검토해보았습니다.
Toast UI, Editor.js, CKEditor 등 여러 에디터가 있었지만, 결국 Tiptap을 선택하게 되었습니다.
그 이유는 무엇보다 커스터마이징이 자유롭고 확장성이 뛰어나며, React 환경과의 호환성이 매우 우수했기 때문입니다.
초기에는 이미지를 업로드할 때 FileReader를 사용하여 이미지를 base64로 인코딩한 후 에디터에 삽입하는 방식을 사용했습니다. 하지만 실제로 작성 중에 WYSIWYG → HTML → WYSIWYG으로 모드를 전환하는 과정에서, 이미지의 base64 인코딩 문자열이 너무 길어지면서 이미지 태그가 사라지는 문제가 발생하였습니다.
이 문제를 해결하기 위해, 이미지 선택 시 백엔드 서버에 S3 업로드를 요청하고, 응답으로 받은 returnUrl 값을 사용하여 이미지 태그의 src를 설정하는 방식으로 전환하였습니다.
이후에는 이미지가 사라지지 않고 안정적으로 유지되었으며, HTML 코드도 간결하게 관리할 수 있었습니다.
덕분에 에디터의 품질과 사용자 경험을 모두 만족시킬 수 있었습니다.
이번 글에서는 위와 같은 고민과 선택의 과정을 통해 구성한 Tiptap 기반의 커스터마이징 가능한 글쓰기 에디터의 구현 내용을 공유드리고자 합니다.
■ 코드에 필요한 npm 패키지
npm install @tiptap/react @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-color @tiptap/extension-highlight @tiptap/extension-link @tiptap/extension-text-style @tiptap/extension-text-align @tiptap/extension-table @tiptap/extension-table-row @tiptap/extension-table-cell @tiptap/extension-table-header
패키지명 | 역할 |
@tiptap/react | Tiptap 리액트 통합 |
@tiptap/starter-kit | 기본 문법 구성 요소 (bold, italic 등) |
@tiptap/extension-image | 이미지 삽입 |
@tiptap/extension-color | 텍스트 색상 변경 |
@tiptap/extension-highlight | 텍스트 배경 하이라이트 |
@tiptap/extension-link | 링크 삽입 기능 |
@tiptap/extension-text-style | 스타일 적용 (color와 같이 사용) |
@tiptap/extension-text-align | 좌/중/우 정렬 기능 |
@tiptap/extension-table | 표 기능 (기본 구조) |
@tiptap/extension-table-row | 표 행 조작 기능 |
@tiptap/extension-table-cell | 셀 조작 기능 |
@tiptap/extension-table-header | 헤더 셀 조작 기능 |
■ 전체 코드
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCellBase from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import Image from "@tiptap/extension-image";
import TextStyle from "@tiptap/extension-text-style";
import Color from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
import Link from "@tiptap/extension-link";
import TextAlign from "@tiptap/extension-text-align";
import { useState, useRef } from "react";
const TableCell = TableCellBase.extend({
addAttributes() {
return {
...this.parent?.(),
backgroundColor: {
default: null,
parseHTML: (element) => element.style.backgroundColor || null,
renderHTML: (attributes) => {
if (!attributes.backgroundColor) return {};
return {
style: background-color: ${attributes.backgroundColor},
};
},
},
};
},
});
const CustomTableHeader = TableHeader.extend({
addAttributes() {
return {
...this.parent?.(),
backgroundColor: {
default: null,
parseHTML: (element) => element.style.backgroundColor || null,
renderHTML: (attributes) => {
if (!attributes.backgroundColor) return {};
return {
style: background-color: ${attributes.backgroundColor},
};
},
},
};
},
});
export default function TiptapEditor() {
const fileInputRef = useRef(null);
const [mode, setMode] = useState<"wysiwyg" | "html">("wysiwyg");
const [htmlContent, setHtmlContent] = useState("");
const [showTextColor, setShowTextColor] = useState(false);
const [showHighlight, setShowHighlight] = useState(false);
const [showCellColor, setShowCellColor] = useState(false);
const editor = useEditor({
extensions: [
StarterKit,
Image,
Highlight.configure({ multicolor: true }),
Color,
TextStyle,
Link.configure({ openOnClick: false }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
Table.configure({ resizable: true }),
TableRow,
CustomTableHeader,
TableCell,
],
content: "<p>여기에 내용을 작성하세요.</p>",
});
const toggleMode = () => {
if (!editor) return;
if (mode === "wysiwyg") {
const html = editor.getHTML();
setHtmlContent(html);
setMode("html");
} else {
editor.commands.setContent(htmlContent);
setMode("wysiwyg");
}
};
const insertTable = () => {
const rows = parseInt(prompt("행 개수 입력") || "3");
const cols = parseInt(prompt("열 개수 입력") || "3");
editor?.commands.insertTable({ rows, cols, withHeaderRow: true });
};
const insertImageFromFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !editor) return;
//...S3에 파일 올린 후 URL return(별도 구현)
sample.mutate({file},
{
onSuccess: (data) => {
const reader = new FileReader();
reader.onload = () => {
const result = data.url;
editor.commands.setImage({ src: result });
};
reader.readAsDataURL(file);
}
}
)
};
const insertLink = () => {
const url = prompt("링크 URL을 입력하세요");
if (!url) return;
const target = prompt("새 창으로 열기? (y/n)", "y");
editor?.commands.setLink({
href: url,
target: target === "y" ? "_blank" : "_self",
});
};
const toggleBold = () => {
editor?.chain().focus().toggleBold().run();
};
const mergeCells = () => {
editor?.commands.mergeCells();
};
const addColumnBefore = () => {
editor?.commands.addColumnBefore();
};
const addColumnAfter = () => {
editor?.commands.addColumnAfter();
};
const addRowBefore = () => {
editor?.commands.addRowBefore();
};
const addRowAfter = () => {
editor?.commands.addRowAfter();
};
const deleteRow = () => {
editor?.commands.deleteRow();
};
const deleteColumn = () => {
editor?.commands.deleteColumn();
};
const alignLeft = () => editor?.chain().focus().setTextAlign("left").run();
const alignCenter = () =>
editor?.chain().focus().setTextAlign("center").run();
const alignRight = () => editor?.chain().focus().setTextAlign("right").run();
return (
<div className="flex-1 mx-auto mt-6">
{mode === "wysiwyg" ? (
<>
<div className="grid gap-3 items-center border-[1px] bg-[#f5f5f5]">
<div className="flex gap-2">
<button
onClick={toggleMode}
className="px-2 py-1 bg-red-200 text-white rounded cursor-pointer"
>
HTML 보기
</button>
<label className="px-2 py-1 bg-[white] border-[1px] border-[#dae0e5] rounded cursor-pointer">
이미지 업로드
<input
type="file"
accept="image/*"
ref={fileInputRef}
onChange={insertImageFromFile}
className="hidden"
/>
</label>
</div>
<div className="flex gap-2">
<button
onClick={insertLink}
className="px-2 py-1 bg-[white] border-[1px] border-[#dae0e5] rounded"
>
링크 삽입
</button>
<button
onClick={toggleBold}
className="px-2 py-1 bg-[white] border-[1px] border-[#dae0e5] rounded font-bold"
>
Bold
</button>
<div className="relative">
<button
onClick={() => setShowTextColor(!showTextColor)}
className="px-2 py-1 bg-[white] border-[1px] border-[#dae0e5] rounded"
>
글자색
</button>
{showTextColor && (
<input
type="color"
autoFocus
onBlur={() => setShowTextColor(false)}
onChange={(e) => {
editor?.chain().focus().setColor(e.target.value).run();
setShowTextColor(false);
}}
className="absolute top-full left-0 mt-1"
/>
)}
</div>
<div className="relative">
<button
onClick={() => setShowHighlight(!showHighlight)}
className="px-2 py-1 bg-[white] border-[1px] border-[#dae0e5] rounded"
>
배경색
</button>
{showHighlight && (
<input
type="color"
autoFocus
onBlur={() => setShowHighlight(false)}
onChange={(e) => {
editor
?.chain()
.focus()
.setHighlight({ color: e.target.value })
.run();
setShowHighlight(false);
}}
className="absolute top-full left-0 mt-1"
/>
)}
</div>
<button
onClick={alignLeft}
className="px-2 py-1 bg-[white] border-[1px] border-[#dae0e5] rounded"
>
좌측 정렬
</button>
<button
onClick={alignCenter}
className="px-2 py-1 bg-[white] border-[1px] border-[#dae0e5] rounded"
>
가운데 정렬
</button>
<button
onClick={alignRight}
className="px-2 py-1 bg-[white] border-[1px] border-[#dae0e5] rounded"
>
우측 정렬
</button>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={insertTable}
className="px-2 py-1 bg-[white] border-[1px] border-[#dae0e5] rounded"
>
표 삽입
</button>
<button
onClick={mergeCells}
className="px-2 py-1 bg-[white] border-[1px] border-[#dae0e5] rounded"
>
셀 병합
</button>
<button
onClick={addColumnBefore}
className="px-2 py-1 bg-[white] border-[1px] border-[#dae0e5] rounded"
>
열 추가 (앞)
</button>
<button
onClick={addColumnAfter}
className="px-2 py-1 bg-[white] border-[1px] border-[#dae0e5] rounded"
>
열 추가 (뒤)
</button>
<button
onClick={addRowBefore}
className="px-2 py-1 bg-[white] border-[1px] border-[#dae0e5] rounded"
>
행 추가 (위)
</button>
<button
onClick={addRowAfter}
className="px-2 py-1 bg-[white] border-[1px] border-[#dae0e5] rounded"
>
행 추가 (아래)
</button>
<button
onClick={deleteRow}
className="px-2 py-1 bg-red-200 rounded"
>
행 삭제
</button>
<button
onClick={deleteColumn}
className="px-2 py-1 bg-red-200 rounded"
>
열 삭제
</button>
<div className="relative">
<button
onClick={() => setShowCellColor(!showCellColor)}
className="px-2 py-1 bg-[white] border-[1px] border-[#dae0e5] rounded"
>
셀 배경색
</button>
{showCellColor && (
<input
type="color"
autoFocus
onBlur={() => setShowCellColor(false)}
onChange={(e) => {
editor?.commands.setCellAttribute(
"backgroundColor",
e.target.value
);
setShowCellColor(false);
}}
className="absolute top-full left-0 mt-1 z-10"
/>
)}
</div>
</div>
</div>
<div className="border rounded p-2">
<EditorContent editor={editor} style={{overflow: "auto"}} />
</div>
</>
) : (
<>
<button
onClick={toggleMode}
className="px-2 py-1 bg-red-200 text-white rounded cursor-pointer"
>
WYSIWYG 편집
</button>
<textarea
className="w-full h-[500px] p-4 border font-mono text-sm bg-gray-50"
value={htmlContent}
onChange={(e) => setHtmlContent(e.target.value)}
/>
</>
)}
</div>
);
}
'React' 카테고리의 다른 글
[TypeScript] 제네릭을 활용한 setDto 최적화 - any 대신 T를 쓰는 이유 (0) | 2025.03.13 |
---|---|
[Next.js] sessionStorage is not defined 오류 (0) | 2025.02.21 |
[Next.js vs React] 요청 흐름 차이 (0) | 2025.02.17 |
[React Query] queryKey란? (0) | 2025.02.10 |
[Middleware] Next.js를 활용한 인증 로직 구현하기 (0) | 2025.01.30 |