Привет, Хабр!

В этой статье речь пойдет об интересной задаче на одном из моих проектов. Он был разработан на React для документооборота сотрудниками. Так уж вышло, что со времен старта проекта основным текстовым WYSIWYG-редактором был небезызвестный Jodit. За долгие годы было написано много кастомных плагинов, например, для работы с упоминаниями сотрудников, и нас устраивала его надежность, хоть его внешний вид был далек от идеала.

И вот однажды заказчик пришел с запросом:

  • редактор должен выглядеть иначе,

  • во всей системе нужен единый набор стилей,

  • фиксированная палитра цветов для текста и фона,

  • ограниченный набор элементов (заголовки, подзаголовки, обычный текст, списки),

  • отдельные стили для ссылок,

  • другой полноэкранный режим,

  • возможность добавлять сноски и так далее

Мы встали перед выбором:

  1. переписать все на новом редакторе (и потерять массу времени на плагины и баги),

  2. или доработать Jodit под новые требования.

Если вы читаете этот текст, значит мы выбрали второй путь. ?

Идея реализации

Когда мы приступили к реализации задачи, нужно было решить, как в панели управления Jodit разместить наши кастомные кнопки и получить доступ к самому редактору. Изучая документацию Jodit, можно увидеть возможность добавить в конфиги кастомные кнопки, в массив buttons. Для замены иконок при использовании стандартных механизмов Jodit мы применили подход из самой документации.

{
  name: 'bold',
  iconURL: boldIcon,
  tooltip: 'Жирный',
},

В таких кейсах сам Jodit понимает, как ему работать с этой кнопкой по заданному name, и делает свою «магию» без нашего участия.

Этот подход помог переопределить иконки у панели управления для тех инструментов, для которых этого было достаточно с точки зрения требований заказчика и дизайн-макета. Но не все так скучно. Другие механизмы и требования были сложнее, а для их реализации нужно было отрисовать отдельный компонент в панели управления. Подход был похожим. Сначала добавляем в массив кнопок объект только с name, который не использует Jodit для своих инструментов.

{
  name: EditorCustomTools.Color,
},

А чтобы отрисовать здесь свой компонент, после инициализации редактора ищем в DOM наши кастомные кнопки и рендерим элементы, благо в настройках Jodit можно определить в объекте events событие afterInit. Все кастомные компоненты мы храним в паре «ключ и значение», где ключ — name, который мы передаем в кнопки, а значение — элемент, который нужно отрисовать

afterInit: (editor: Jodit) => 
{
  Object.entries(CUSTOM_TOOLS).forEach(([key, element]) => {
    const target = editor.toolbarContainer.querySelector(`[ref=${ref}]`);
      
    if (!target) {
      return null;
    }
      
    target.innerHTML = '';
    const root = createRoot(target);
    return root.render(createElement(component, { editor }));
  });
},

Так мы рендерим внутри DOM-элемента наш компонент и передаем через props объект редактора, для удобного управления его инструментами.

Этот подход развязал нам руки и позволил реализовывать требования заказчика в полном объеме.

Так мы вставили свой Popover, при выборе значения которого меняли HTML-элемент в редакторе через editor.execCommand('formatBlock', false, имя_блока).

Добавили свои color-picker компоненты с ограниченным количеством цветов для текста и фона, редактируя эти свойства через методы editor.execCommand('foreColor', false, цвет_текста) и editor.execCommand('backColor', false, цвет_фона) соответственно.

И реализовывали более сложные компоненты для вставки ссылок и сносок в тексте. Используя такое решение, кастомизация Jodit ограничивается только вашей фантазией, вы можете реализовывать любые компоненты для управления контентом.

Ограничение стилей и поддержка единой дизайн-системы

Вторым не менее интересным испытанием, помимо добавления своих инструментов, было требование ограничивать пользователей определенным набором стилей и компонентов. Но как этого добиться, если пользователи любят копировать из различных источников и вставлять текст в редактор? Обычно любой WYSIWYG-редактор просто применяет стили из источника.

Нас это не устраивало, поэтому мы приняли решение перед вставкой текста парсить его и оставлять только те цвета и элементы, что мы поддерживаем, а также удалять все опасные HTML-теги для улучшения безопасности веб-приложения.

Для этого мы так же добавляем в объект events функцию для события paste

paste: (e: ClipboardEvent) => 
{
  e.preventDefault();
  const text = e.clipboardData?.getData('text/html') || e.clipboardData?.getData('text/plain');

  if (!text) return;

  const parsed = parserHTML(text);
  ref.current?.selection.insertHTML(parsed);
},

Тут мы отключаем стандартное поведение Jodit, получаем из буфера обмена текст и прогоняем его через функцию parserHTML, которая как раз и занимается «очисткой» вставляемого текста. Что же она из себя представляет?

export const parserHTML = (html?: string | null) => {
  if (!html) {
    return '';
  }
  
  const cleanedHtml = html.replaceAll(/<!--.*?-->/g, '');
  const parser = new DOMParser();
  const { body } = parser.parseFromString(cleanedHtml, 'text/html');
  const { childNodes } = body;

  const parse = (childNodes) => {
   return Array.from(childNodes).map((node) => {
    if (isEmpty(node.childNodes) && node.nodeValue) {
      return prepareText(node);
    }

    const htmlElement = node as HTMLElement;

    switch (node.nodeName) {
      case 'H1':
      case 'H2':
      case 'H3': {
        return `<h2>${parse(node.childNodes)}</h2>`;
      }
      default: {
        return null;
      }
    }); 
  }

  parse(childNodes);
};

Изначально мы инициализируем класс DOMParser(), парсим из строки DOM и получаем ноды из body. А после проходим по нодам циклом и переопределяем вставляемые теги на те, что мы поддерживаем. На примере видно, как мы все заголовки h1-h3 делаем заголовками h2, а также вырезаем все стили. Однако в нашем случае мы еще использовали функцию, которая проходит по стилям и проверяет, входят ли они в выбранную палитру и поддерживаемые свойства (жирность, курсив) или нет. Так, проходясь по нашему дереву, мы доходим до текста, с которым тоже можем сделать все что угодно, например, вырезать неподдерживаемые элементы. Для этой задачи достаточно было просто проверить, что она не пустая.

Также мы использовали похожий парсер, чтобы отрисовывать контент для пользовательского интерфейса, только наоборот: из строки, которая приходила с бэкенда, мы рисовали React-компоненты из нашей дизайн-системы, а все, что могло прийти, но мы не поддерживали, — вырезали. Это улучшило общую безопасность и стабильность веб-приложения.

Такой подход полезно использовать в веб-приложениях, если вы получаете с бэкенда строку с HTML, а вам необходимо безопасно отрисовать контент с помощью компонентов вашей дизайн-системы.

Комментарии (0)