React

 

https://tiptap.dev/docs/editor/getting-started/install/nextjs

 

Tiptap 라이선스 및 협업 기능 정리

 

1. Tiptap은 무료인가, 유료인가?

  • 무료 (오픈소스):

    • Tiptap의 기본 에디터와 대부분의 확장(extensions)은 MIT 라이선스로 제공됩니다.

    • 따라서 개인 프로젝트, 기업 프로젝트, 상업적 서비스(예: 쇼핑몰, 블로그, SaaS 등)에도 자유롭게 사용 가능합니다.

    • 즉, 협업 기능을 제외하면 상업적 사용에 제약이 없음.

  • 유료 (Pro 기능):

    • 협업(Collaboration), AI 관련 기능, 일부 엔터프라이즈용 서비스는 유료 플랜으로 제공됩니다.

    • 이 부분은 오픈소스 코어와 분리된 Tiptap Pro 서비스로 운영됩니다.

 

2. 협업(Collaboration)이란 무엇인가?

  • 협업의 의미:

    • 여러 사용자가 동시에 같은 문서를 열고, 실시간으로 편집할 수 있는 기능을 말합니다.

    • Google Docs에서 여러 사람이 함께 문서를 수정하는 것과 같은 개념입니다.

  • 구현 방식:

    • Tiptap 협업 기능은 ProseMirror의 Y.js 기반으로 동작합니다.

    • 충돌 없는 실시간 편집, 버전 관리, 다중 사용자 커서 표시 등을 지원합니다.

  • 중요 포인트:

    • 협업은 단순히 "내가 작성한 글을 다른 사람이 볼 수 있다"는 것이 아니라, 동시에 여러 사용자가 한 에디터에 접속해 편집하는 것을 의미합니다.

    • 따라서 쇼핑몰 상품 설명 작성, 블로그 포스팅, 일반 게시판 작성 등 단일 사용자 입력 환경에는 협업 기능이 필요하지 않음.

 

3. 정리

  • 무료 사용 가능: 쇼핑몰, 블로그, 기업 웹사이트, SaaS 서비스 등에서 문제없이 상업적 사용 가능.

  • 유료 필요: 여러 사용자가 동시에 문서를 편집하는 Google Docs 스타일의 협업 기능을 구현하려면 유료(Pro) 플랜 필요.

  • 협업의 의미: 단순 공유가 아니라, 다중 사용자의 실시간 동시 편집을 뜻함.

 

 

 

✅ Simple template   MIT 라인센스

 

1. 라이브러리 설치 

 

npx @tiptap/cli@latest add simple-editor

 

2. app/globals.css

@import '../styles/_variables.scss';
@import '../styles/_keyframe-animations.scss';

 

3. 사용

import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor'

export default function App() {
  return <SimpleEditor />
}

 

 

 

4.툴바 한국어 설정

tiptap-templates/simple/simple-editor.tsx

 

~
const MainToolbarContent = ({
  onHighlighterClick,
  onLinkClick,
  isMobile,
}: {
  onHighlighterClick: () => void
  onLinkClick: () => void
  isMobile: boolean
}) => {
  return (
    <>
 
      <ToolbarGroup>
        <UndoRedoButton action="undo" tooltip="되돌리기" />
        <UndoRedoButton action="redo" tooltip="다시 실행" />
      </ToolbarGroup>

      <ToolbarSeparator />

      <ToolbarGroup>
        <HeadingDropdownMenu
          levels={[1, 2, 3, 4]}
          portal={isMobile}
          tooltip="제목 스타일"
        />
        <ListDropdownMenu
          types={["bulletList", "orderedList", "taskList"]}
          portal={isMobile}
          tooltip="목록"
        />
        <BlockquoteButton tooltip="인용문" />
        <CodeBlockButton tooltip="코드 블록" />
      </ToolbarGroup>

      <ToolbarSeparator />

      <ToolbarGroup>
        <MarkButton type="bold" tooltip="굵게" />
        <MarkButton type="italic" tooltip="기울임꼴" />
        <MarkButton type="strike" tooltip="취소선" />
        <MarkButton type="code" tooltip="인라인 코드" />
        <MarkButton type="underline" tooltip="밑줄" />
        {!isMobile ? (
          <ColorHighlightPopover tooltip="형광펜" />
        ) : (
          <ColorHighlightPopoverButton
            onClick={onHighlighterClick}
            tooltip="형광펜"
          />
        )}
        {!isMobile ? (
          <LinkPopover tooltip="링크" />
        ) : (
          <LinkButton onClick={onLinkClick} tooltip="링크 추가" />
        )}
      </ToolbarGroup>

      <ToolbarSeparator />

      <ToolbarGroup>
        <MarkButton type="superscript" tooltip="위 첨자" />
        <MarkButton type="subscript" tooltip="아래 첨자" />
      </ToolbarGroup>

      <ToolbarSeparator />

      <ToolbarGroup>
        <TextAlignButton align="left" tooltip="왼쪽 정렬" />
        <TextAlignButton align="center" tooltip="가운데 정렬" />
        <TextAlignButton align="right" tooltip="오른쪽 정렬" />
        <TextAlignButton align="justify" tooltip="양쪽 정렬" />
      </ToolbarGroup>

      <ToolbarSeparator />

      <ToolbarGroup>
        <ImageUploadButton text="이미지 추가" tooltip="이미지 업로드" />
      </ToolbarGroup>

      {/* <Spacer /> */}

      {isMobile && <ToolbarSeparator />}

      <ToolbarGroup>
        <ThemeToggle  />
      </ToolbarGroup>
    </>
  )
}

~

 

 

5.화면 사이즈 조절

tiptap-templates/simple/simple-editor.tsx

  return (
    <div className="simple-editor-wrapper w-full max-w-full h-full max-h-full">
      <EditorContext.Provider value={{ editor }}>
        <Toolbar
          ref={toolbarRef}
           className="flex flex-wrap gap-1 py-2 px-4 bg-white dark:bg-gray-800 shadow-md z-30 absolute w-full"
          style={{
            ...(isMobile
              ? {
                  bottom: `calc(100% - ${height - rect.y}px)`,
                }
              : {}),
          }}
        >

 

 

6.수정 모드에서 외부 데이터가 바뀔 때 자동 반영되게 하게

tiptap-templates/simple/simple-editor.tsx

export function SimpleEditor({ content }: { content?: string }) {
  const isMobile = useIsMobile()
  const { height } = useWindowSize()
  const [mobileView, setMobileView] = React.useState<"main" | "highlighter" | "link">("main")
  const toolbarRef = React.useRef<HTMLDivElement>(null)

  const editor = useEditor({
    immediatelyRender: false,
    shouldRerenderOnTransaction: false,
    editorProps: {
      attributes: {
        autocomplete: "off",
        autocorrect: "off",
        autocapitalize: "off",
        "aria-label": "Main content area, start typing to enter text.",
        class: "simple-editor",
      },
    },
    extensions: [
      StarterKit.configure({
        horizontalRule: false,
        link: { openOnClick: false, enableClickSelection: true },
      }),
      HorizontalRule,
      TextAlign.configure({ types: ["heading", "paragraph"] }),
      TaskList,
      TaskItem.configure({ nested: true }),
      Highlight.configure({ multicolor: true }),
      Image,
      Typography,
      Superscript,
      Subscript,
      Selection,
      ImageUploadNode.configure({
        accept: "image/*",
        maxSize: MAX_FILE_SIZE,
        limit: 3,
        upload: handleImageUpload,
        onError: (error) => console.error("Upload failed:", error),
      }),
    ],
    content, // 초기 렌더링 시 content 반영
  })

  // ✅ 외부에서 content가 바뀌면 editor 내용 갱신
  React.useEffect(() => {
    if (editor && content !== undefined) {
      editor.commands.setContent(content)
    }
  }, [content, editor])

  return (
    <div className="simple-editor-wrapper w-full max-w-full h-full max-h-full">
      <EditorContext.Provider value={{ editor }}>
        <Toolbar ref={toolbarRef} className="flex flex-wrap gap-1 ">
          {/* ... toolbar 내용 그대로 ... */}
        </Toolbar>
        <EditorContent editor={editor} role="presentation" className="simple-editor-content" />
      </EditorContext.Provider>
    </div>
  )
}

 

 

 

 

7. 이미지 업로드 처리

✅ 이미지 업로드 동작 방식

  1. 툴바의 <ImageUploadButton /> 클릭 → 파일 선택 창 열림

  2. 사용자가 이미지를 선택 → handleImageUpload 함수 실행

  3. handleImageUpload 함수에서

    • (A) 서버로 업로드 → 이미지 URL 받아오기

    • (B) 또는 로컬(preview) URL 생성

  4. 받은 URL을 editor.commands.setImage({ src: url }) 로 삽입

 

handleImageUpload 예시 코드

/lib/tiptap-utils.ts 안에 이런 식으로 작성하면 

// /lib/tiptap-utils.ts
export const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB

export async function handleImageUpload(file: File): Promise<string> {
  if (!file.type.startsWith("image/")) {
    throw new Error("이미지 파일만 업로드할 수 있습니다.")
  }
  if (file.size > MAX_FILE_SIZE) {
    throw new Error("파일 크기가 너무 큽니다. (최대 5MB)")
  }

  // ✅ (A) 서버 업로드 방식
  // Next.js API Route로 업로드 요청
  const formData = new FormData()
  formData.append("file", file)

  const res = await fetch("/api/upload", {
    method: "POST",
    body: formData,
  })

  if (!res.ok) throw new Error("이미지 업로드 실패")
  const data = await res.json()

  // data.url 은 서버에서 반환하는 이미지 URL
  return data.url

  // ✅ (B) 서버 업로드가 필요 없다면, 로컬 미리보기 URL
  // return URL.createObjectURL(file)
}

 

Next.js API 라우트 예시 (/app/api/upload/route.ts)

 

import { NextRequest, NextResponse } from "next/server"
import { writeFile } from "fs/promises"
import path from "path"

export async function POST(req: NextRequest) {
  const formData = await req.formData()
  const file = formData.get("file") as File

  if (!file) {
    return NextResponse.json({ error: "파일 없음" }, { status: 400 })
  }

  const bytes = await file.arrayBuffer()
  const buffer = Buffer.from(bytes)

  const uploadDir = path.join(process.cwd(), "public/uploads")
  const filePath = path.join(uploadDir, file.name)

  await writeFile(filePath, buffer)

  return NextResponse.json({
    url: `/uploads/${file.name}`,
  })
}

 

이제 업로드된 이미지는 /public/uploads 안에 저장되고, 브라우저에서는 /uploads/파일명 으로 접근할 수 있습니다

 

✅ 정리

  • ImageUploadButton → handleImageUpload 호출

  • handleImageUpload 안에서

    • 서버 업로드 방식(API + public/uploads 저장)

    • 또는 로컬 preview 방식(URL.createObjectURL)

  • 업로드 완료 후 editor.commands.setImage({ src: url }) 실행

 

 

 

8.마크다운 적용

라이브러리 설치

pnpm add marked turndown 

pnpm i --save-dev @types/turndown

 

tiptap-templates/simple/editor-md-wrapper.tsx

"use client";

import { AlertCircle } from "lucide-react";
import { useEffect, useState, forwardRef } from "react";

import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";

import { useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";

// ???? Markdown ↔ HTML 변환기
import { marked } from "marked";
import TurndownService from "turndown";
import { SimpleEditor } from "./simple-editor";

//pnpm i --save-dev @types/turndown

const turndownService = new TurndownService();


const LoadingFallback = () => (
  <div className="min-h-[350px] rounded-md border background-light500_dark200 text-light-700_dark300 p-4">
    <div className="space-y-3">
      <Skeleton className="h-8 w-full" />
      <Skeleton className="h-4 w-3/4" />
      <Skeleton className="h-4 w-1/2" />
      <Skeleton className="h-4 w-5/6" />
      <div className="space-y-2 pt-4">
        <Skeleton className="h-4 w-full" />
        <Skeleton className="h-4 w-full" />
        <Skeleton className="h-4 w-2/3" />
      </div>
    </div>
  </div>
);

interface EditorWrapperProps {
  markdown?: string;
  onChange?: (markdown: string) => void;
  className?: string;
}

export function TiptapMDEditorWrapper({
  markdown = "",
  onChange,
  className,
}: EditorWrapperProps) {
  const [hasError, setHasError] = useState(false);

  // ✅ Markdown → HTML 변환 후 초기 content 세팅
  const initialHTML = markdown ? marked.parse(markdown) : "";

  const editor = useEditor({
    extensions: [StarterKit],
    content: initialHTML,
    editorProps: {
      attributes: {
        class: `prose dark:prose-invert focus:outline-none ${className ?? ""}`,
      },
    },
    immediatelyRender: false, // ???? SSR 방지
    onUpdate: ({ editor }) => {
      // ✅ HTML → Markdown 변환
      const html = editor.getHTML();
      const md = turndownService.turndown(html);
      onChange?.(md);
    },
  });

  useEffect(() => {
    if (typeof window !== "undefined") {
      setHasError(false);
    }
  }, []);

  if (hasError) {
    return (
      <Alert
        variant="destructive"
        className="min-h-[350px] flex items-center justify-center"
      >
        <AlertCircle className="h-4 w-4" />
        <AlertDescription>
          편집기를 로드하지 못했습니다. 페이지를 새로고침하여 다시 시도해 주세요.
        </AlertDescription>
      </Alert>
    );
  }

  return (
    <div className="min-h-[350px] rounded-md border p-4 ">
      {editor ? <SimpleEditor content={editor.getHTML()} /> : <LoadingFallback />}
    </div>
  );
}

// ForwardRef 지원
export const ForwardRefEditor = forwardRef<any, EditorWrapperProps>(
  (props, ref) => <TiptapMDEditorWrapper {...props} />
);

ForwardRefEditor.displayName = "ForwardRefEditor";

export default TiptapMDEditorWrapper;

 

 

사용

        <div className="max-w-4xl  h-[calc(100vh-215px)] overflow-y-auto">          
             <TiptapMDEditorWrapper markdown={content} />
        </div>

 

 

 

 

9. 양방향 바인딩 onUpdate 부모에 값 전달

tiptap-templates/simple/simple-editor.tsx

 

~
export function SimpleEditor({ content, onChange }: { content?: string, onChange?: (content: string) => void }) {

~

 const editor = useEditor({
    immediatelyRender: false,
    shouldRerenderOnTransaction: false,
    editorProps: {
      attributes: {
        autocomplete: "off",
        autocorrect: "off",
        autocapitalize: "off",
        "aria-label": "Main content area, start typing to enter text.",
        class: "simple-editor",
      },
    },
    extensions: [
      StarterKit.configure({
        horizontalRule: false,
        link: {
          openOnClick: false,
          enableClickSelection: true,
        },
      }),
      HorizontalRule,
      TextAlign.configure({ types: ["heading", "paragraph"] }),
      TaskList,
      TaskItem.configure({ nested: true }),
      Highlight.configure({ multicolor: true }),
      Image,
      Typography,
      Superscript,
      Subscript,
      Selection,
      ImageUploadNode.configure({
        accept: "image/*",
        maxSize: MAX_FILE_SIZE,
        limit: 3,
        upload: handleImageUpload,
        onError: (error) => console.error("Upload failed:", error),
      }),
    ],
    content, // 초기 렌더링 시 content 반영
    onUpdate: ({ editor }) => {
      onChange?.(editor.getHTML()) // ✅ 부모에 값 전달
    },
  
    
  })

 

tiptap-templates/simple/editor-md-wrapper.tsx

~
interface EditorWrapperProps {
  markdown?: string;
  onChange?: (markdown: string) => void;
  className?: string;
  setContent?: (content: string) => void;
}

export function TiptapMDEditorWrapper({
  markdown = "",
  onChange,
  className,
  setContent,
}: EditorWrapperProps) {
  const [hasError, setHasError] = useState(false);

  // ✅ Markdown → HTML 변환 후 초기 content 세팅
  const initialHTML = markdown ? marked.parse(markdown) : "";

  const editor = useEditor({
    extensions: [StarterKit],
    content: initialHTML,
    editorProps: {
      attributes: {
        class: `prose dark:prose-invert focus:outline-none ${className ?? ""}`,
      },
    },
    immediatelyRender: false, // ???? SSR 방지
    onUpdate: ({ editor }) => {
      // ✅ HTML → Markdown 변환
      const html = editor.getHTML();
      const md = turndownService.turndown(html);
      onChange?.(md);
    },
  });

  useEffect(() => {
    if (typeof window !== "undefined") {
      setHasError(false);
    }
  }, []);

  if (hasError) {
    return (
      <Alert
        variant="destructive"
        className="min-h-[350px] flex items-center justify-center"
      >
        <AlertCircle className="h-4 w-4" />
        <AlertDescription>
          편집기를 로드하지 못했습니다. 페이지를 새로고침하여 다시 시도해 주세요.
        </AlertDescription>
      </Alert>
    );
  }

  return (
    <div className="min-h-[350px] rounded-md border p-4 ">
      {editor ? <SimpleEditor content={editor.getHTML()}
                 onChange={(content) => setContent?.(content)}
      /> : <LoadingFallback />}
    </div>
  );
}


~

 

사용

~
export function SummaryUpdateForm({ summary }: ISummaryUpdateFormProps) {
~

  const [content, setContent] = useState(summary.content);

  useEffect(() => {
    setContent(summary.content);
  }, [summary.content]);


  return (
    <div className={styles.container}>
      <form action={updateFormAction}>
~


        <textarea
              name="content"
              value={content}
              placeholder="Enter summary content"
              title="Summary Content"
              readOnly
            />



    <div className={styles.fieldGroup}>
       <div className="w-full flex flex-col items-center justify-center  gap-10   ">
           <div className="w-full  h-[calc(100vh-215px)] overflow-y-auto">          
              <TiptapMDEditorWrapper 
              onChange={(value) => setContent(value)}
              setContent={setContent}
              markdown={content} />

             <ZodErrors error={updateFormState?.zodErrors?.content} />
          </div>
      </div>


~

 

 

 

 

1.Next.js 프로젝트에 Tiptap을  설정 방법

 

요구 사항

  • Node.js가 설치된 환경

  • React 사용 경험

 

 

1) 프로젝트 생성 (선택 사항)

이미 Next.js 프로젝트를 가지고 있다면 이 단계는 건너뛰어도 됩니다. 여기서는 예시를 위해 새로운 프로젝트를 생성하겠습니다.

아래 명령어로 my-tiptap-project라는 프로젝트를 생성합니다.

 

# 프로젝트 생성
npx create-next-app my-tiptap-project

# 디렉토리 이동
cd my-tiptap-project

 

 

2) 의존성 설치

Next.js 보일러플레이트가 준비되었으니, 이제 Tiptap을 설치합니다. 필요한 패키지는 세 가지입니다.

  • @tiptap/react

  • @tiptap/pm

  • @tiptap/starter-kit (기본 확장 기능 포함)

npm install @tiptap/react @tiptap/pm @tiptap/starter-kit

위 단계를 마쳤다면 npm run dev로 개발 서버를 실행하고, 브라우저에서 http://localhost:3000/에 접속할 수 있습니다. (기존 프로젝트라면 경로가 다를 수 있음)

 

 

3) Tiptap 통합하기

Tiptap을 사용하려면 새 컴포넌트를 추가해야 합니다.

  1. components/ 디렉토리를 생성합니다.

  2. components/Tiptap.jsx 파일을 만들고 아래 코드를 추가합니다.

'use client'

import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'

const Tiptap = () => {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Hello World! ????️</p>',
    // SSR 이슈를 피하기 위해 즉시 렌더링하지 않음
    immediatelyRender: false,
  })

  return <EditorContent editor={editor} />
}

export default Tiptap

 

 

4) 앱에 추가하기

이제 app/page.js (Pages Router를 쓴다면 pages/index.js)의 내용을 수정해 Tiptap 컴포넌트를 불러옵니다.

import Tiptap from '../components/Tiptap'

export default function Home() {
  return <Tiptap />
}

 

브라우저를 새로고침하면 Tiptap 에디터가 정상적으로 보이는 것을 확인할 수 있습니다. 

 

 

 

다음 단계

  • 에디터 설정 커스터마이징하기

  • 스타일 추가하기

  • Tiptap 개념 더 알아보기

  • 에디터 상태를 저장하는 방법 학습하기

  • 나만의 확장 기능 만들어보기

 

 

 

 

 

2. 에디터 설정 커스터마이징하기

 

Tiptap을 설정할 때는 세 가지 핵심 요소를 지정해야 합니다.

  1. 렌더링할 위치 (element)
    React나 Vue 통합을 사용할 경우에는 직접 지정하지 않아도 됩니다.

  2. 활성화할 기능 (extensions)

  3. 초기 문서 내용 (content)

이 기본 설정만으로 대부분의 경우에 충분하지만, 추가 옵션도 설정할 수 있습니다.

 

1) 설정 추가하기

에디터를 설정하려면 아래와 같이 Editor 클래스에 객체 형태의 설정을 전달합니다.

import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'

new Editor({
  // `.element`에 Tiptap 바인딩
  element: document.querySelector('.element'),
  // 확장 기능 등록
  extensions: [Document, Paragraph, Text],
  // 초기 콘텐츠 지정
  content: '<p>Example Text</p>',
  // 초기화 후 커서를 에디터에 배치
  autofocus: true,
  // 텍스트 편집 가능 여부 (기본값 true)
  editable: true,
  // 기본 ProseMirror CSS 로딩 방지
  // 대부분의 경우 true 유지 권장 (Tiptap 동작에 필수적인 스타일 포함)
  injectCSS: false,
})

 

 

 

2) 노드, 마크, 확장 기능

대부분의 편집 기능은 노드(node), 마크(mark), 확장 기능(extension) 형태로 제공됩니다. 필요한 것만 불러와서 extensions 배열에 전달하면 됩니다.

아래는 최소한의 설정 예시입니다.

import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'

new Editor({
  element: document.querySelector('.element'),
  extensions: [Document, Paragraph, Text],
})

 

 

3) 확장 기능 설정하기

많은 확장 기능은 .configure() 메서드로 세부 설정을 할 수 있습니다. 예를 들어, Heading 확장에서 제목 레벨을 1, 2, 3까지만 허용하려면 다음과 같이 설정합니다.

import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import Heading from '@tiptap/extension-heading'

new Editor({
  element: document.querySelector('.element'),
  extensions: [
    Document,
    Paragraph,
    Text,
    Heading.configure({
      levels: [1, 2, 3],
    }),
  ],
})

각 확장 기능의 문서를 참고하면 더 많은 설정 옵션을 확인할 수 있습니다.

 

 

4) 가장 흔한 확장 기능 묶음 (StarterKit)

Tiptap은 자주 쓰이는 확장 기능들을 StarterKit이라는 번들로 제공합니다. 사용 방법은 간단합니다.

import StarterKit from '@tiptap/starter-kit'

new Editor({
  extensions: [StarterKit],
})

 

 

5) StarterKit 확장 기능 개별 설정

StarterKit.configure() 메서드를 통해 번들 내 확장 기능을 개별적으로 설정할 수 있습니다. 특정 확장 기능에만 옵션을 주려면 이름을 키로 전달하면 됩니다.

예: Heading 레벨 제한

import StarterKit from '@tiptap/starter-kit'

new Editor({
  extensions: [
    StarterKit.configure({
      heading: {
        levels: [1, 2, 3],
      },
    }),
  ],
})

 

 

 

6) 특정 확장 기능 비활성화

StarterKit에서 특정 기능을 제외하려면 false로 설정하면 됩니다. 예를 들어, Undo/Redo 히스토리 확장을 끄려면:

import StarterKit from '@tiptap/starter-kit'

new Editor({
  extensions: [
    StarterKit.configure({
      undoRedo: false,
    }),
  ],
})

Tiptap의 Collaboration(협업 기능) 을 사용할 때는 자체 히스토리 확장이 있기 때문에 StarterKit의 Undo/Redo 확장을 반드시 비활성화해야 충돌을 피할 수 있습니다.

 

 

 

7) 추가 확장 기능

StarterKit이 모든 확장을 포함하는 것은 아닙니다. 더 많은 기능을 원한다면 확장을 직접 불러와 extensions 배열에 추가하세요.

예: Strike(취소선) 확장 추가

import StarterKit from '@tiptap/starter-kit'
import Strike from '@tiptap/extension-strike'

new Editor({
  extensions: [StarterKit, Strike],
})

 

 

 

 

 

3. 스타일 추가하기
 

Tiptap은 Headless-first 접근 방식을 따릅니다. 즉, 핵심 확장 기능은 스타일이나 UI 컴포넌트 없이 순수 로직만 제공됩니다. 덕분에 에디터의 외형과 동작을 완전히 원하는 대로 제어할 수 있습니다.

하지만 개발 속도를 높이기 위해 선택적으로 사용할 수 있는 UI 컴포넌트와 템플릿도 제공됩니다.

 

 

Tiptap UI 템플릿

UI 템플릿 활용하기

Tiptap에서 제공하는 UI 템플릿은 자주 쓰이는 에디터 기능들을 미리 구성한 컴포넌트들입니다. 소스 코드를 다운로드해 프로젝트에 맞게 커스터마이징할 수 있습니다.

https://tiptap.dev/docs/ui-components/getting-started/overview

 

직접 UI 만들기

직접 UI를 만들거나 Tiptap 스타일링 방식을 이해하고 싶다면 다음과 같은 방법들을 사용할 수 있습니다.

 

 

 

1) 순수 HTML 스타일링

에디터는 .tiptap 클래스가 적용된 컨테이너 안에 렌더링됩니다. 따라서 해당 클래스를 기준으로 스타일을 지정할 수 있습니다.

/* 에디터 전용 스타일 */
.tiptap p {
  margin: 1em 0;
}

 

 

2) CSS Modules 스타일링

CSS Modules에서는 클래스명이 로컬 스코프로 변환되기 때문에 .tiptap을 직접 타겟팅할 경우 스타일이 적용되지 않을 수 있습니다.

이때는 전역 스타일이나 :global(.tiptap)을 사용하면 됩니다.

/* 전역 스타일 */
p {
  margin: 1em 0;
}

만약 저장된 콘텐츠를 다른 곳에서 렌더링한다면 .tiptap 컨테이너가 없을 수 있으므로, 전역 HTML 태그 스타일링이 필요합니다.

 

 

3) 커스텀 클래스 추가하기

렌더링 방식을 제어하면서 HTML 요소에 직접 클래스를 추가할 수도 있습니다.

확장 기능을 통한 클래스 추가

대부분의 확장 기능은 HTMLAttributes 옵션을 지원합니다. 이를 활용해 커스텀 클래스를 지정할 수 있으며, 특히 Tailwind CSS와 함께 사용할 때 유용합니다.

new Editor({
  extensions: [
    Document,
    Paragraph.configure({
      HTMLAttributes: {
        class: 'my-custom-paragraph',
      },
    }),
    Heading.configure({
      HTMLAttributes: {
        class: 'my-custom-heading',
      },
    }),
    Text,
  ],
})

렌더링 결과:

<h1 class="my-custom-heading">Example Text</h1>
<p class="my-custom-paragraph">Wow, that's really custom.</p>

기존 클래스가 있다면 여기에 추가됩니다.

 

 

4) 에디터 컨테이너에 클래스 추가하기

new Editor({
  editorProps: {
    attributes: {
      class: 'prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none',
    },
  },
})

 

 

5)HTML 마크업 커스터마이징

확장 기능의 렌더링 방식을 직접 바꿀 수도 있습니다. 예를 들어 Bold 확장을 <strong> 대신 <b> 태그로 렌더링하도록 수정:

import Bold from '@tiptap/extension-bold'

const CustomBold = Bold.extend({
  renderHTML({ HTMLAttributes }) {
    return ['b', HTMLAttributes, 0]
  },
})

new Editor({
  extensions: [CustomBold],
})

커스텀 확장은 별도 파일에 관리하는 것이 좋습니다.

 

 

6) Tailwind CSS로 스타일링하기

Tiptap 콘텐츠는 순수 HTML이므로 기본적으로 Tailwind CSS 클래스가 적용되지 않습니다. Tailwind를 활용하려면 다음 방법 중 하나를 선택하세요.

 

1. 전역 CSS와 @apply 사용

Tailwind 팀은 @apply 사용을 권장하지 않지만, 사용자 입력 콘텐츠처럼 유연한 스타일링에는 적합합니다. 전역 CSS에서 .tiptap을 기준으로 스타일을 정의하세요.

.tiptap {
  p {
    @apply my-4 first:mt-0 last:mb-0 text-base leading-relaxed;
  }

  h1 {
    @apply text-3xl font-bold mt-8 mb-4 first:mt-0 last:mb-0;
  }
}

 

 

2. 확장 기능에 Tailwind 클래스 추가하기

전역 CSS 대신 확장을 확장하여 renderHTML에서 Tailwind 클래스를 직접 지정할 수도 있습니다.

import Paragraph from '@tiptap/extension-paragraph'

const TailwindParagraph = Paragraph.extend({
  renderHTML({ HTMLAttributes }) {
    return [
      'p',
      {
        ...HTMLAttributes,
        class: 'my-4 first:mt-0 last:mb-0 text-base leading-relaxed',
      },
      0,
    ]
  },
})

이 방식은 가능하지만 확장이 복잡해질수록 관리가 어려워지므로, 대부분의 경우 전역 CSS와 @apply를 사용하는 것을 권장합니다.

 

 

3. @tailwindcss/typography 플러그인 사용

Tiptap은 Tailwind의 Typography 플러그인과도 잘 호환됩니다. 이를 적용해 더 쉽게 스타일링할 수 있습니다.

 

 

 

7) TailwindCSS Intellisense 설정

VS Code에서 TailwindCSS Intellisense를 사용할 경우, Tiptap 객체 내부에서도 지원되도록 .vscode/settings.json에 다음을 추가하세요.

"tailwindCSS.experimental.classRegex": [
  "class:\\s*?[\"'`]([^\"'`]*)[\"'`],"
]

 

 

 

 

4. Tiptap 개념 더 알아보기

Tiptap의 API는 ProseMirror의 아키텍처를 기반으로 하여, 정교한 리치 텍스트 편집을 가능하게 합니다. 여기서는 Tiptap을 이해하는 데 필요한 핵심 개념들을 정리합니다.

 

 

1) 구조 (Structure)

ProseMirror는 Schema(스키마) 라는 엄격한 규칙에 따라 동작합니다. 스키마는 문서가 가질 수 있는 구조를 정의하며, 문서는 제목(heading), 문단(paragraph) 등으로 이루어진 트리 구조로 표현됩니다. 이러한 요소들을 노드(Node) 라고 부릅니다. 또한, 노드에는 마크(Mark) 를 적용할 수 있는데, 예를 들어 텍스트의 일부분을 강조(italic, bold 등)하는 것이 가능합니다. 명령(Command) 은 이런 문서를 프로그래밍적으로 변경하는 기능을 수행합니다.

 

 

2) 콘텐츠 (Content)

문서는 상태(State) 에 저장됩니다. 모든 변경은 트랜잭션(Transaction) 을 통해 상태에 적용됩니다. 상태에는 현재의 콘텐츠, 커서 위치, 선택 영역에 대한 정보가 포함됩니다. 또한, 특정 이벤트에 훅(Hook)을 걸어 트랜잭션이 적용되기 전 이를 가로채어 조정할 수도 있습니다.

 

 

3 )확장 (Extensions)

확장(Extension) 은 에디터에 새로운 노드, 마크, 기능 등을 추가합니다. 많은 확장들은 자주 사용하는 키보드 단축키와 연결되어 있어, 직관적인 사용이 가능합니다.

 

 

4) 용어 정리 (Vocabulary)

ProseMirror 및 Tiptap에서 자주 등장하는 핵심 용어를 정리하면 다음과 같습니다:

 

 

5) 용어설명

 

 

 

 

 

5. 에디터 상태를 저장하는 방법 학습하기

에디터를 설정하고, 확장 기능을 추가하고, 콘텐츠를 작성했다면 다음 단계는 에디터 상태를 어떻게 저장(persist)할 것인가입니다. Tiptap은 HTML 또는 JSON 형태로 데이터를 반환할 수 있어, DB, LocalStorage, SQLite, IndexedDB 등 다양한 저장소에 손쉽게 저장할 수 있습니다.

HTML vs JSON 저장

  • HTML: 렌더링 가능한 형태라서 가장 단순하지만, 파싱 과정이 필요하고 외부에서 수정을 가하려면 번거롭습니다.

  • JSON (권장): 구조적이고 유연하며, 파싱하기 쉽습니다. 또 HTML 파서를 거치지 않고도 외부에서 데이터를 수정할 수 있어 실용적입니다.

 

 

1) LocalStorage에 상태 저장하기

브라우저의 LocalStorage API를 사용하면 간단히 상태를 저장하고 복원할 수 있습니다.

// 상태 저장하기
localStorage.setItem('editorContent', JSON.stringify(editor.getJSON()))

// 상태 복원하기
const savedContent = localStorage.getItem('editorContent')
if (savedContent) {
  editor.setContent(JSON.parse(savedContent))
}

에디터 초기화 시 LocalStorage에 저장된 데이터를 불러올 수도 있습니다:

const savedContent = localStorage.getItem('editorContent')
const editor = new Editor({
  content: savedContent ? JSON.parse(savedContent) : '',
  extensions: [
    // 확장 기능들
  ],
})

 

 

2)데이터베이스에 상태 저장하기

DB에 저장할 때도 같은 방식으로 JSON을 활용합니다. fetch API로 서버에 전송할 수 있습니다.

// DB에 상태 저장하기
fetch('/api/editor/content', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(editor.getJSON()),
})

DB에서 콘텐츠를 복원할 때:

fetch('/api/editor/content')
  .then(response => response.json())
  .then(data => {
    editor.setContent(data)
  })
  .catch(error => {
    console.error('Error fetching editor content:', error)
  })

 

 

3) React에서 상태 복원하기

React에서는 useEffect나 useMemo를 사용해 컴포넌트 마운트 시 콘텐츠를 불러올 수 있습니다.

function MyEditor() {
  const content = useMemo(() => {
    const savedContent = localStorage.getItem('editorContent')
    return savedContent ? JSON.parse(savedContent) : ''
  }, [])

  const editor = useEditor({
    content,
    extensions: [
      // 확장 기능들
    ],
  })

  return (
    <div>
      <EditorContent editor={editor} />
      <button
        onClick={() => {
          localStorage.setItem('editorContent', JSON.stringify(editor.getJSON()))
        }}
      >
        Save Content
      </button>
    </div>
  )
}

✅ 정리: JSON 형태로 상태를 저장하는 것이 가장 효율적이며, LocalStorage나 DB 등 원하는 스토리지에 쉽게 연동할 수 있습니다.

 

 

 

 

 

 

 

 

6. 나만의 확장 기능 만들어보기

Tiptap의 가장 큰 장점 중 하나는 바로 확장성(Extendability) 입니다. 기본 제공되는 확장 기능에만 의존할 필요가 없으며, 원하는 방식으로 직접 확장 기능을 만들어서 에디터에 추가할 수 있습니다.

커스텀 확장을 활용하면 기존에 있는 기능 위에 새로운 콘텐츠 타입을 추가하거나, 아예 처음부터 새로운 기능을 만들어낼 수도 있습니다. 여기서는 기존 확장을 확장하는 방법과 새로운 확장을 만드는 기본적인 흐름을 소개합니다.

 

1) 확장 및 커스터마이징

1. 기존 확장 기능 확장하기 (Extend Extensions)

  • 기본 제공되는 확장을 가져와서 일부 동작이나 속성을 수정할 수 있습니다.

  • 예: Paragraph, Heading, Bold 같은 확장에 새로운 속성 추가하기.

import Paragraph from '@tiptap/extension-paragraph'

const CustomParagraph = Paragraph.extend({
  addAttributes() {
    return {
      class: {
        default: 'my-paragraph',
      },
    }
  },
})

위 예시는 기본 단락(Paragraph)에 class 속성을 추가해서 커스텀 스타일링이 가능하도록 확장한 코드입니다.

 

 

2. 새로운 확장 기능 만들기 (Create Extensions)

  • 아예 새로운 확장을 만들어서 원하는 기능을 추가할 수도 있습니다.

  • 확장은 크게 Nodes(노드), Marks(마크), 기타 기능 확장으로 나눌 수 있습니다.

노드(Node)

  • 콘텐츠의 구조적 단위 (예: 문단, 제목, 이미지 등)

  • 새로운 노드를 정의하면, Tiptap 문서 트리에서 새로운 콘텐츠 타입을 가질 수 있음.

import { Node } from '@tiptap/core'

const CustomNode = Node.create({
  name: 'customNode',
  group: 'block',
  content: 'inline*',

  parseHTML() {
    return [{ tag: 'custom-node' }]
  },

  renderHTML({ HTMLAttributes }) {
    return ['custom-node', HTMLAttributes, 0]
  },
})

 

마크(Mark)

  • 노드에 인라인 속성을 추가할 수 있는 기능 (예: 굵게, 기울임, 링크 등)

import { Mark } from '@tiptap/core'

const CustomHighlight = Mark.create({
  name: 'highlight',

  parseHTML() {
    return [{ tag: 'span[data-highlight]' }]
  },

  renderHTML({ HTMLAttributes }) {
    return ['span', { ...HTMLAttributes, 'data-highlight': '' }, 0]
  },
})

 

 

2) 노드 뷰(Node View) 활용

Tiptap은 단순한 HTML 태그 렌더링뿐 아니라, 인터랙티브 UI를 가진 노드 뷰(Node View)를 만들 수 있습니다. 예를 들어, 버튼이 달린 위젯, React/Vue 컴포넌트와 연결된 노드 등을 구현할 수 있습니다.

공식 문서에 있는 Node View 예제들을 참고하면 더욱 복잡한 UI 요소를 Tiptap 에디터에 삽입할 수 있습니다.

정리

  • 기존 확장을 확장(extend) 해서 새로운 속성을 추가하거나 동작을 수정할 수 있습니다.

  • 새로운 확장을 생성(create) 하여, 새로운 노드(Node)나 마크(Mark)를 추가할 수 있습니다.

  • Node View를 통해 단순 HTML을 넘어서 인터랙티브한 컴포넌트도 구현 가능합니다.

 

https://tiptap.dev/docs/editor/extensions/custom-extensions

 

 

이제 기본기를 익혔다면, 직접 원하는 확장을 만들어 Tiptap 에디터를 원하는 대로 커스터마이징해 보세요!

 

 

 

 

 

 

 

 

 

about author

PHRASE

Level 60  라이트

소금 먹은 놈이 물 켠다 , 죄지은 사람이 벌을 받고, 빚진 사람이 빚을 갚아야 한다는 말.

댓글 ( 0)

댓글 남기기

작성

React 목록    more