Реалізація Rich Text Editor на сайті
Rich Text Editor для адміністративного контенту — це інша задача в порівнянні з редактором користувача. Тут немає проблеми XSS від недовіреного HTML, зате є інші вимоги: підтримка складних вкладених структур, кастомні блоки, робота з медіа, версіонування, локалізація.
Архітектурний вибір формату зберігання
Перше рішення — в якому форматі зберігати контент. Від цього залежить все остальне.
HTML-рядок — простіший старт, складніші трансформації. Не можна легко витягти чистий текст, підрахувати слова, зробити diff.
JSON (Slate/Lexical/ProseMirror) — структуроване дерево вузлів. Легко трансформувати, рендерити в різні формати, робити diff для версіонування.
Portable Text (Sanity) — JSON-формат, орієнтований на portability між рендерерами.
Для нового проекту краще JSON. Для інтеграції в наявний Laravel з TinyMCE — HTML з ретельно описаною схемою дозволених тегів.
Реалізація на Lexical (Meta)
Lexical — нативний для React, підтримує concurrent mode, добре розширюється:
npm install lexical @lexical/react @lexical/rich-text @lexical/selection @lexical/utils @lexical/html
// components/RichTextEditor/index.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 { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'
import { ListPlugin } from '@lexical/react/LexicalListPlugin'
import { TabIndentationPlugin } from '@lexical/react/LexicalTabIndentationPlugin'
import { HeadingNode, QuoteNode } from '@lexical/rich-text'
import { ListItemNode, ListNode } from '@lexical/list'
import { LinkNode, AutoLinkNode } from '@lexical/link'
import { CodeHighlightNode, CodeNode } from '@lexical/code'
import { ImageNode } from './nodes/ImageNode'
import { ToolbarPlugin } from './plugins/ToolbarPlugin'
import { ImagesPlugin } from './plugins/ImagesPlugin'
import { OnChangePlugin } from './plugins/OnChangePlugin'
const editorConfig = {
namespace: 'RichTextEditor',
nodes: [
HeadingNode, QuoteNode,
ListNode, ListItemNode,
LinkNode, AutoLinkNode,
CodeNode, CodeHighlightNode,
ImageNode,
],
onError: (error: Error) => console.error(error),
theme: {
heading: {
h1: 'text-3xl font-bold mb-4',
h2: 'text-2xl font-semibold mb-3',
h3: 'text-xl font-medium mb-2',
},
text: {
bold: 'font-bold',
italic: 'italic',
underline: 'underline',
strikethrough: 'line-through',
code: 'font-mono bg-gray-100 px-1 rounded text-sm',
},
link: 'text-blue-600 underline cursor-pointer',
list: {
ul: 'list-disc list-inside mb-4',
ol: 'list-decimal list-inside mb-4',
listitem: 'mb-1',
},
quote: 'border-l-4 border-gray-300 pl-4 italic text-gray-600 my-4',
},
}
interface RichTextEditorProps {
initialState?: string // serialized Lexical state JSON
onChange: (state: string, html: string) => void
}
export function RichTextEditor({ initialState, onChange }: RichTextEditorProps) {
return (
<LexicalComposer initialConfig={{
...editorConfig,
editorState: initialState,
}}>
<div className="border rounded-lg overflow-hidden">
<ToolbarPlugin />
<div className="relative">
<RichTextPlugin
contentEditable={
<ContentEditable className="min-h-[300px] p-4 outline-none prose max-w-none" />
}
placeholder={
<div className="absolute top-4 left-4 text-gray-400 pointer-events-none">
Почніть вводити текст...
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
</div>
</div>
<HistoryPlugin />
<AutoFocusPlugin />
<ListPlugin />
<LinkPlugin />
<TabIndentationPlugin />
<ImagesPlugin />
<OnChangePlugin onChange={onChange} />
</LexicalComposer>
)
}
Кастомний вузол для зображень
// nodes/ImageNode.tsx
import { DecoratorNode, LexicalNode, NodeKey } from 'lexical'
export class ImageNode extends DecoratorNode<React.ReactElement> {
__src: string
__alt: string
__width: number | 'inherit'
__height: number | 'inherit'
static getType(): string { return 'image' }
static clone(node: ImageNode): ImageNode {
return new ImageNode(node.__src, node.__alt, node.__width, node.__height, node.__key)
}
constructor(src: string, alt: string, width?: number | 'inherit', height?: number | 'inherit', key?: NodeKey) {
super(key)
this.__src = src
this.__alt = alt
this.__width = width ?? 'inherit'
this.__height = height ?? 'inherit'
}
createDOM(): HTMLElement {
const div = document.createElement('div')
div.className = 'editor-image'
return div
}
updateDOM(): false { return false }
exportJSON() {
return {
type: 'image',
src: this.__src,
alt: this.__alt,
width: this.__width,
height: this.__height,
version: 1,
}
}
static importJSON(data: any): ImageNode {
return new ImageNode(data.src, data.alt, data.width, data.height)
}
decorate(): React.ReactElement {
return (
<ImageComponent
src={this.__src}
alt={this.__alt}
width={this.__width}
height={this.__height}
nodeKey={this.getKey()}
/>
)
}
}
Збереження та завантаження стану
// plugins/OnChangePlugin.tsx
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $generateHtmlFromNodes } from '@lexical/html'
import { useEffect } from 'react'
export function OnChangePlugin({ onChange }: { onChange: (state: string, html: string) => void }) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const stateJSON = JSON.stringify(editorState.toJSON())
const html = $generateHtmlFromNodes(editor)
onChange(stateJSON, html)
})
})
}, [editor, onChange])
return null
}
В базі даних зберігаємо JSON (editor_state) для редактування та HTML (content_html) для рендеринга без завантаження редактора.
Інтеграція з React Hook Form
// forms/PostForm.tsx
import { Controller, useForm } from 'react-hook-form'
import { RichTextEditor } from '@/components/RichTextEditor'
export function PostForm({ post }: { post?: Post }) {
const { control, handleSubmit } = useForm({
defaultValues: {
title: post?.title ?? '',
editorState: post?.editorState ?? null,
},
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="editorState"
control={control}
rules={{ required: 'Контент обов\'язковий' }}
render={({ field, fieldState }) => (
<div>
<RichTextEditor
initialState={field.value}
onChange={(state, html) => {
field.onChange(state)
// html зберігаємо в окремому полі якщо потрібно
}}
/>
{fieldState.error && (
<p className="text-red-500 text-sm mt-1">{fieldState.error.message}</p>
)}
</div>
)}
/>
</form>
)
}
Рендеринг на фронтенді без редактора
// components/ContentRenderer.tsx
import { createHeadlessEditor } from '@lexical/headless'
import { $generateHtmlFromNodes } from '@lexical/html'
// Server-side: рендер з Lexical JSON в HTML
export async function renderLexicalToHtml(stateJson: string): Promise<string> {
return new Promise((resolve, reject) => {
const editor = createHeadlessEditor({ nodes: [/* всі вузли */] })
const state = editor.parseEditorState(stateJson)
editor.setEditorState(state)
editor.read(() => {
resolve($generateHtmlFromNodes(editor))
})
})
}
// Client-side: для статичного HTML з БД
export function ContentRenderer({ html }: { html: string }) {
return (
<div
className="prose lg:prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
Toolbar з групами кнопок
// plugins/ToolbarPlugin.tsx — фрагмент
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { FORMAT_TEXT_COMMAND, FORMAT_ELEMENT_COMMAND } from 'lexical'
import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from '@lexical/list'
import { $createHeadingNode } from '@lexical/rich-text'
import { $setBlocksType } from '@lexical/selection'
import { $getSelection, $isRangeSelection } from 'lexical'
export function ToolbarPlugin() {
const [editor] = useLexicalComposerContext()
const [activeFormats, setActiveFormats] = useState<Set<string>>(new Set())
// Підписка на зміни виділення
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const formats = new Set<string>()
if (selection.hasFormat('bold')) formats.add('bold')
if (selection.hasFormat('italic')) formats.add('italic')
setActiveFormats(formats)
}
})
})
}, [editor])
return (
<div className="flex flex-wrap gap-1 p-2 border-b bg-gray-50">
{/* Заголовки */}
<select onChange={e => {
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createHeadingNode(e.target.value as any))
}
})
}}>
<option value="h2">H2</option>
<option value="h3">H3</option>
</select>
{/* Форматування тексту */}
{['bold', 'italic', 'underline'].map(fmt => (
<button
key={fmt}
className={`px-2 py-1 rounded ${activeFormats.has(fmt) ? 'bg-blue-100' : ''}`}
onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, fmt as any)}
>
{fmt[0].toUpperCase()}
</button>
))}
</div>
)
}
Терміни
Базовий редактор (Lexical/TipTap) з збереженням JSON + HTML, інтеграція в форму: 3–4 дні. Кастомні вузли (зображення, таблиці, embed), повний toolbar, версіонування: 8–12 днів.







