[RTE] React Tiptap 에디터 적용하기 2

2026. 1. 18. 20:03·기능/기타
반응형

지난 글에서는 tiptap 라이브러리를 사용해서 간단한 에디터, 볼드체, 이탤릭체 등 폰트 변경과 사이즈 그리고 텍스트 정렬 구현을 진행했습니다. 이번 글에서는 tiptap에 소개된 다양한 라이브러리를 소개하고 조금 더 고오급 에디터를 만들어보도록 합시다. 지난 글과 겹치는 부분이 다수 존재할 수 있습니다. 

 

여러 기능을 한번에 소개하다보니 글 가독성이 많이 떨어집니다. 또한 기능별로 폴더를 나눠 작업을 진행했기 때문에 구현이 필요하신 분들은 깃허브에서 클론 후 사용하시는걸 추천드립니다. 

 

GitHub - gnaak/tiptap: tiptap 라이브러리를 사용한 RTE(Rich Text Editor) 컴포넌트

tiptap 라이브러리를 사용한 RTE(Rich Text Editor) 컴포넌트. Contribute to gnaak/tiptap development by creating an account on GitHub.

github.com

 

UI Components

Tiptap은 기능 뿐만 아니라 UI Component도 지원을 합니다. 오픈 소스가 달려있지 않은 항목들은 유료 컴포넌트로 기능 자체는 직접 구현하거나, 다른 라이브러리로 충분히 대체할 수 있습니다. 오픈 소스 컴포넌트들은 다양한 버튼과 팝오버를 제공해줍니다. 하지만 기능을 제공해주는게 아니라 UI만 제공해주고 저는 기존에 커스텀 컴포넌트들과 lucide-react에서 아이콘들을 사용하고 있기 때문에 컴포넌트는 직접 구현하는 방식으로 진행해보도록 하겠습니다. 

 

Components | Tiptap UI Component

Pick from a list of UI components and primitives to integrate into your Tiptap editor. More in the docs.

tiptap.dev

 

기능 리스트

아래 기능들은 EidtorArea.tsx(에디터 페이지)와 EditorToolBar.tsx(툴바)로 나눠서 작업했습니다. 공식 문서에는 editor.commands().setSomeChange()로 나와있으나, 드래그하는 영역이 달라지는 오류가 종종 발생해서 editor.chain().focus().setSomeChange().run()으로 통일해서 진행했습니다. 

 

기본 코드 예시:

EditorArea.tsx

import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { EditorToolBar } from "./toolbar";

export const EditorArea = () => {
  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        heading: false,
      }),
    ],
  });

  return (
    <div className="w-screen h-screen flex flex-col">
      <EditorToolBar editor={editor} />
      <EditorContent editor={editor} />
    </div>
  );
};

EditorToolBar.tsx

import { Editor } from "@tiptap/core";

interface EditorToolBarProps {
  editor: Editor | null;
}

export const EditorToolBar = ({ editor }: EditorToolBarProps) => {
  if (!editor) return null;
  return (
    <div className="border border-b bg-gray-300/30 p-2">
      <span> 툴바영역 </span>
    </div>
  );
};

이전 글에서 언급한 바와 같이 useEditor 훅을 사용해서 기본 에디터 extensions를 설정해주고, EditorToolBar로 툴바 영역, EditorContent를 사용해서 에디터 영역을 호출해줍니다. 


에디터와 툴바 상태 동기화

기존 editor 내부에 함수를 추가하여 에디터 상태와 툴바 상태 동기화를 진행해줍시다. 에디터 생성 시, 기본 설정으로 Inter 폰트, 16px, 검정색 폰트, 하얀색 배경을 설정해줍시다. 

const editor = useEditor({
// ...
	onTransaction: ({ editor, transaction }) => {
      updateToolBar(editor);
    },

    onFocus: ({ editor }) => {
      updateToolBar(editor);
    },

    onCreate: ({ editor }) => {
      updateToolBar(editor);
      setUpdateTrigger(1);
    },
});

// ... 

  const updateToolBar = (editor) => {
    const attrs = editor.getAttributes("textStyle");
    setFontFamily(attrs.fontFamily || "Inter");
    setFontSize(attrs.fontSize ? attrs.fontSize : "16px");
    setUpdateTrigger((prev) => prev + 1);
    setFontColor(attrs.color || "#000000");
    setBackgroundColor(attrs.backgroundColor || "#FFFFFF");
  };
  
//...

단축키

볼드(Ctrl + B), 이탤릭(Ctrl + I)등 기본적으로 지원하는 단축키가 존재하지만, 존재하지 않는 단축키들을 매핑해주기 위해서 에디터 설정에서 직접 함수를 작성해줍니다.

 

취소선 예시:

editorProps: {
  attributes: {
    class: "w-full h-[300px] focus:outline-none p-5",
    style: "font-size: 14px;",
  },
  handleKeyDown: (view, event) => {
    if (event.ctrlKey || event.metaKey) {
      if (event.shiftKey) {
        if (
          event.key == "x" ||
          event.key == "X" ||
          event.key == "s" ||
          event.key == "S"
        ) {
          editor.chain().focus().toggleStrike().run();
          event.preventDefault();
          return true;
        }
      }
    }
    return false;
  },
},

폰트

폰트와 관련된 라이브러리를 사용하기 위해서 @tiptap/extension-text-style 설치 및 TextStyle extension을 추가해줍시다. 

npm install @tiptap/extension-text-style

폰트 변경

기존에 설치된 @tiptap/extension-text-style에서 FontFamily import 및 extension 추가해줍시다. 추후에 서술되는 모든 extension 추가는 모두 아래와 같이 useEditor 내부 extension 추가와 동일합니다.

const editor = useEditor({
  extensions: [
    StarterKit.configure({
      heading: false,
    }),
    TextStyle, // 폰트 관련 공통 extension
    FontFamily, // 폰트 변경을 위한 extension
  ],
});

에디터 영역

fontFamily를 사용해서 폰트 상태 관리 및 props로 전달합니다. 

import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { EditorToolBar } from "./toolbar";
import { TextStyle, FontSize, FontFamily } from "@tiptap/extension-text-style";
import { useEffect, useState } from "react";

export const EditorArea = () => {
  // FONT RELATED
  const [fontFamily, setFontFamily] = useState<string>("Inter");

  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        heading: false,
      }),
      TextStyle, // 폰트 관련 공통 extension
      FontFamily, // 폰트 변경을 위한 extension
    ],
    content:
      "테스트용 글입니다. 테스트용 글 입니다.",
    editorProps: {
      attributes: {
        class: "w-full h-[300px] focus:outline-none p-5",
        style: "font-size: 14px;",
      },
    },
  });

  if (!editor) return null;

  return (
    <div className="w-screen h-screen flex flex-col">
      <EditorToolBar
        editor={editor}
        fontFamily={fontFamily}
        setFontFamily={setFontFamily}
      />
      <EditorContent editor={editor} />
    </div>
  );
};

툴바 영역

폰트 selectBox는 기존에 존재하던 컴포넌트를 사용해서 제작했습니다.

const FONTS = [
  { value: "Inter", label: "Inter" },
  { value: "Apple", label: "Apple" },
  { value: "Roboto", label: "Roboto" },
  { value: "Gmarket", label: "Gmarket" },
];

interface EditorToolBarProps {
  editor: Editor | null;
  fontFamily: string;
  setFontFamily: (fontSize: string) => void;
}

export const EditorToolBar = ({ editor, 
  fontFamily, 
  setFontFamily 
}: EditorToolBarProps) => {
  return (
    <div className="border border-b bg-gray-300/30 p-2">
      <span> 툴바영역 </span>
      <div>
        <SelectBox
          value={fontFamily}
          onChange={(fontFamily: string) => {
            setFontFamily(fontFamily);
            editor.chain().focus().setFontFamily(fontFamily).run();
          }}
          options={FONTS}
          isFont={true}
        />
      </div>
    </div>
  );
};

폰트 사이즈 변경

폰트 사이즈 변경은 기존에 설치된 @tiptap/extension-text-style 에서 FontSize import 및 extension을 추가해줍니다. 에디터 영역에서는 fontSize를 사용해서 폰트 크기 상태 관리 및 props를 전달해주고, 툴바 영역에서는 마찬가지로 selectBox.tsx를 사용했습니다. px을 그대로 사용하는게 아닌, pt값을 미리 설정해둬서 사용자가 익숙한 폰트 포인트로 보일 수 있게 바꿔줬습니다.

export const FONTSIZES = [
  { value: "11px", label: "8pt" }, // 10.66 → 11
  { value: "12px", label: "9pt" }, // 12
  { value: "13px", label: "10pt" }, // 13.33 → 13
  { value: "15px", label: "11pt" }, // 14.66 → 15
  { value: "16px", label: "12pt" }, // 16
  { value: "19px", label: "14pt" }, // 18.66 → 19
  { value: "21px", label: "16pt" }, // 21.33 → 21
  { value: "24px", label: "18pt" }, // 24
  { value: "27px", label: "20pt" }, // 26.66 → 27
  { value: "29px", label: "22pt" }, // 29.33 → 29
  { value: "32px", label: "24pt" }, // 32
  { value: "35px", label: "26pt" }, // 34.66 → 35
  { value: "37px", label: "28pt" }, // 37.33 → 37
  { value: "43px", label: "32pt" }, // 42.66 → 43
  { value: "64px", label: "48pt" }, // 64
  { value: "96px", label: "72pt" }, // 96
];

// ...

<SelectBox
  className="w-[150px]"
  value={fontSize}
  onChange={(fontSize: string) => {
    setFontSize(fontSize);
    editor.chain().focus().setFontSize(`${fontSize}`).run();
  }}
  options={FONTSIZES}
/>

폰트 색상 변경 및 하이라이트

폰트 색상 및 하이라이트 변경은 기존에 설치된 @tiptap/extension-text-style 에서 Color, Background import 및 extension을 추가해줍니다. 에디터 영역에서는 fontColor, backgroundColor를 사용해서 폰트 색상과 하이라이트 색상 상태 관리 및 props를 전달했습니다. 폰트 색상과 하이라이트 색상의 경우 노션UI를 차용했습니다. tailwind에서는 색상을 동적으로 바꿀 수 없어 인라인 스타일을 적용했습니다. 

const COLORS = [
  { value: "#000000", label: "검은색", outline: "#E6E5E3" },
  { value: "#A7A6A7", label: "회색", outline: "#E6E5E3" },
  { value: "#B1A299", label: "갈색", outline: "#E0CDC0" },
  { value: "#C39061", label: "주황색", outline: "#EACCB2" },
  { value: "#D9B682", label: "노란색", outline: "#E8D497" },
  { value: "#6E9284", label: "초록색", outline: "#BED9C9" },
  { value: "#5081A7", label: "파란색", outline: "#B6D6F5" },
  { value: "#937B9C", label: "보라색", outline: "#DBC8E8" },
  { value: "#A86587", label: "분홍색", outline: "#EAC4D5" },
  { value: "#CF5148", label: "빨간색", outline: "#F0C5BE" },
];

const BACKGROUNDCOLORS = [
  { value: "#FFFFFF", label: "검은색", outline: "#E6E5E3" },
  { value: "#EFEFED", label: "회색", outline: "#E6E5E3" },
  { value: "#F5EDE9", label: "갈색", outline: "#E0CDC0" },
  { value: "#FBEBDF", label: "주황색", outline: "#EACCB2" },
  { value: "#FAF4DD", label: "노란색", outline: "#E8D497" },
  { value: "#E8F1EC", label: "초록색", outline: "#BED9C9" },
  { value: "#E6F2FC", label: "파란색", outline: "#B6D6F5" },
  { value: "#F1EAF8", label: "보라색", outline: "#DBC8E8" },
  { value: "#FAE9F1", label: "분홍색", outline: "#EAC4D5" },
  { value: "#FDE9E7", label: "빨간색", outline: "#DDC4C5" },
];

// ...
	<div ref={colorButtonRef} className="relative">
	  <button
	    onClick={() => {
	      setIsColorModal((prev) => !prev);
	    }}
	    className="flex flex-row items-center justify-center gap-2 relative"
	  >
	    <div
	      className="flex items-center justify-center h-10 w-10 bg-white rounded-md border border-gray-400/30"
	      style={{ color: fontColor, backgroundColor: backgroundColor }}
	    >
	      <span>A</span>
	    </div>
	    <ChevronDown className="text-gray-400/80 w-5 h-5" />
	  </button>
	  {isColorModal && (
	    <div className="flex flex-col gap-3 absolute top-full w-[180px] right-0 bg-white rounded-md border border-gray-400/30 mt-[6px] z-10 p-3">
	      {/* FONT COLOR */}
	      <div className="flex flex-col gap-2">
	        <span className="text-sm font-medium">텍스트 색상</span>
	        <div className="grid grid-cols-5 gap-2">
	          {COLORS.map((color) => (
	            <button
	              key={color.value}
	              className={`flex items-center justify-center w-6 h-6 outline border rounded-md shrink-0 text-sm
	                        ${
	                          color.value == fontColor
	                            ? "outline-[2px]"
	                            : "outline-[1px]"
	                        }
	                        `}
	              onClick={() => {
	                setFontColor(color.value);
	                editor.chain().focus().setColor(color.value).run();
	                setIsColorModal(false);
	              }}
	              style={{
	                color: color.value,
	                outlineColor: color.outline,
	              }}
	            >
	              <span>A</span>
	            </button>
	          ))}
	        </div>
	      </div>
	      {/* BACKGROUND COLOR */}
	      <div className="flex flex-col gap-2">
	        <span className="text-sm font-medium">텍스트 색상</span>
	        <div className="grid grid-cols-5 gap-2">
	          {BACKGROUNDCOLORS.map((color) => (
	            <button
	              key={color.value}
	              className={`flex items-center justify-center w-6 h-6 outline border rounded-md shrink-0 text-sm
	                        ${
	                          color.value == backgroundColor
	                            ? "outline-[2px]"
	                            : "outline-[1px]"
	                        }
	                        `}
	              onClick={() => {
	                setBackgroundColor(color.value);
	                editor.chain().focus().setBackgroundColor(color.value).run();
	                setIsColorModal(false);
	              }}
	              style={{
	                color: color.value,
	                outlineColor: color.outline,
	                backgroundColor: color.value,
	              }}
	            >
	              <span>A</span>
	            </button>
	          ))}
	        </div>
	      </div>
	    </div>
	  )}
	</div>;
// ...

++ 최근 사용 항목

폰트 및 하이라이트 색상을 변경하면서 최근 사용 항목 상태 관리 및 UI에 같이 렌더링해줬습니다.

export interface RecentlyUsedProps {
  type: "color" | "background";
  color: string;
  outline: string;
}

const [recentlyUsed, setRecentlyUsed] = useState<RecentlyUsedProps[]>([]);

// 폰트 색상 변경 시 최근 사용 항목 추가 
onClick={() => {
  setFontColor(color.value);
  setRecentlyUsed((prev) => {
    const newItem: RecentlyUsedProps = {
      type: "color", // 배경 색상 변경이면 "background"
      color: color.value,
      outline: color.outline,
    };
    const filtered = prev.filter(
      (item) =>
        !(
          item.type === newItem.type &&
          item.color === newItem.color &&
          item.outline === newItem.outline
        ),
    );
    return [newItem, ...filtered].slice(0, 5);
  });
  editor.chain().focus().setColor(color.value).run();
  setIsColorModal(false);
}}
// ...

최신 사용 항목들에 대해서는 ctrl + shift + h 단축키를 매핑해줬습니다. 커서 위치 기준 폰트 색상, 혹은 배경 색상과 다른 경우에 색상 적용 혹은 제거를 합니다.

handleKeyDown: (view, event) => {
  if (event.ctrlKey || event.metaKey) {
    if (event.shiftKey) {
      if (event.key == "h" || event.key == "H") {
        if (recentlyUsed) {
          const lastUsed = recentlyUsed[0];
          if (lastUsed.type == "color") {
            if (fontColor != lastUsed.color) {
              editor.chain().focus().setColor(lastUsed.color).run();
            } else {
              editor.chain().focus().unsetColor().run();
            }
          } else {
            if (backgroundColor != lastUsed.color) {
              editor.chain().focus().setBackgroundColor(lastUsed.color).run();
            } else {
              editor.chain().focus().unsetBackgroundColor().run();
            }
          }
        }
      }
    }
  }
}

폰트 변경(굵기, 이탤릭, 밑줄, 취소선)

굵기, 이탤릭, 밑줄, 취소선은 StarterKit에 내장되어 있습니다. 

 

굵기(Bold): editor.chain().focus().toggleBold().run()

이탤릭(Italic): editor.chain().focus().toggleItalic().run()

밑줄(Underline): editor.chain().focus().toggleUnderline().run()

취소선(Strike): editor.chain().focus().toggleStrike().run()

 

editor가 bold일 때, 버튼 안의 색상을 파란색으로 바꿔서 UX를 향상해줍시다.

<button
  onClick={() => {
    editor.chain().focus().toggleBold().run();
  }}
  className={`flex flex-row items-center justify-center h-10 w-10 bg-white rounded-md border border-gray-400/30
    ${editor?.isActive("bold") ? "text-blue-600" : ""}
    `}
>
  <span className="font-bold">B</span>
</button>

이미지

이미지는 @tiptap/extension-image 라이브러리 설치 및 Image extension을 추가해줍시다.

npm install @tiptap/extension-image

이미지 삽입

툴바 영역에서 버튼 및 input을 hidden으로 생성하고 editor.chain().focus().setImage().run() 를 호출해줍시다.

<input
  ref={imageInputRef}
  type="file"
  accept="image/*"
  hidden
  onChange={(e) => {
    const file = e.target.files?.[0];
    if (!file || !editor) return;

    const reader = new FileReader();
    reader.onload = () => {
      editor
        .chain()
        .focus()
        .setImage({ src: reader.result as string })
        .run();
    };

    reader.readAsDataURL(file);

    // 같은 파일 다시 선택 가능하게 초기화
    e.target.value = "";
  }}
/>

에디터 영역에서는 이미지 사이즈 조절과, 위치 정렬을 위해서 커스텀 이미지 extension을 생성했습니다.

import ImageNodeView from "./imageNodeView";

const CustomImage = Image.extend({
  inline: false,
  group: "block",
  draggable: true,
  selectable: true,

  addAttributes() {
    return {
      ...this.parent?.(),

      width: {
        default: null,
        renderHTML: (attributes) => {
          if (!attributes.width) return {};
          return {
            style: `width: ${attributes.width};`,
          };
        },
      },
      align: {
        default: "left",
        renderHTML: (attrs) => ({
          "data-align": attrs.align,
        }),
      },
    };
  },
  addNodeView() {
    return ReactNodeViewRenderer(ImageNodeView);
  },
});
import { NodeViewWrapper, useReactNodeView } from "@tiptap/react";
import type { DragEvent as ReactDragEvent } from "react";

import { useRef } from "react";

const ImageNodeView = ({ node, updateAttributes }) => {
  const imgRef = useRef<HTMLImageElement>(null);
  const { onDragStart } = useReactNodeView();

  const alignClass = {
    left: "justify-start",
    center: "justify-center",
    right: "justify-end",
  }[node.attrs.align || "left"];

  const onResizeMouseDown = (
    e: React.MouseEvent,
    direction: "left" | "right"
  ) => {
    e.preventDefault();
    e.stopPropagation();

    const img = imgRef.current;
    if (!img) return;

    const rect = img.getBoundingClientRect();
    const startX = e.clientX;
    const startWidth = rect.width;

    const onMouseMove = (e: MouseEvent) => {
      const diff = e.clientX - startX;

      const newWidth =
        direction === "right" ? startWidth + diff : startWidth - diff;

      updateAttributes({
        width: `${Math.max(120, newWidth)}px`,
      });
    };

    const onMouseUp = () => {
      document.removeEventListener("mousemove", onMouseMove);
      document.removeEventListener("mouseup", onMouseUp);
    };

    document.addEventListener("mousemove", onMouseMove);
    document.addEventListener("mouseup", onMouseUp);
  };

  return (
    <NodeViewWrapper
      className={`relative flex ${alignClass} group`}
      contentEditable={false}
      data-drag-handle
      draggable
      onDragStart={(e: ReactDragEvent<HTMLDivElement>) => {
        onDragStart(e.nativeEvent);
      }}
    >
      <div className="inline-block relative">
        <img
          ref={imgRef}
          src={node.attrs.src}
          draggable={false}
          style={{
            width: node.attrs.width ?? "auto",
            maxWidth: "100%",
          }}
        />

        <div className="absolute w-full h-1/2 top-1/2 -translate-y-1/2 flex justify-between items-center p-3 opacity-0 group-hover:opacity-60 transition-opacity pointer-events-none">
          {/* 왼쪽 핸들 */}
          <div
            onMouseDown={(e) => onResizeMouseDown(e, "left")}
            className="pointer-events-auto absolute left-0 top-1/2 -translate-y-1/2
               w-[6px] h-12 cursor-ew-resize bg-gray-700 rounded-full"
          />

          {/* 오른쪽 핸들 */}
          <div
            onMouseDown={(e) => onResizeMouseDown(e, "right")}
            className="pointer-events-auto absolute right-0 top-1/2 -translate-y-1/2
               w-[6px] h-12 cursor-ew-resize bg-gray-700 rounded-full"
          />
        </div>
        <div className="absolute w-full h-1/3 top-0 right-0 flex justify-end p-1 opacity-0 group-hover:opacity-100 transition-opacity ">
          <div className="flex flex-row gap-1">
            <button
              className="hover:bg-gray-300 border bg-gray-100 border-gray-300 h-6 w-6 flex items-center justify-center rounded-md"
              onClick={() => updateAttributes({ align: "left" })}
            >
              <svg
                aria-hidden="true"
                role="graphics-symbol"
                viewBox="0 0 16 16"
                className="w-4 h-4 fill-current flex-shrink-0"
              >
                <path d="M2.4 2.175a.625.625 0 1 0 0 1.25h11.2a.625.625 0 1 0 0-1.25zm1.2 2A1.825 1.825 0 0 0 1.775 6v4c0 1.008.817 1.825 1.825 1.825H8A1.825 1.825 0 0 0 9.825 10V6A1.825 1.825 0 0 0 8 4.175zM3.025 6c0-.318.258-.575.575-.575H8c.318 0 .575.257.575.575v4a.575.575 0 0 1-.575.575H3.6A.575.575 0 0 1 3.025 10zM2.4 12.575a.625.625 0 1 0 0 1.25h11.2a.625.625 0 1 0 0-1.25z"></path>
              </svg>
            </button>
            <button
              className="hover:bg-gray-300 border bg-gray-100 border-gray-300 h-6 w-6 flex items-center justify-center rounded-md"
              onClick={() => updateAttributes({ align: "center" })}
            >
              <svg
                aria-hidden="true"
                role="graphics-symbol"
                viewBox="0 0 16 16"
                className="w-4 h-4 fill-current flex-shrink-0"
              >
                <path d="M2.4 2.175a.625.625 0 1 0 0 1.25h11.2a.625.625 0 1 0 0-1.25zm3.4 2h4.4c1.008 0 1.825.817 1.825 1.825v4a1.825 1.825 0 0 1-1.825 1.825H5.8A1.825 1.825 0 0 1 3.975 10V6c0-1.008.817-1.825 1.825-1.825M5.225 6v4c0 .318.258.575.575.575h4.4a.575.575 0 0 0 .575-.575V6a.575.575 0 0 0-.575-.575H5.8A.575.575 0 0 0 5.225 6M2.4 12.575a.625.625 0 1 0 0 1.25h11.2a.625.625 0 1 0 0-1.25z"></path>
              </svg>{" "}
            </button>{" "}
            <button
              className="hover:bg-gray-300 border bg-gray-100 border-gray-300 h-6 w-6 flex items-center justify-center rounded-md"
              onClick={() => updateAttributes({ align: "right" })}
            >
              <svg
                aria-hidden="true"
                role="graphics-symbol"
                viewBox="0 0 16 16"
                className="w-4 h-4 fill-current flex-shrink-0"
              >
                <path d="M2.4 2.175a.625.625 0 1 0 0 1.25h11.2a.625.625 0 1 0 0-1.25zm5.6 2A1.825 1.825 0 0 0 6.175 6v4c0 1.008.817 1.825 1.825 1.825h4.4A1.825 1.825 0 0 0 14.225 10V6A1.825 1.825 0 0 0 12.4 4.175zM7.425 6c0-.318.257-.575.575-.575h4.4c.318 0 .575.257.575.575v4a.575.575 0 0 1-.575.575H8A.575.575 0 0 1 7.425 10zM2.4 12.575a.625.625 0 1 0 0 1.25h11.2a.625.625 0 1 0 0-1.25z" />
              </svg>{" "}
            </button>
          </div>
        </div>
      </div>
    </NodeViewWrapper>
  );
};

export default ImageNodeView;

기본 Image extension에서는 드래그가 정상적으로 작동하는데, 커스텀 익스텐션 사용 후 드래그가 안되면, css를 확인할 필요가 있습니다. 전역으로 -webkit-user-drag:none, user-select:none이 걸려있는 경우 드래그가 안먹히기 때문에 ProseMirror 내부 전체 드래그를 가능하게 해줍시다.

* {
  font-family: "Inter", sans-serif;
  -webkit-user-drag: none;
  user-select: none;
  touch-action: manipulation;
}

.ProseMirror,
.ProseMirror * {
  -webkit-user-drag: auto;
  user-select: auto;
}

이미지 드래그 앤 드롭

이벤트로 처리할 경우 사용자 PC 내 파일, 혹은 브라우저에서 바로 드래그해서 가져오는 경우도 이미지를 복사해서 에디터에서 사용할 수 있습니다. 

handleDrop(view, event: DragEvent) {
  const dt = event.dataTransfer.effectAllowed;
  if (!dt) return false;
  if (dt == "copyMove") return;

  event.preventDefault();

  const files = event.dataTransfer?.files;
  if (!files || files.length === 0) return false;

  const imageFiles = Array.from(files).filter((file) =>
    file.type.startsWith("image/")
  );

  if (imageFiles.length === 0) return false;

  event.preventDefault();

  const { schema } = view.state;
  const coordinates = view.posAtCoords({
    left: event.clientX,
    top: event.clientY,
  });

  if (!coordinates) return false;

  const tr = view.state.tr;

  imageFiles.forEach((file) => {
    const url = URL.createObjectURL(file);
    const node = schema.nodes.image.create({ src: url });
    tr.insert(coordinates.pos, node);
  });

  view.dispatch(tr);
  return true;
}

이미지 붙여넣기

붙여넣기도 마찬가지로 이벤트로 처리해줍시다.

handlePaste(view, event: ClipboardEvent) {
  const items = event.clipboardData?.items;
  if (!items) return false;

  for (const item of items) {
    if (item.type.startsWith("image/")) {
      const file = item.getAsFile();
      if (!file) continue;

      const url = URL.createObjectURL(file);

      view.dispatch(
        view.state.tr.replaceSelectionWith(
          view.state.schema.nodes.image.create({
            src: url,
          })
        )
      );

      return true;
    }
  }

  return false;
},

리스트

번호 리스트 & 불릿 리스트

불릿 리스트(<ul/>)와 번호 리스트(<ol/>)의 경우 기본적으로 제공됩니다. 1 +␣를 통해 번호 리스트 생성이 가능하며, - or * +␣ 를 통해 불릿 리스트 생성이 가능합니다.

 

기능을 커스터마이징 하기 위해서는 StarterKit에서 리스트 관련 부분을 disabled 처리 후, 커스터마이징을 진행하면 됩니다.

StarterKit.configure({
  heading: false,
  orderedList: false,
  bulletList: false,
  listItem: false,
}),

이 경우 추가로 리스트 관련 패키지 설치 후 extension에 OrderedList, BulletList, ListItem 추가해줍시다.

npm install @tiptap/extension-list

리스트가 보이지 않은 경우 css 문제일 가능성이 크기 때문에 css에 추가해줍시다.

ol,
ul {
  list-style: disc;
  padding-left: 1.5rem;
}

ol {
  list-style: decimal;
}

태스크 리스트

태스크 리스트를 사용하기 위해서는 라이브러리 설치 및 TaskList extension을 추가해야 합니다. 또한 TaskItem도 같이 사용해야 하기 때문에 라이브러리 설치 후 extension에 추가해줍시다. 

npm install @tiptap/extension-list
npm install @tiptap/extension-task-item

태스크 리스트 HTML 구조

<ul data-type="taskList">
  <li data-checked="false">
    <label contenteditable="false">
      <input aria-label="Task item checkbox for empty task item" type="checkbox">
	  <span></span>
	</label>
    <div>
      <p style="text-align: left;">
        <br class="ProseMirror-trailingBreak">
      </p>
    </div>
  </li>
</ul>

텍스트

텍스트 정렬

텍스트 정렬은 라이브러리 설치 및 TextAlign extension을 설치해줍시다. 기본 커맨드는 ctrl + shift + L or E or R 입니다.

TextAlign.configure({
  types: ["heading", "paragraph"],
  defaultAlignment: "left",
}),

정렬 시 사용되는 버튼의 아이콘은 lucide-react에서 아이콘을 사용했습니다.

<button
  onClick={() => {
    editor.chain().focus().setTextAlign("left").run();
  }}
  className={`hover:text-blue-500 font bold shrink-0 flex flex-row items-center justify-center h-10 w-10 bg-white rounded-md border border-gray-400/30
    ${
      editor?.isActive({ textAlign: "left" })
        ? "text-blue-600 font-bold"
        : ""
    }
    `}
>
  <AlignLeft />
</button>
<button
  onClick={() => {
    editor.chain().focus().setTextAlign("center").run();
  }}
  className={`hover:text-blue-500 font bold shrink-0 flex flex-row items-center justify-center h-10 w-10 bg-white rounded-md border border-gray-400/30
    ${
      editor?.isActive({ textAlign: "center" })
        ? "text-blue-600 font-bold"
        : ""
    }
    `}
>
  <AlignCenter />
</button>
<button
  onClick={() => {
    editor.chain().focus().setTextAlign("left").run();
  }}
  className={`hover:text-blue-500 font bold shrink-0 flex flex-row items-center justify-center h-10 w-10 bg-white rounded-md border border-gray-400/30
    ${
      editor?.isActive({ textAlign: "right" })
        ? "text-blue-600 font-bold"
        : ""
    }
    `}
>
  <AlignRight />
</button>

텍스트 상하 이동 

Alt + ↑ or ↓는 키보드 이벤트를 통해서 구현했습니다. 아래 코드는 문장 기준이 아닌 블럭(문단) 기준으로 움직입니다.
import { NodeSelection, TextSelection } from "prosemirror-state";

export const moveBlock = (editor: Editor, direction: -1 | 1): boolean => {
  const view = editor.view;
  const state = view.state;
  const { doc, selection } = state;

  let $from;
  if (selection instanceof TextSelection) {
    $from = selection.$from;
  } else if (selection instanceof NodeSelection) {
    $from = doc.resolve(selection.from);
  } else {
    return false;
  }

  let depth = $from.depth;
  while (depth > 0 && !$from.node(depth).isBlock) depth--;
  if (depth === 0) return false;

  const parent = $from.node(depth - 1);
  const index = $from.index(depth - 1);

  const curFrom = $from.before(depth);
  const curNode = $from.node(depth);
  const curTo = curFrom + curNode.nodeSize;

  const cursorOffset = Math.max(1, selection.from - curFrom);

  let newBlockFrom: number;

  if (direction === -1) {
    if (index === 0) return false;

    const prevNode = parent.child(index - 1);
    const prevFrom = curFrom - prevNode.nodeSize;
    const prevTo = curFrom;

    const curSlice = doc.slice(curFrom, curTo);
    const prevSlice = doc.slice(prevFrom, prevTo);

    let tr = state.tr;
    tr = tr.delete(curFrom, curTo);
    tr = tr.delete(prevFrom, prevTo);
    tr = tr.insert(prevFrom, curSlice.content);
    tr = tr.insert(prevFrom + curSlice.size, prevSlice.content);

    newBlockFrom = prevFrom;

    const maxOffset = tr.doc.nodeAt(newBlockFrom)!.nodeSize - 2;
    const safeOffset = Math.min(cursorOffset, maxOffset);

    tr = tr.setSelection(
      TextSelection.create(tr.doc, newBlockFrom + safeOffset)
    );

    view.dispatch(tr);
    return true;
  }

  if (direction === 1) {
    if (index >= parent.childCount - 1) return false;

    const nextNode = parent.child(index + 1);
    const nextFrom = curTo;
    const nextTo = nextFrom + nextNode.nodeSize;

    const curSlice = doc.slice(curFrom, curTo);
    const nextSlice = doc.slice(nextFrom, nextTo);

    let tr = state.tr;
    tr = tr.delete(nextFrom, nextTo);
    tr = tr.delete(curFrom, curTo);
    tr = tr.insert(curFrom, nextSlice.content);
    tr = tr.insert(curFrom + nextSlice.size, curSlice.content);

    newBlockFrom = curFrom + nextSlice.size;

    const maxOffset = tr.doc.nodeAt(newBlockFrom)!.nodeSize - 2;
    const safeOffset = Math.min(cursorOffset, maxOffset);

    tr = tr.setSelection(
      TextSelection.create(tr.doc, newBlockFrom + safeOffset)
    );

    view.dispatch(tr);
    return true;
  }

  return false;
};
if (event.altKey && !event.ctrlKey && !event.metaKey) {
  if (event.key === "ArrowUp") {
    event.preventDefault();
    return moveBlock(editor, -1);
  }

  if (event.key === "ArrowDown") {
    event.preventDefault();
    return moveBlock(editor, 1);
  }
}

기타

탭 

탭은 이벤트를 사용하여 공백 4칸 삽입, shift + Tab 시 문단 기준 최대 4칸 공백 삭제됩니다.

if (event.key === "Tab") {
  event.preventDefault();

  editor
    .chain()
    .focus()
    .insertContent("    ")
    .run();

  return true;
}
if (event.key === "Tab" && event.shiftKey) {
  event.preventDefault();

  const { state } = editor;
  const { $from } = state.selection;

  const lineStart = $from.start();
  const lineEnd = $from.end();

  // 현재 줄 텍스트
  const lineText = state.doc.textBetween(
    lineStart,
    lineEnd,
    "\n",
    "\n"
  );

  // 줄 앞 공백/탭을 최대 4칸까지만
  const match = lineText.match(/^[ \t]{1,4}/);

  if (match) {
    editor.commands.deleteRange({
      from: lineStart,
      to: lineStart + match[0].length,
    });
  }

  return true;
}

코드

코드 블럭은 라이브러리와 lowlight, highlight.js 설치를 통해 진행했습니다. lowlight를 하이라이팅 엔진으로 사용하고, highlight.js의 CSS 테마를 적용해 코드에 색상을 추가하는 커스텀 extension을 생성했습니다.

npm install @tiptap/extension-code-block-lowlight
npm install highlight.js/styles/github.css
npm install lowlight
import "highlight.js/styles/github.css";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { all, createLowlight } from "lowlight";
const lowlight = createLowlight(all);
import CodeNodeView from "./node/codeNodeView";

export const CustomCodeBlock = CodeBlockLowlight.extend({
  inline: false,
  group: "block",
  draggable: true,
  selectable: true,

  addNodeView() {
    return ReactNodeViewRenderer(CodeNodeView);
  },
}).configure({ lowlight });
import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";

const CodeNodeView = () => {
  return (
    <NodeViewWrapper
      contentEditable={true}
      suppressContentEditableWarning
      data-drag-handle
      draggable
    >
      <div className="inline-block relative w-full">
        <pre
          className="bg-[#f9f8f7] p-3 m-1 rounded-md w-full"
          onDragStart={(e) => {
            e.preventDefault();
          }}
        >
          <NodeViewContent />
        </pre>
      </div>
    </NodeViewWrapper>
  );
};

export default CodeNodeView;

이번 글에서는 tiptap 기능들을 살펴보고, 폰트, 이미지, 텍스트 등 다양한 기능들을 구현해봤습니다. 이제 간단한 에디터에서 고오오급 에디터에 조금 더 가까워졌네요. 위에서 말씀드린 것처럼 기능들(에디터, 툴바, 커스텀 익스텐션, 이벤트)을 나눠서 작업했기 때문에 깃허브 클론 받고 코드를 보시는게 더 이해하시기에는 쉬울 것 같습니다. 

 

언제나처럼 ㅡ 시작은 삽질이지만, 끝은 지식입니다.

반응형

'기능 > 기타' 카테고리의 다른 글

[RTE] React Tiptap 에디터 적용하기  (0) 2026.01.07
[SMTP] 구글 이메일 발송  (3) 2025.08.08
[기타] 엑셀 다운로드  (3) 2025.08.08
'기능/기타' 카테고리의 다른 글
  • [RTE] React Tiptap 에디터 적용하기
  • [SMTP] 구글 이메일 발송
  • [기타] 엑셀 다운로드
그낙이
그낙이
시작은 삽질이지만, 끝은 지식입니다.
  • 그낙이
    개발 삽질 일지
    그낙이
  • 전체
    오늘
    어제
    • 분류 전체보기 (71)
      • 서버 (12)
        • 터미널 기본기 (4)
        • AWS (3)
        • Linux (5)
      • 아키텍처 (3)
      • 기능 (19)
        • 로그인 (4)
        • API (5)
        • 앱 (5)
        • 기타 (4)
      • 자유로운 개발일지 (37)
        • APP (4)
        • AI (7)
        • 직링 (19)
        • 자동매매 (6)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    티켓
    linux
    puppeteer
    비트코인
    인앱 결제
    개발자 도구 우회
    fiddler
    GPT
    kotlin
    챗봇 만들기
    자동화 도구
    웹소켓
    직링
    예매
    챗봇
    업비트
    Capacitor
    개발자 도구
    EC2
    소셜 로그인
    자동매매
    콘서트
    FastAPI
    코인
    apple connect store
    IAP
    apple developer
    앱
    퍼피티어
    nginx
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
그낙이
[RTE] React Tiptap 에디터 적용하기 2
상단으로

티스토리툴바