React

[Next.js] 무료 에디터 Tiptap커스텀하기

연신내고독한늑대 2025. 3. 26. 16:35

에디터를 선택하기 위해 다양한 옵션을 검토해보았습니다.
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>
  );
}