← /blog # Editor 2025.09.30 13 min

TipTap에 12개의 커스텀 확장을 얹기

Admin CMS 에디터에서 필요한 커스텀 노드 / 마크 / 명령어들. ProseMirror 모델을 이해하는 데 도움된 멘탈 모델 정리.

ProseMirror의 스키마를 이해하면 에디터 확장은 레고 블록이 된다. 스키마를 모르면 모든 커스텀이 해킹이 된다.

왜 커스텀 에디터가 필요했나

어웨어랩 Admin CMS에서 투자 분석 콘텐츠를 작성하는 에디터가 필요했다. 일반적인 리치 텍스트(볼드, 이탤릭, 이미지)만으로는 부족했다. 종목 차트 삽입, 유료 콘텐츠 구간 설정(Paywall), 슬래시 명령어 같은 투자 플랫폼 특화 기능이 필요했다.

Directus CMS의 기본 에디터는 이런 커스텀 블록을 지원하지 않았다. TipTap을 선택한 이유는 ProseMirror 기반이면서도 확장 시스템이 깔끔하기 때문이다. 결과적으로 12개의 커스텀 확장을 개발했다.

ProseMirror 멘탈 모델

TipTap 확장을 만들기 전에, ProseMirror의 문서 모델을 이해해야 한다. ProseMirror 문서는 HTML과 비슷하지만 다른 트리 구조다.

  • Document — 최상위 노드, 전체 문서를 감싼다
  • Node — 블록 레벨 요소 (paragraph, heading, image, custom block)
  • Mark — 인라인 스타일 (bold, italic, link, custom highlight)
  • Schema — 어떤 Node와 Mark가 허용되는지 정의하는 규칙
text document-tree.txt
Document
├─ Heading (level: 2)
│  └─ "Market Analysis"
├─ Paragraph
│  ├─ "The S&P 500 showed "
│  ├─ Bold["strong momentum"]
│  └─ " this quarter."
├─ StockNode (ticker: "AAPL")     ← Custom Node
├─ PaywallNode                      ← Custom Node
│  ├─ Paragraph
│  └─ Paragraph
└─ Paragraph

Schema가 "이 문서에서 무엇이 가능한가"를 정의한다. 커스텀 Node를 Schema에 등록하면, ProseMirror는 그 Node를 문서의 일급 시민으로 취급한다 — 복사/붙여넣기, 실행 취소/다시 실행, 협업 편집까지 자동으로 지원된다.

12개 확장 지도

4개 카테고리로 분류한 12개 확장:

  • 블록 노드 (4개) — StockNode, PaywallNode, ImageBlock (업로드+리사이즈), EmbedBlock
  • 인라인 마크 (3개) — HighlightMark, SubscriptMark, FootnoteMark
  • 명령어 (2개) — SlashCommand (/ 메뉴), TableOfContents (자동 목차)
  • 유틸리티 (3개) — DirtyTracking, AutoSave, CharacterCount

SlashCommand — / 입력으로 블록 삽입

Notion 스타일의 슬래시 명령어다. /를 입력하면 드롭다운이 나타나고, 블록 타입을 선택하면 해당 노드가 삽입된다. ProseMirror의 Plugin 시스템을 사용했다.

typescript extensions/SlashCommand.ts
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import Suggestion from '@tiptap/suggestion';

export const SlashCommand = Extension.create({
  name: 'slashCommand',

  addOptions() {
    return {
      suggestion: {
        char: '/',
        command: ({ editor, range, props }: any) => {
          editor.chain().focus().deleteRange(range).run();
          props.command({ editor });
        },
        items: ({ query }: { query: string }) => {
          return [
            { title: 'Heading 1', command: ({ editor }: any) =>
              editor.chain().toggleHeading({ level: 1 }).run() },
            { title: 'Stock Chart', command: ({ editor }: any) =>
              editor.chain().insertContent({ type: 'stockNode' }).run() },
            { title: 'Paywall', command: ({ editor }: any) =>
              editor.chain().insertContent({ type: 'paywallNode' }).run() },
          ].filter(item =>
            item.title.toLowerCase().includes(query.toLowerCase())
          );
        },
      },
    };
  },

  addProseMirrorPlugins() {
    return [Suggestion({ ...this.options.suggestion, editor: this.editor })];
  },
});

Paywall Node — 유료 구간 경계

에디터에서 "여기부터 유료"라는 경계를 시각적으로 표시하는 커스텀 Node다. 이 노드 이후의 콘텐츠는 멤버십 사용자에게만 표시된다.

typescript extensions/PaywallNode.ts
import { Node, mergeAttributes } from '@tiptap/core';

export const PaywallNode = Node.create({
  name: 'paywallNode',
  group: 'block',
  content: 'block+',
  defining: true,

  parseHTML() {
    return [{ tag: 'div[data-paywall]' }];
  },

  renderHTML({ HTMLAttributes }) {
    return [
      'div',
      mergeAttributes(HTMLAttributes, { 'data-paywall': '' }),
      0,  // 0 = content hole (children go here)
    ];
  },

  addNodeView() {
    return ({ node, getPos }) => {
      const dom = document.createElement('div');
      dom.classList.add('paywall-boundary');
      dom.setAttribute('data-label', 'Premium Content');
      const contentDOM = document.createElement('div');
      dom.appendChild(contentDOM);
      return { dom, contentDOM };
    };
  },
});

content: 'block+'는 이 노드 안에 하나 이상의 블록 노드를 넣을 수 있다는 뜻이다. 0은 ProseMirror에서 "콘텐츠 구멍(content hole)"을 의미한다 — 자식 노드가 들어갈 자리다.

Stock Node — 종목 차트 삽입

종목 티커를 입력하면 해당 종목의 차트와 기본 정보가 에디터에 임베딩되는 커스텀 Node.

typescript extensions/StockNode.ts
import { Node, mergeAttributes } from '@tiptap/core';

export const StockNode = Node.create({
  name: 'stockNode',
  group: 'block',
  atom: true,  // This node has no editable content

  addAttributes() {
    return {
      ticker: { default: '' },
      exchange: { default: 'NYSE' },
      period: { default: '1Y' },
    };
  },

  parseHTML() {
    return [{ tag: 'div[data-stock]' }];
  },

  renderHTML({ HTMLAttributes }) {
    return ['div', mergeAttributes(HTMLAttributes, {
      'data-stock': '',
      'data-ticker': HTMLAttributes.ticker,
    })];
  },
});

atom: true는 이 노드가 편집 불가능한 단위라는 뜻이다. 사용자는 노드를 선택/삭제/이동할 수 있지만, 노드 안의 텍스트를 직접 편집하지는 못한다. 차트 렌더링은 별도 React 컴포넌트가 담당한다.

Dirty Tracking + Validation

에디터 상태의 변경 감지와 유효성 검증을 Zustand 스토어에서 처리했다. 초기 상태를 스냅샷으로 저장하고, 현재 상태와 비교하여 저장 필요 여부를 판단한다.

typescript stores/editorStore.ts
interface EditorStore {
  title: string;
  slug: string;
  category: string;
  content: JSONContent | null;
  initialSnapshot: string | null;

  isDirty: () => boolean;
  getValidation: () => { valid: boolean; missing: string[] };
  getUpdatePayload: () => UpdatePayload | null;
}

const useEditorStore = create<EditorStore>()(immer((set, get) => ({
  // ... state

  isDirty: () => {
    const { initialSnapshot, title, slug, content } = get();
    const current = JSON.stringify({ title, slug, content });
    return current !== initialSnapshot;
  },

  getValidation: () => {
    const { title, slug, category } = get();
    const missing: string[] = [];
    if (!title.trim()) missing.push('title');
    if (!slug.trim()) missing.push('slug');
    if (!category) missing.push('category');
    return { valid: missing.length === 0, missing };
  },

  getUpdatePayload: () => {
    const state = get();
    if (!state.isDirty()) return null;
    const { valid } = state.getValidation();
    if (!valid) return null;
    return { title: state.title, slug: state.slug, content: state.content };
  },
})));

isDirty()는 초기 스냅샷과 현재 상태를 비교한다. getValidation()은 누락 필드 목록을 반환한다. getUpdatePayload()는 dirty이면서 유효한 경우에만 페이로드를 생성한다. UI는 이 getter들만 소비하면 된다.

확장의 조합 가능성

12개 확장을 독립 모듈로 만든 가장 큰 이점은 조합 가능성이다. 에디터 설정 파일에서 배열로 확장을 추가하거나 제거하면 된다. "이 에디터에는 Paywall이 필요 없다" → 배열에서 빼면 끝이다. 확장 간 의존성을 최소화했기 때문에 가능한 구조다.

  • ProseMirror의 Document/Node/Mark/Schema 모델을 먼저 이해하라 — 커스텀 확장의 기초다
  • atom: true는 편집 불가능한 임베드 노드, content: "block+"는 편집 가능한 컨테이너 노드
  • 각 확장을 독립 모듈로 만들면 조합과 제거가 설정 변경만으로 가능하다
  • Dirty tracking은 스냅샷 비교로 구현하고, validation과 payload 생성은 getter로 분리하라
  • 슬래시 명령어는 TipTap의 Suggestion 플러그인으로 깔끔하게 구현할 수 있다