Files
2nd/10_Wiki/Topics/Coding/React_Editor_Slate_Lexical.md
T
2026-05-09 21:08:02 +09:00

6.3 KiB

id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
id title category status source_trust_level verification_status created_at updated_at tags tech_stack applied_in aliases
react-editor-slate-lexical Rich Text Editor — Slate / Lexical / TipTap Coding draft B conceptual 2026-05-09 2026-05-09
react
editor
slate
lexical
tiptap
vibe-coding
language applicable_to
TS / React
Frontend
Slate
Lexical
TipTap
ProseMirror
rich text
contenteditable
WYSIWYG

Rich Text Editor

TipTap (ProseMirror) = production safe, 큰 ecosystem. Lexical (Meta) = modern, 강력 collab. Slate = 유연 + 학습 곡선. contenteditable 직접은 lock-in / bug 지옥.

📖 핵심 개념

  • ProseMirror: 커리어 standard. Schema 기반 + 변경 immutable.
  • Slate: React friendly, 유연.
  • Lexical: 새, headless + framework-agnostic.
  • contenteditable: HTML native, 그러나 cross-browser 어려움.

💻 코드 패턴

TipTap (ProseMirror wrapper)

import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';

function Editor() {
  const editor = useEditor({
    extensions: [
      StarterKit,
      Image,
      Link.configure({ openOnClick: false }),
    ],
    content: '<p>Hello</p>',
    onUpdate: ({ editor }) => console.log(editor.getJSON()),
  });

  return (
    <div>
      <Toolbar editor={editor} />
      <EditorContent editor={editor} className="prose" />
    </div>
  );
}

function Toolbar({ editor }: ...) {
  if (!editor) return null;
  return (
    <div>
      <button onClick={() => editor.chain().focus().toggleBold().run()}
              className={editor.isActive('bold') ? 'active' : ''}>B</button>
      <button onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
    </div>
  );
}

Custom extension (TipTap)

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

const Highlight = Mark.create({
  name: 'highlight',
  parseHTML() { return [{ tag: 'mark' }]; },
  renderHTML({ HTMLAttributes }) { return ['mark', mergeAttributes(HTMLAttributes), 0]; },
  addCommands() {
    return {
      toggleHighlight: () => ({ commands }) => commands.toggleMark(this.name),
    };
  },
});

Lexical

import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
import { ListNode, ListItemNode } from '@lexical/list';
import { HeadingNode } from '@lexical/rich-text';

const config = {
  namespace: 'Editor',
  nodes: [HeadingNode, ListNode, ListItemNode],
  onError: (e: Error) => console.error(e),
};

<LexicalComposer initialConfig={config}>
  <RichTextPlugin
    contentEditable={<ContentEditable />}
    placeholder={<p>Write...</p>}
    ErrorBoundary={LexicalErrorBoundary}
  />
  <HistoryPlugin />
  <ListPlugin />
</LexicalComposer>

Slate

import { createEditor, Descendant, Editor, Transforms } from 'slate';
import { Slate, Editable, withReact } from 'slate-react';

const initial: Descendant[] = [{ type: 'paragraph', children: [{ text: 'Hello' }] }];

function MyEditor() {
  const [editor] = useState(() => withReact(createEditor()));
  const [value, setValue] = useState(initial);

  return (
    <Slate editor={editor} initialValue={initial} onChange={setValue}>
      <Editable
        renderElement={({ element, attributes, children }) => {
          if (element.type === 'heading') return <h2 {...attributes}>{children}</h2>;
          return <p {...attributes}>{children}</p>;
        }}
        onKeyDown={(e) => {
          if (e.key === 'b' && (e.ctrlKey || e.metaKey)) {
            e.preventDefault();
            Editor.addMark(editor, 'bold', true);
          }
        }}
      />
    </Slate>
  );
}

Save / Load (JSON)

// TipTap
const json = editor.getJSON();
await api.saveDoc({ content: json });
editor.commands.setContent(json);

// Lexical
editor.update(() => {
  const json = editor.getEditorState().toJSON();
});

editor.setEditorState(editor.parseEditorState(savedJson));

Collaborative (yjs)

import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { TiptapCollabProvider } from '@hocuspocus/provider';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';

const ydoc = new Y.Doc();
const provider = new WebsocketProvider('ws://localhost:1234', 'doc-1', ydoc);

const editor = useEditor({
  extensions: [
    StarterKit.configure({ history: false }),
    Collaboration.configure({ document: ydoc }),
    CollaborationCursor.configure({ provider, user: { name: 'Alice', color: '#ff0' } }),
  ],
});

Markdown 입력 / 출력

// TipTap markdown
import { Markdown } from 'tiptap-markdown';
extensions: [StarterKit, Markdown]

editor.storage.markdown.getMarkdown(); // text
editor.commands.setContent('# Hello'); // accepts markdown

XSS 방지

// 입력 / 출력 — schema 안전
// 사용자 HTML 직접 setContent X — JSON / sanitized HTML 만
import DOMPurify from 'dompurify';
const safe = DOMPurify.sanitize(userHtml);
editor.commands.setContent(safe);

🤔 의사결정 기준

상황 추천
일반 + 큰 ecosystem TipTap
Modern + Meta + collab Lexical
매우 custom Slate
단순 markdown react-markdown / remark
Code editor Monaco / CodeMirror 6
Notion-like Tiptap + 자체 block extensions

안티패턴

  • contenteditable 직접: 브라우저 차이 + 버그.
  • HTML diff 직접 변경: editor 가 mutation observer.
  • Save 매 keystroke: throttle / debounce.
  • Markdown 양방향 perfect 가정: lossy. JSON 가 정.
  • 사용자 HTML 그대로 setContent: XSS. sanitize / schema 만.
  • Collab 없는데 yjs 통합: overhead. 단순 save.
  • Plugin 전부 Always 로딩: bundle. 필요 시 lazy.

🤖 LLM 활용 힌트

  • 시작 = TipTap (StarterKit 한 줄).
  • 큰 / collab = Lexical + yjs.
  • Save = JSON (setContent / parseEditorState).

🔗 관련 문서