[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
---
|
||||
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: '<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)
|
||||
```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),
|
||||
};
|
||||
|
||||
<LexicalComposer initialConfig={config}>
|
||||
<RichTextPlugin
|
||||
contentEditable={<ContentEditable />}
|
||||
placeholder={<p>Write...</p>}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<HistoryPlugin />
|
||||
<ListPlugin />
|
||||
</LexicalComposer>
|
||||
```
|
||||
|
||||
### 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 (
|
||||
<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)
|
||||
```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]]
|
||||
Reference in New Issue
Block a user