--- id: react-editor-slate-lexical title: Rich Text Editor — Slate / Lexical / TipTap category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [react, editor, slate, lexical, tiptap, vibe-coding] tech_stack: { language: "TS / React", applicable_to: ["Frontend"] } applied_in: [] aliases: [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) ```tsx 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: '

Hello

', onUpdate: ({ editor }) => console.log(editor.getJSON()), }); return (
); } function Toolbar({ editor }: ...) { if (!editor) return null; return (
); } ``` ### Custom extension (TipTap) ```ts 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 ```tsx 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), }; } placeholder={

Write...

} ErrorBoundary={LexicalErrorBoundary} />
``` ### Slate ```tsx 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 ( { if (element.type === 'heading') return

{children}

; return

{children}

; }} onKeyDown={(e) => { if (e.key === 'b' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); Editor.addMark(editor, 'bold', true); } }} />
); } ``` ### Save / Load (JSON) ```ts // 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) ```tsx 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 입력 / 출력 ```ts // TipTap markdown import { Markdown } from 'tiptap-markdown'; extensions: [StarterKit, Markdown] editor.storage.markdown.getMarkdown(); // text editor.commands.setContent('# Hello'); // accepts markdown ``` ### XSS 방지 ```ts // 입력 / 출력 — 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). ## 🔗 관련 문서 - [[Security_Output_Encoding_XSS]] - [[React_Component_Composition]] - [[Frontend_A11y_Testing]]