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