Реалізація Rich Text Editor на сайті

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.
Розробка та обслуговування будь-яких видів сайтів:
Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація Rich Text Editor на сайті
Середня
~5 робочих днів
Часті питання
Наші компетенції:
Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Реалізація 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 днів.