본문 바로가기
JavaScript/React

Tiptap Editor

by curious week 2025. 6. 26.

TIPTAP toolkit

 

Tiptap - Dev Toolkit Editor Suite

Tiptap is a suite of content editing & real time collaboration tools. Build editor experiences like Notion in weeks, not years.

tiptap.dev


1단계

1-1. 설치

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

StarterKit은 Document, Paragraph, Text, Bold, Italic 등의 기본 Extension을 모두 포함합니다.


1-2. 기본 개념

1) Editor 인스턴스

  • 에디터 상태와 모든 명령(command)을 포함한 핵심 객체
  • 리액트에서는 useEditor()로 생성

2) EditorContent 컴포넌트

  • 실제 텍스트 입력 영역을 렌더링함
  • editor 인스턴스를 props로 전달

3) extensions

  • 기능(예: Bold, Italic, Heading 등)을 추가하는 플러그인 목록
  • StarterKit이 대부분 포함

1-3. 사용 예제

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

export const BasicEditor = () => {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Hello <strong>World!</strong></p>',
  });

  return (
    <div className="space-y-4">
      <h2 className="text-lg font-semibold">📝 Basic Tiptap Editor</h2>
      <EditorContent editor={editor} className="border p-4 rounded-md min-h-[150px]" />
    </div>
  );
};

1-4. 확장 목록 (StarterKit 내부 포함 기능)

Document 최상위 노드 (필수)
Paragraph 일반 텍스트 블록
Text 실제 텍스트 노드
Bold 굵게 (<strong>)
Italic 기울임 (<em>)
Strike 취소선 (<s>)
BulletList 순서 없는 리스트 (<ul>)
OrderedList 번호 리스트 (<ol>)
Heading 제목 (<h1>~<h6>)
Blockquote 인용구 (<blockquote>)
CodeBlock 코드 블록 (<pre><code>)
History undo/redo 기능

1-5. 상태 보기 / 디버깅

<button onClick={() => console.log(editor?.getJSON())}>
  현재 상태 콘솔 출력
</button>

 


목표

  1. Bold / Italic 토글 버튼
  2. editor.getHTML() / editor.getJSON() 확인용 버튼
  3. 초기 content 없이 빈 에디터로 시작

코드 예제

// components/BasicEditor.tsx
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';

export const BasicEditor = () => {
  // 3. 초기 content 없이 빈 상태로 시작
  const editor = useEditor({
    extensions: [StarterKit],
    content: '', // 빈 content
  });

  if (!editor) return null;

  return (
    <div className="space-y-4 p-4 border rounded-md">
      <h2 className="text-xl font-bold">📝 Tiptap Editor Demo</h2>

      {/* 1. Bold / Italic 토글 버튼 */}
      <div className="flex gap-3">
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={`px-3 py-1 rounded border ${editor.isActive('bold') ? 'bg-black text-white' : ''}`}
        >
          Bold
        </button>
        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={`px-3 py-1 rounded border ${editor.isActive('italic') ? 'bg-black text-white' : ''}`}
        >
          Italic
        </button>
      </div>

      {/* 2. 실제 에디터 UI */}
      <EditorContent editor={editor} className="min-h-[150px] border p-3 rounded" />

      {/* 2. 상태 출력 버튼 */}
      <div className="flex gap-3">
        <button
          onClick={() => console.log('HTML:', editor.getHTML())}
          className="text-blue-600 underline"
        >
          👉 getHTML() 출력
        </button>
        <button
          onClick={() => console.log('JSON:', editor.getJSON())}
          className="text-green-600 underline"
        >
          👉 getJSON() 출력
        </button>
      </div>
    </div>
  );
};

사용 방법

위 컴포넌트를 프로젝트 내에서 import해서 사용

import { BasicEditor } from './components/BasicEditor';

export default function Page() {
  return (
    <div className="max-w-2xl mx-auto mt-10">
      <BasicEditor />
    </div>
  );
}

브라우저에서 개발자 콘솔 열고 getHTML()이나 getJSON() 버튼 클릭 시 결과 확인 가능


2단계

  1. useForm과 Tiptap 에디터 연결
  2. editor.getHTML()을 form의 description 필드로 등록
  3. 기본적인 Tailwind 스타일 + 다크모드 대응
  4. 제출 시 console.log로 HTML 출력 확인

 

useEditor 주요 인자 정리

const editor = useEditor({
  content,            // 초기 콘텐츠 (HTML, JSON, Text 등)
  extensions,         // 사용하고 싶은 확장 기능 (Bold, Image 등)
  editable,           // 에디터 편집 가능 여부
  autofocus,          // 자동 포커스 여부 (boolean 또는 'start' 등)
  injectCSS,          // Tiptap 기본 CSS를 자동 삽입할지
  editorProps,        // ProseMirror의 추가 옵션 (attributes 등)
  onUpdate,           // 내용이 업데이트될 때 실행할 콜백
  onCreate,           // editor 생성 직후 실행할 콜백
  onTransaction,      // 트랜잭션 발생 시 실행할 콜백
});

1. content – 초기 콘텐츠

content: '<p>Hello World</p>'
// 또는
content: {
  type: 'doc',
  content: [
    { type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] },
  ],
}

2. extensions – 사용할 확장 목록

extensions: [
  StarterKit,
  Underline,
  Link.configure({ openOnClick: false }),
  Image.configure({ inline: false }),
]

3. editable – 읽기 전용 모드 토글

editable: true // 또는 false

4. autofocus – 자동 포커스 위치

autofocus: true  // 기본 true
autofocus: 'start' // 커서 시작 위치

5. editorProps – ProseMirror 속성 설정

editorProps: {
  attributes: {
    class: 'prose min-h-[300px] px-4 py-2 focus:outline-none',
  },
}

6. onUpdate – 내용 변경 시 콜백

onUpdate: ({ editor }) => {
  const html = editor.getHTML();
  const json = editor.getJSON();
  console.log('내용 변경됨:', html, json);
}

7. onCreate, onTransaction

  • onCreate: 에디터가 처음 생성되었을 때
  • onTransaction: 내부 트랜잭션 발생 시 (커서 이동 포함)
onCreate: ({ editor }) => { ... }
onTransaction: ({ editor, transaction }) => { ... }

 

코드 예제

// components/RichTextEditorField.tsx
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { useEffect } from 'react';

interface RichTextEditorFieldProps {
  onChange: (value: string) => void;
  value?: string;
}

export const RichTextEditorField = ({ onChange, value }: RichTextEditorFieldProps) => {
  const editor = useEditor({
    extensions: [StarterKit],
    content: value ?? '',
    editorProps: {
      attributes: {
        class:
          'min-h-[200px] w-full p-4 rounded-md border bg-white dark:bg-neutral-900 dark:text-white text-black shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500',
      },
    },
    onUpdate: ({ editor }) => {
      onChange(editor.getHTML()); // 에디터 업데이트 시 form 상태 반영
    },
  });

  // 외부 value가 변경될 경우 반영
  useEffect(() => {
    if (editor && value && editor.getHTML() !== value) {
      editor.commands.setContent(value);
    }
  }, [value]);

  return <EditorContent editor={editor} />;
};

통합 예시

// pages/FormPage.tsx
import { useForm } from 'react-hook-form';
import { RichTextEditorField } from '@/components/RichTextEditorField';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';

type FormValues = {
  title: string;
  description: string;
};

export default function FormPage() {
  const {
    register,
    handleSubmit,
    setValue,
    watch,
    formState: { errors },
  } = useForm<FormValues>({
    defaultValues: {
      title: '',
      description: '',
    },
  });

  const onSubmit = (data: FormValues) => {
    console.log('폼 제출:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-6 p-6">
      {/* title input */}
      <div>
        <label className="block text-sm font-medium mb-1">Title</label>
        <Input placeholder="제목 입력" {...register('title', { required: true })} />
        {errors.title && <p className="text-red-500 text-sm mt-1">제목은 필수입니다.</p>}
      </div>

      {/* description (tiptap) */}
      <div>
        <label className="block text-sm font-medium mb-1">Description</label>
        <RichTextEditorField
          value={watch('description')}
          onChange={(val) => setValue('description', val)}
        />
        {errors.description && <p className="text-red-500 text-sm mt-1">내용을 입력해주세요.</p>}
      </div>

      <Button type="submit" className="w-full">제출</Button>
    </form>
  );
}
onUpdate 에디터 변경 시 getHTML()을 onChange로 전달해 form에 저장
useForm register, setValue, watch 사용
Tailwind 스타일 EditorContent에 기본 입력 스타일 적용 (dark/light 포함)
초기화/동기화 외부 상태 변경 시 setContent로 반영

 


3단계

  • 이미지를 포함한 노트 작성 가능
  • 링크/하이라이트/언더라인 등 풍부한 마크업 지원
  • 사용자가 선택 시 나타나는 BubbleMenu, 항상 보이는 FloatingMenu로 툴바 구성

1. 필수 확장 기능 설치

npm install @tiptap/extension-image @tiptap/extension-link @tiptap/extension-placeholder @tiptap/extension-underline @tiptap/extension-highlight

2. 확장 포함한 에디터 구성 예제

// components/RichTextEditor.tsx
import {
  useEditor,
  EditorContent,
  BubbleMenu,
  FloatingMenu,
} from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import Highlight from '@tiptap/extension-highlight';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';

interface Props {
  value: string;
  onChange: (val: string) => void;
}

export const RichTextEditor = ({ value, onChange }: Props) => {
  const editor = useEditor({
    content: value,
    extensions: [
      StarterKit,
      Underline,
      Highlight,
      Link.configure({
        openOnClick: false,
      }),
      Image,
      Placeholder.configure({
        placeholder: '내용을 입력하세요...',
      }),
    ],
    editorProps: {
      attributes: {
        class:
          'min-h-[200px] w-full p-4 rounded-md border bg-white dark:bg-neutral-900 text-black dark:text-white shadow-sm focus:outline-none',
      },
    },
    onUpdate({ editor }) {
      onChange(editor.getHTML());
    },
  });

  if (!editor) return null;

  return (
    <div className="relative">
      {/* Floating Toolbar (상단 고정 툴바) */}
      <FloatingMenu
        editor={editor}
        className="flex gap-2 bg-gray-100 dark:bg-neutral-800 p-2 rounded shadow-md mb-2"
      >
        <button onClick={() => editor.chain().focus().toggleBold().run()}>
          Bold
        </button>
        <button onClick={() => editor.chain().focus().toggleItalic().run()}>
          Italic
        </button>
        <button onClick={() => editor.chain().focus().toggleUnderline().run()}>
          Underline
        </button>
        <button
          onClick={() => editor.chain().focus().toggleHighlight().run()}
        >
          Highlight
        </button>
        <button
          onClick={() => {
            const url = prompt('URL 입력:');
            if (url) {
              editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
            }
          }}
        >
          Link
        </button>
        <button
          onClick={() => {
            const url = prompt('이미지 주소 입력:');
            if (url) {
              editor.chain().focus().setImage({ src: url }).run();
            }
          }}
        >
          Image
        </button>
      </FloatingMenu>

      {/* BubbleMenu (선택 시 메뉴) */}
      <BubbleMenu
        editor={editor}
        tippyOptions={{ duration: 100 }}
        className="flex gap-2 bg-gray-200 dark:bg-neutral-700 p-2 rounded shadow"
      >
        <button onClick={() => editor.chain().focus().toggleBold().run()}>
          B
        </button>
        <button onClick={() => editor.chain().focus().toggleItalic().run()}>
          I
        </button>
        <button onClick={() => editor.chain().focus().toggleUnderline().run()}>
          U
        </button>
      </BubbleMenu>

      <EditorContent editor={editor} />
    </div>
  );
};

3. 멘션(Mention) / 해시태그(Tag) 도입 (추후 단계)

  • @tiptap/extension-mention → 사용자 목록, 태그 목록에서 선택 지원
  • @tiptap/extension-character-count → 글자수 제한 UI

이건 다음 4단계에서 따로 다루겠습니다.


4. 실용 팁

BubbleMenu 텍스트 선택 시 나타나는 툴바
FloatingMenu 항상 고정된 상단 툴바 구성
editor.commands.setImage({ src }) 이미지 삽입
editor.commands.setLink({ href }) 링크 추가
editor.commands.toggleHighlight() 형광펜 기능 (작성 강조용)

 

BubbleMenu 구현

  • 사용자가 텍스트를 마우스로 드래그 선택하면 Bold, Italic, Underline 등을 제어할 수 있는 BubbleMenu 표시

구현 예시

import { BubbleMenu } from '@tiptap/react';

<BubbleMenu
  editor={editor}
  tippyOptions={{ duration: 100 }}
  className="bg-white dark:bg-neutral-800 shadow rounded p-2 flex gap-2"
>
  <button onClick={() => editor.chain().focus().toggleBold().run()}>B</button>
  <button onClick={() => editor.chain().focus().toggleItalic().run()}>I</button>
  <button onClick={() => editor.chain().focus().toggleUnderline().run()}>U</button>
</BubbleMenu>

BubbleMenu는 textSelection이 있을 때만 자동으로 표시됩니다.


URL 자동 링크화

  • 사용자가 에디터에 https://... 같은 텍스트를 입력하면 자동으로 링크 마크를 적용

구현 방법

  1. Link 익스텐션 설정 시 autolink: true 활성화
  2. linkify-it 패키지 자동 포함됨
import Link from '@tiptap/extension-link';

Link.configure({
  autolink: true,
  openOnClick: false,
  linkOnPaste: true,
  validate: href => /^https?:\/\//.test(href),
})

linkOnPaste: true를 설정하면 URL을 붙여넣을 때 자동으로 링크로 변환됩니다.


이미지 드래그/삽입

  • 사용자가 이미지를 드래그하거나 붙여넣으면 자동으로 삽입
  • 또는 수동으로 삽입 버튼 클릭

1단계: 이미지 extension 설치

npm install @tiptap/extension-image

2단계: 에디터 확장에 포함

Image.configure({
  allowBase64: true, // 개발용 base64 이미지 허용
}),

3단계: 드래그 앤 드롭 구현

이미지 삽입은 기본 onPaste, onDrop 이벤트를 통해 지원됩니다. 하지만 아래처럼 직접 커스터마이징할 수도 있습니다:

editor.view.dom.addEventListener('drop', async (event) => {
  const file = event.dataTransfer?.files?.[0];
  if (file && file.type.startsWith('image/')) {
    const reader = new FileReader();
    reader.onload = () => {
      editor.chain().focus().setImage({ src: reader.result as string }).run();
    };
    reader.readAsDataURL(file);
  }
});

이후에는 base64 말고 실제 업로드된 URL로 삽입하도록 개선하는 것이 좋습니다 (S3, Supabase 등).


4단계

  • 커스텀 Extension, Node, Mark를 만들 수 있다.
  • Tiptap 확장 구조의 구성요소 (addOptions, addCommands, addNodeView 등)를 이해한다.
  • <mention>, <highlight> 등 커스텀 태그를 다룰 수 있다.

개념 이해

1. Extension의 핵심 구조

import { Extension } from '@tiptap/core';

export const CustomExtension = Extension.create({
  name: 'custom',

  addOptions() {
    return {
      someOption: true,
    };
  },

  addCommands() {
    return {
      setCustom:
        () =>
        ({ commands }) => {
          return commands.insertContent('<custom>hello</custom>');
        },
    };
  },

  addKeyboardShortcuts() {
    return {
      'Mod-k': () => this.editor.commands.setCustom(),
    };
  },
});

2. Node vs Mark 차이

항목 | Node | Mark

정의 위치 트리의 독립적인 블록 텍스트에 인라인으로 적용
예시 Paragraph, Image, ListItem Bold, Italic, Link
HTML 변환 <node>content</node> <span style="...">text</span>

Node는 구조(Block), Mark는 스타일(Inline)
예: <p><strong>Hello</strong></p> 에서 p는 Node, strong은 Mark


실습 예제

1. Highlight 마크 만들기

import { Mark } from '@tiptap/core';

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

  parseHTML() {
    return [{ tag: 'mark' }];
  },

  renderHTML() {
    return ['mark', 0];
  },

  addCommands() {
    return {
      toggleHighlight:
        () =>
        ({ commands }) => {
          return commands.toggleMark(this.name);
        },
    };
  },
});

사용 방법

editor.commands.toggleHighlight();

2. Mention 노드 만들기

import { Node } from '@tiptap/core';

export const Mention = Node.create({
  name: 'mention',
  group: 'inline',
  inline: true,
  atom: true,

  addAttributes() {
    return {
      id: {},
      label: {},
    };
  },

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

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

  addCommands() {
    return {
      insertMention:
        (attrs) =>
        ({ commands }) => {
          return commands.insertContent({
            type: this.name,
            attrs,
          });
        },
    };
  },
});

이 구조는 멘션 자동완성과도 연동 가능합니다 (e.g. SlashCommand, Suggestion 플러그인).


추가 기능 예시

  • SlashCommand 명령어 확장
  • CustomNodeView를 통한 React 컴포넌트와의 연결 (addNodeView)
  • addProseMirrorPlugins()로 키보드, 입력 이벤트 커스터마이징

기능 | 사용 API | 예시

옵션 정의 addOptions placeholder 등 설정
명령 정의 addCommands toggleHighlight, insertMention
마크 정의 Mark.create({...}) Bold, Italic, Underline
노드 정의 Node.create({...}) Paragraph, Mention, Image
컴포넌트 연결 addNodeView() React 기반 UI 렌더링

1. <callout> 커스텀 Node 확장

"주의", "정보", "성공" 등 다양한 스타일의 박스를 만들 수 있는 블록 노드입니다. Markdown의 :::info 같은 역할.

1-1. 목표

  • 블록 단위의 Callout 노드 구현
  • type 속성(info, warning, success)을 가짐
  • <div data-type="callout" data-callout-type="info">...</div> 형태로 HTML 출력

1-2. Callout.ts

import { Node, mergeAttributes } from '@tiptap/core';

export interface CalloutOptions {
  defaultType: 'info' | 'warning' | 'success';
}

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    callout: {
      setCallout: (type: string) => ReturnType;
    };
  }
}

export const Callout = Node.create<CalloutOptions>({
  name: 'callout',
  group: 'block',
  content: 'block+',
  defining: true,
  addOptions() {
    return {
      defaultType: 'info',
    };
  },
  addAttributes() {
    return {
      type: {
        default: this.options.defaultType,
      },
    };
  },
  parseHTML() {
    return [{ tag: 'div[data-type="callout"]' }];
  },
  renderHTML({ HTMLAttributes }) {
    return [
      'div',
      mergeAttributes(HTMLAttributes, {
        'data-type': 'callout',
        class: `callout callout-${HTMLAttributes.type}`,
      }),
      0,
    ];
  },
  addCommands() {
    return {
      setCallout:
        (type: string) =>
        ({ commands }) => {
          return commands.insertContent({
            type: this.name,
            attrs: { type },
            content: [
              {
                type: 'paragraph',
              },
            ],
          });
        },
    };
  },
});

1-3. 스타일 예시 (Tailwind)

.callout-info {
  @apply bg-blue-50 border-l-4 border-blue-400 p-4 text-blue-700;
}
.callout-warning {
  @apply bg-yellow-50 border-l-4 border-yellow-400 p-4 text-yellow-800;
}
.callout-success {
  @apply bg-green-50 border-l-4 border-green-400 p-4 text-green-700;
}

2. <mention> 태그 노드 구현 + 팝업 UI

사용자 이름 또는 해시태그 자동완성 기능

2-1. Mention.ts

import { Node, mergeAttributes } from '@tiptap/core';

export const Mention = Node.create({
  name: 'mention',
  group: 'inline',
  inline: true,
  atom: true,
  selectable: false,

  addAttributes() {
    return {
      id: { default: null },
      label: { default: null },
    };
  },

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

  renderHTML({ HTMLAttributes }) {
    return [
      'span',
      mergeAttributes(HTMLAttributes, {
        'data-mention': '',
        class: 'mention',
      }),
      `@${HTMLAttributes.label}`,
    ];
  },

  addCommands() {
    return {
      insertMention:
        (attrs) =>
        ({ commands }) => {
          return commands.insertContent({
            type: this.name,
            attrs,
          });
        },
    };
  },
});

2-2. mention 스타일

.mention {
  @apply px-1 bg-blue-100 text-blue-600 rounded;
}

3. 팝업 UI 연동 (Suggestion)

Tiptap은 @tiptap/suggestion 패키지를 사용하여 자동완성 UI를 띄울 수 있습니다.

npm install @tiptap/suggestion

 


 

5단계

Tiptap 상태의 저장 및 복원, SSR 환경 호환, 렌더링 최적화를 마스터하기


1. 상태 저장: editor.getJSON()

Tiptap은 내부 문서를 JSON 형태로 직렬화 가능:

const json = editor.getJSON();
localStorage.setItem('note', JSON.stringify(json));

저장된 JSON을 DB에 저장하거나, 로컬스토리지/IndexedDB에 사용할 수 있습니다.


2. 상태 복원: editor.commands.setContent(json)

복원 시에는 아래처럼 사용:

useEffect(() => {
  const saved = localStorage.getItem('note');
  if (saved) {
    editor?.commands.setContent(JSON.parse(saved));
  }
}, [editor]);

주의: editor.commands.setContent()는 반드시 editor가 초기화된 이후 실행해야 하므로 useEffect 또는 이벤트 핸들러에서 사용합니다.


3. 상태 초기화 및 디버깅 팁

  • editor.commands.clearContent() → 전체 초기화
  • console.log(editor.getJSON()) → 현재 상태 확인
  • editor.isFocused, editor.isEditable, editor.isEmpty → 상태 체크

필요시 onBlur, onFocus, onSelectionUpdate 이벤트를 활용해 사용자 행동을 추적 가능


4. SSR 환경 대응 (Next.js 등)

React 서버 사이드 렌더링 환경에서는 window, document 사용이 불가능하므로, 다음과 같은 전략을 사용해야 함:

4-1. dynamic()을 활용한 클라이언트 전용 로딩

// pages/editor.tsx
import dynamic from 'next/dynamic';

const TiptapEditor = dynamic(() => import('@/components/TiptapEditor'), {
  ssr: false, // 서버에서는 렌더링하지 않음
});

export default function EditorPage() {
  return <TiptapEditor />;
}

4-2. 내부에서 useEditor는 useEffect 안에서 초기화 가능

const [editor, setEditor] = useState<Editor | null>(null);

useEffect(() => {
  const editorInstance = new Editor({
    extensions: [StarterKit],
    content: '',
  });

  setEditor(editorInstance);
  return () => editorInstance.destroy();
}, []);

5. 퍼포먼스 고려

  • editable: false로 read-only 최적화 가능
  • debounce로 onUpdate 감지 줄이기 (lodash.debounce 추천)
  • FloatingMenu, BubbleMenu는 focus 이벤트 기준이므로 조건부 렌더링 필요
  • 대규모 문서: extension 수 줄이기, 이미지 등 lazy load 필요

 

5단계 실전 통합: Spring 백엔드 + Tiptap 연동

1. 저장 전략 (HTML/JSON 전송)

  • editor.getHTML() 또는 editor.getJSON() 사용
  • Spring 백엔드에서 /note/save 같은 엔드포인트 생성
  • 프론트엔드에서는 fetch 또는 React Query를 통해 저장 요청
const saveContent = async () => {
  const json = editor?.getJSON();
  await fetch('/api/note/save', {
    method: 'POST',
    body: JSON.stringify({ content: json }),
    headers: { 'Content-Type': 'application/json' },
  });
};

2. 복원 전략 (서버 or localStorage)

  • 서버에서 불러온 JSON 데이터를 editor.commands.setContent(json)으로 적용
  • SSR일 경우에는 dynamic import로 editor 생성
editor.commands.setContent(savedJson);

3. SSR 안전한 렌더링 (Next.js 기준)

  • dynamic(() => import('./Editor'), { ssr: false }) 사용
  • 내부에서 useEffect로 editor 생성 → 클라이언트 전용 처리

4. 뷰 모드와 수정 모드 분리

  • 뷰 모드: readOnly 상태로 EditorContent 렌더링
  • 수정 모드: 일반 editor 활성화
<EditorContent editor={editor} editable={!readOnly} />

5. 로딩 최적화

  • 로딩 중 Skeleton 표시
  • useQuery, useEffect 등과 조합해서 Skeleton 노출
return isLoading ? <Skeleton className="h-[300px]" /> : <EditorContent editor={editor} />;

6. onUpdate 최적화

  • editor.on('update', debounce(() => ..., 300)) 사용
useEffect(() => {
  if (!editor) return;
  const handler = debounce(() => {
    const json = editor.getJSON();
    saveToLocalStorage(json);
  }, 300);
  editor.on('update', handler);
  return () => editor.off('update', handler);
}, [editor]);