Draft.js — фреймворк для создания текстовых редакторов от Facebook

Русскоязычное руководство

Draft.js — часть огромной инфраструктуры, выстраиваемой в Facebook вокруг концепции React.js. Технологию, изначально созданную инженерами Facebook внутри компании, представили сообществу в феврале 2016 года на React Conf, исходники проекта появились на github, и к текущему моменту репозиторий собрал уже более 11 000 звёзд.

Конечно же, необходимо сразу отметить, что Draft.js — это инструмент для решения весьма узкого спектра задач, а именно — задач управления текстовым вводом и редактирования текста.

“Rich text editor framework for React” (фреймворк для создания текстовых редакторов на React), как представляют его на официальном сайте.

Здесь я хотел бы провести аналогию с другой узконишевой, но довольно известной в JavaScript сообществе технологией — D3.js. Рассчитываю, что эта аналогия позволит понять область применения Draft.js и ответит на резонно возникающий вопрос — «Зачем осваивать целый фреймворк, если можно воспользоваться массой готовых решений?». Допустим, вы хотите показать на странице некоторые данные в виде графика, и вам без труда удасться найти плагин/компонент/модуль, который «нарисует» желаемое и потребует от вас только передать ему на вход соответствующим образом отформатированные данные. Однако, если от вас требуется что-то совершенно нестандартное, если необходима полная свобода в стилизации, расширении и кастомизации результата, то, скорее всего, от готового решения вам придется отказаться и обратиться к более низкоуровневому инструменту для работы с визуализацией данных, такому, как D3.js. Сказанное выше останется абсолютно справедливым, если мы заменим график на текстовый редактор и D3.js на Draft.js.

Потенциал этой технологии чрезвычайно высок. Посмотрите примеры текстовых редакторов сделанных на Draft.js со страницы «Awesome Draft.js». Ниже я попытаюсь раскрыть и продемонстрировать основные (но далеко не все) возможности этого фреймворка. При подготовке этой публикации я использовал примеры и рецепты кода из официальной документации Draft.js, а также массы статей и блогпостов от очень активного комьюнити, образовавшегося вокруг Draft.js. Особо хотел бы выделить цикл статей, посвященных использованию этой технологии, от Brijesh Bittu и его потрясающий open-source проект — medium-draft. Я рекомендовал бы обязательно изучить исходники этого проекта, если вам интересно как может быть организована архитектура по-настоящему сложного и многофункционального редактора.

На финише у нас накопится довольно внушительная кодовая база. Поэтому я решил выделить каждую стадию в отдельную ветку в репозитории и демо-страницу на github pages, а в тексте статьи будут разобраны наиболее значимые фрагменты кода. Также я не буду останавливаться на структуре папок, config-файлах для Webpack и стилях. Если вы уже работали с React.js и Webpack, не думаю, что эти моменты вызовут у вас какие-либо вопросы. Если какие-то вопросы все же возникли — пишите в комментарии, я отвечу.

Шаг 1. Подключаем компонент редактора

Начнем с ветки starting point — это будет отправная точка. Все, что должно нас здесь интересовать — это файл src/components/DraftEditor/DraftEditor.js.

export default class DraftEditor extends Component {
constructor() {
super();

this.state = {
editorState: EditorState.createEmpty()
};

this.onChange = (editorState) => {
console.log('editorState ==>', editorState.toJS());

this.setState({ editorState });
}
}

render() {
const {
editorState
} = this.state;

return (
<div
id="editor-container"
className="c-editor-container js-editor-container"
>
<div className="editor">
<Editor
editorState={editorState}
onChange={this.onChange}
placeholder="Здесь можно печатать..."
/>
</div>
</div>
);
}
}

Здесь мы просто подключили на страницу React-компонент Editor, который пока ничего не умеет. Давайте, однако, подробнее рассмотрим приведенный выше фрагмент кода, а потом уже возьмемся за добавление более интересного функционала. У компонента, помимо не требующего объяснений свойства placeholder, объявлены свойства:

  • editorState — текущее состояние редактора.
  • onChange — функция вызывающаяся при любых манипуляциях внутри редактора, первый аргументом эта функция получает объект — изменившейся editorState.

Как вы можете видеть из кода, мы будем хранить editorState редактора в стейте родительского компонента и обновлять его в методе onChange через setState. В конструкторе класса присвоим начальное значение свойству editorState с помощью EditorState.createEmpty(). Для тех случаев, когда редактор должен появится на странице уже с предопределенным контентом, используется метод createWithContent. Также в методе onChange поставим console.log, чтобы при каждом изменении видеть текущий editorState в консоли. Здесь нужно обратить внимание, что сам editorState — это сущность Record библиотеки Immutable.js, поэтому мы переводим его в привычный JavaScript объект вызовом toJS().

Посмотрите, какой сложной структуры этот объект:

Здесь, помимо данных непосредственно о текущем контенте редактора, хранится информация о выделенном фрагменте текста (selection), полная история изменений (undoStack/redoStack) и прочая информация. За счет использования immutable структур удается хранить историю изменений оптимальным для памяти и производительности приложения образом. Попробуйте стандартные горячие клавиши (Ctrl/Cmd + Z и Ctrl/Cmd + Shift + Z) для отмены и возвращения сделанных изменений. Конечно, сейчас, когда наш редактор по своему функционалу мало чем отличается от обычной textarea, это выглядит не так эффектно, поэтому, давайте перейдем к следующему шагу.

Шаг 2. Инлайн стилизация

В этой части, рассмотрим, как с помощью Draft.js применить стилизацию к тексту. Мы повторим знакомое всем пользователям Medium поведение с появлением тулбара с вариантами действий непосредственно над выделенным участком текста.

Переключитесь в ветку inline-stylization. У нас появился новый компонент src/components/InlineToolbar и файл для хранения утилитарных функций utils/index.js. Сейчас их там две: getSelectionRange для получения данных о текущем выделении (selection):

export const getSelectionRange = () => {
const selection = window.getSelection();
if (selection.rangeCount === 0) return null;
  return selection.getRangeAt(0);
};

И getSelectionCoords для получения координат выделенного фрагмента относительно контейнера редактора:

export const getSelectionCoords = (selectionRange) => {
const editorBounds = document.getElementById('editor-container').getBoundingClientRect();
  const rangeBounds = selectionRange.getBoundingClientRect();
const rangeWidth = rangeBounds.right - rangeBounds.left;
// 107px is width of inline toolbar
const offsetLeft = (rangeBounds.left - editorBounds.left) + (rangeWidth / 2) - (107 / 2);
// 42px is height of inline toolbar
const offsetTop = rangeBounds.top - editorBounds.top - 42;
  return { offsetLeft, offsetTop };
};

Следует заметить, что эти функции даже никак не связанны с Draft.js, здесь используется стандартный браузерный Selection API. Воспользуемся ими в методе onChange компонента DraftEditor, где теперь мы не просто обновляем свойство editorState стейта компонента, но и проверяем, есть ли внутри редактора выделенный фрагмент текста (как упоминалось выше, объект editorState хранит в себе информацию в том числе и о selection). В положительном случае мы вычисляем координаты, необходимые, чтобы наш тулбар появился непосредственно над выделенным текстом и обновляем стейт компонента.

onChange(editorState) {
if (!editorState.getSelection().isCollapsed()) {
const selectionRange = getSelectionRange();
      if (!selectionRange) {
this.setState({ inlineToolbar: { show: false } });
        return;
}
      const selectionCoords = getSelectionCoords(selectionRange);
      this.setState({
inlineToolbar: {
show: true,
position: {
top: selectionCoords.offsetTop,
left: selectionCoords.offsetLeft
}
}
});
} else {
this.setState({ inlineToolbar: { show: false } });
}
    this.setState({ editorState });
}

В компоненте DraftEditor также появился новый метод:

toggleInlineStyle(inlineStyle) {
this.onChange(
RichUtils.toggleInlineStyle(
this.state.editorState,
inlineStyle
)
);
}

Здесь мы впервые воспользуемся методом из модуля RichUtils — это набор утилит для Draft.js, в дальнейшем мы ещё неоднократно к нему обратимся. Метод RichUtils.toggleInlineStyle, получая первым аргументом текущий editorState редактора, а вторым — строку с названием стиля, который должен быть применен (например 'BOLD'), вернет новый editorState, в котором уже будет храниться информация, что выделенный на момент вызова фрагмент текста стилизован соответствующим образом.

Метод toggleInlineStyle прокидываем в свойство onToggle компонента InlineToolbar:

<InlineToolbar
editorState={editorState}
onToggle={this.toggleInlineStyle}
position={inlineToolbar.position}
/>

В самом компоненте (src/components/InlineToolbar) вызываем его в функции обработчике события onMouseDown:

onMouseDown={(e) => {
e.preventDefault();
onToggle(type.style);
}}

Тулбар будет состоять из трех элементов, по клику на которые выделенный текст будет стилизован одним из следующий типов стилей: BOLD — жирное начертание, ITALIC— курсив и HIGHLIGHT — выделенный текст. И здесь необходимо заметить, что BOLD и ITALIC — это стандартные типы стилей для Draft.js. То есть, когда один из них применяется к фрагменту текста, Draft.js знает о том, что к соответствующей этому фрагменту DOM-ноде, следует применить инлайновые стили font-weight: bold и font-style: italic соответственно. А вот для того, чтобы фреймворк знал, какие css-правила необходимо применять для кастомных стилей (в нашем случае — HIGHLIGHТ), объявляем в компоненте src/components/DraftEditor объект customStyleMap:

const customStyleMap = {
HIGHLIGHT: {
backgroundColor: 'palegreen',
},
};

Формат этот объекта должен быть следующим: ключи — названия кастомного стиля, значение — соответствующие ему css-свойства. В случае, если нам понадобятся другие кастомные стили, мы просто расширим этот объект новыми свойствами. Теперь передаем этот объект в одноименное свойство компонента Editor:

<Editor
editorState={editorState}
onChange={this.onChange}
handleKeyCommand={this.handleKeyCommand}
customStyleMap={customStyleMap}
ref="editor"
/>

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

handleKeyCommand(command) {
const { editorState } = this.state;
const newState = RichUtils.handleKeyCommand(editorState, command);
    if (newState) {
this.onChange(newState);
return true;
}
    return false;
}

Попробуйте выделить текст и нажать Ctrl/Cmd + B или Ctrl/Cmd + I, текст примет жирное или курсивное начертание. И опять же, что бы нам определить кастомные горячие клавиши для стилизации либо каких то других манипуляций внутри редактора, придется проделать немного дополнительной работы. Наглядный пример реализации этого есть на официальном сайте Draft.js.

Шаг 3. Добавление ссылок — Entities и Decorators в Draft.js.

В этой части расширим наш редактор возможностью добавления ссылок и на этом примере рассмотрим две широкоиспользуемые в Draft.js концепции: Entities — особым образом аннотированные участки текста, которые могут иметь некоторые метаданные (для нашего случая это url ссылки) и систему декораторов (Draft.js Decorators).
Переключимся в ветку link-entity. Обратите внимание на то, что в тулбаре появился новый элемент для добавления ссылки.

Для простоты мы будем использовать стандартный prompt диалог. Рассмотрим метод setLink, который вызывается при клике на соответствующий элемент тулбара.

setLink() {
// получаем ссылку из prompt диалога
const urlValue = prompt('Введите ссылку', '');
    // получаем текущий editorState
const { editorState } = this.state;
    // получаем текущий contentState
const contentState = editorState.getCurrentContent();
    // создаем Entity
const contentStateWithEntity = contentState.createEntity(
'LINK',
'SEGMENTED',
{ url: urlValue }
);
    const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
    // обновляем свойство currentContent у editorState
const newEditorState = EditorState.set(editorState, {currentContent: contentStateWithEntity});


// с помощью метода toggleLink из RichUtils генерируем новый
// editorState и обновляем стейт
this.setState({
editorState: RichUtils.toggleLink(
newEditorState,
newEditorState.getSelection(),
entityKey
)
}, () => {
setTimeout(() => this.focus(), 0);
});
}

Подробнее остановимся на созданииEntity. Для создания Entity необходимо на текущем contentState вызвать метод createEntity, который принимает два обязательных аргумента и один опциональный. Давайте рассмотрим каждый из них на нашем примере:

  • LINK (строка) — собственно тип создаваемого Entity. Ниже, когда мы начнем рассматривать декораторы, мы увидим пример использования этого свойства.
  • SEGMENTED (строка) — это свойство определяет поведение текста связанного с создаваемым Entity в случае его редактирования. Возможные значения: IMMUTABLE — любое редактирование текста приведет к разрыву связи фрагмента текста с Entity и, соответственно, её удалению; MUTABLE — полная свобода редактирования текста, Entity будет удалено вместе с удалением последнего символа из ассоциированного участка текста; SEGMENTED — связь между текстом и Entity разрывается при добавлении символов к тексту, однако, при удалении, сохраняется пока не удален последний сегмент текста. Как именно Entity ведет себя в этом случае, лучше увидеть наглядно, поэтому мы используем именно SEGMENTED в нашем примере, в реальных условиях, для ссылки логично использовать тип MUTABLE.
  • { url: urlValue } (объект) — метаданные, которые будут привязаны к создаваемому Entity (опциональный аргумент).

Если бы мы, все же, дополняли наш код последовательно, то сейчас, заметили бы, что после добавления Entity визуально контент редактора никак не изменился. Чтобы в контенте редактора отрендерить ссылку желаемым образом, воспользуемся концепцией декораторов в Draft.js. Она заключается в том, что содержимое редактора сканируется, и участки текста удовлетворяющие определенному условию рендерятся с помощью отдельного React.js компонента. В нашем случае таким условием будет то, что фрагмент текста имеет ассоциированное Entity с типом LINK.

const decorator = new CompositeDecorator([
{
strategy: findLinkEntities,
component: Link
}
]);

this.state = {
inlineToolbar: { show: false },
editorState: EditorState.createEmpty(decorator)
};

Создаем экземпляр класса CompositeDecorator передавая в качестве аргумента массив объектов со свойствами strategy — функция для выборки фрагментов текста и component — компонент, с помощью которого этот фрагмент будет отрендерен, сохраняем его в переменную decorator, которую передаем в метод createEmpty. Код компонента и функции выборки приведен ниже.

function findLinkEntities(contentBlock, callback, contentState) {
contentBlock.findEntityRanges(
(character) => {
const entityKey = character.getEntity();
return (
entityKey !== null &&
contentState.getEntity(entityKey).getType() === 'LINK'
);
},
callback
);
}

const Link = (props) => {
const { url } = props.contentState
.getEntity(props.entityKey).getData();

return (
<a href={url} title={url} className="ed-link">
{props.children}
</a>
);
};

Шаг 4. Кастомный блок — слайдер

Возможность определения кастомных блоков — это, пожалуй, самая мощная фича в арсенале Draft.js. В этой части мы реализуем возможность добавления в контент редактора слайдера изображений. Причем сделать это можно будет просто перетащив несколько изображений в область редактора.

В законченном виде этот функционал сохранен в ветке custom-component демо-репозитория (попробовать как это работает).

Начнем с того, что в Draft.js есть и набор дефолтных блоков (с полным их перечнем можно ознакомиться здесь). Подобно случаю с дефолтными стилями, для их добавления в редактор не потребуется создавать отдельного компонента.

constructor() {
...
this.getEditorState = () => this.state.editorState;
this.blockRendererFn = customBlockRenderer(
this.onChange,
this.getEditorState
);
}
...
const customBlockRenderer = (setEditorState, getEditorState) => (contentBlock) => {
const type = contentBlock.getType(); <-- [1]

switch (type) {
case 'SLIDER':
return {
component: EditorSlider, // <-- [2]
props: {
getEditorState, // <-- [3]
setEditorState, // <-- [3]
}
};

default: return null;
}
};

const RenderMap = new Map({ // <-- [4]
SLIDER: {
element: 'div', // <-- [5]
}
}).merge(DefaultDraftBlockRenderMap);

В случае же с кастомным блоком нам потребуется создать отдельный react-компонент и сообщить редактору, что он должен рендерить блок определенного типа [1], (в нашем случае SLIDER) с помощью указанного компонента [2]. Для этого мы определяем функцию customBlockRenderer, получающую перым аргументом функцию для обновления editorState, а вторым — функцию для получения текущего editorState, эти же функции мы пробросим в props нашего компонента [3]. Также, определим RenderMap [4] — правила, сообщающие редактору, какой элемент должен быть оберткой для нашего компонента, в конкретном случае, это будет простой <div>, но, в зависимости от задачи, это может быть и другой react-компонент.

render() {
...
<Editor
editorState={editorState}
onChange={this.onChange}
handleKeyCommand={this.handleKeyCommand}
customStyleMap={customStyleMap}
handleDroppedFiles={this.handleDroppedFiles} // <-- [4]
handleReturn={this.handleReturn} // <-- [3]
blockRenderMap={RenderMap} // <-- [1]
blockRendererFn={this.blockRendererFn} // <-- [1]
ref="editor"
/>
...
);
}

RenderMap и метод this.blockRenderFn мы передаем в соответствующие свойства компонента Editor [1]. У компонента появилось еще два ранее не рассмотренных свойства: handleReturn и handleDroppedFiles. Первое из них [3] необходимо, что бы корректно обрабатывать нажатие пользователем кнопки Enter (переход на новую строку) для случая, когда курсор находится в пределах нашего кастомного блока. Дело в том, что если в момент нажатия Enter, курсор находится на участке текста являющимся блоком, Draft.js переводит курсор на новую строку и добавляет контейнер именно для того блока, на котором курсор находился, поэтому, чтобы у нас не вставлялся новый слайдер, мы предотвращаем такое поведение в методе this.handleReturn. В свойство handleDroppedFiles передается обработчик для события, перетаскивания файла/файлов в область редактора.

handleDroppedFiles(selection, files) {
const filteredFiles = files
.filter(file => (file.type.indexOf('image/') === 0)); // <-- [1]

if (!filteredFiles.length) {
return 'not_handled'; // <-- [2]
}

this.onChange(addNewBlockAt( // <-- [3]
this.state.editorState,
selection.getAnchorKey(),
'SLIDER',
new Map({ slides: _map(
filteredFiles,
file => ({ url: urlCreator.createObjectURL(file) }) // <-- [4]
)})
));

return 'handled';
}

Здесь мы отфильтруем только изображения из массива переданных файлов [1], и если среди этих файлов изображений нет — то мы никак не будем обрабатывать это событие [2], а в положительном случае — обновим состояние редактора с помощью метода addNewBlockAt, реализацию которого можно посмотреть в файле для хранения утилит — /src/utils/index.js.

Изучая код множества open-source проектов, использующих Draft.js, можно найти для себя массу полезных рецептов для реиспользования кода. Также, хотел бы отметить вот этот репозиторий, где подобные утилитарные методы агрегируются. Чтобы метод addNewBlockAt разобрался, какой блок необходимо добавить и куда именно, в качестве аргументов мы передаем ему: текущий стейт редактора, ключ фрагмента текста, являющегося началом текущего selection, тип блока и данные для этого блока. Данные (url слайдов) мы для упрощения создадим с помощью метода URL.createObjectURL [4], то есть это будут blob-объекты. В реальных условиях здесь должен быть метод, отправляющий файлы на сервер и получающий в ответ url файлов лежащих на сервере. На коде самого компонента слайдера /src/components/Slider/EditorSlider.js не будем останавливаться, так как он является и работает как стандартный react-компонент, в props которого Draft.js передает необходимые данные. Приведу ниже лишь код метода updateData, обновляющего данные кастомного блока и вызывающегося после выхода из режима редактирования слайдов, в котором мы можем удалить, добавить или изменить порядок слайдов:

updateData(data) {
const editorState = this.props.blockProps.getEditorState();
const content = editorState.getCurrentContent();

const selection = new SelectionState({
anchorKey: this.props.block.key,
anchorOffset: 0,
focusKey: this.props.block.key,
focusOffset: this.props.block.getLength()
});

const newContentState = Modifier.mergeBlockData(content, selection, data);
const newEditorState = EditorState.push(editorState, newContentState);

setTimeout(() => this.props.blockProps.setEditorState(newEditorState));
}

Шаг 5. Экспорт состояния редактора в html-разметку

Естественно, полноценный редактор текстов должен уметь инициализироваться с уже предопределенным контентом, чтобы можно было вернуться к редактированию ранее сохраненного черновика, и генерировать обычную html-разметку, которая будет показываться на пользовательских страницах. Именно экспорт текущего стейта редактора в html-строку мы и разберем в последней части этой публикации.

Демо-страница, ветка в репозитории — markup-export.

Что касается инициализации с предопределенным контентом — в Draft.js есть все необходимое для этого уже из коробки. Как упоминалось выше, для этого вместо EditorState.createEmpty, используется метод EditorState.createWithContent, в качестве аргументов необходимо передать объект особого формата и, опционально, декоратор. “Объект особого формата” получаем с помощью функции convertToRaw, передав ей на вход текущий ContentState редактора. Увидеть структуру этого объекта можно на demo-странице, открыв консоль и кликнув кнопку “Log state”. Код обработчика клика приведен ниже:

this.logState = () => {
console.log(
'editor state ==> ',
convertToRaw(this.state.editorState.getCurrentContent())
);
}

С генерацией же html-разметки все не так просто. Дело в том, что в Draft.js из коробки способов решения этой задачи нет. Предполагаю, что связанно это с тем, что в Draft.js допустимо определять кастомные сущности — стили, блоки. Однако, существует масса решений в виде отдельных модулей. Для нашего случая мы будем использовать draft-convert. В файле src/components/DraftEditor/converter.js определяем набор правил, в соответствии с которыми контент нашего редактора будет преобразован в html-строку:

import React from 'react';
import { convertToHTML } from 'draft-convert';

export const styleToHTML = (style) => { // <-- [1]
switch (style) {
case 'ITALIC':
return <em className="italic" />;
case 'BOLD':
return <strong className="bold" />;
case 'HIGHLIGHT':
return <strong className="highlight" />;
default:
return null;
}
};

export const blockToHTML = (block) => { // <-- [2]
const blockType = block.type;

switch (blockType) {
case 'SLIDER': {
const slides = block.data.slides; // <-- [4]

return {
start: `<div class="slider js-slider" data-slides="${ JSON.stringify(slides).replace(/"/g, "'")}"><div>`,
end: `</div></div>`
}
}

default: return null;
}
};

export const entityToHTML = (entity, text) => { // <-- [3]
if (entity.type === 'LINK') {
return (
<a
className="link"
href={entity.data.url}
target="_blank"
>
{text}
</a>
);
}
return text;
};
...

Необходимо определить три функции: styleToHTML[1], blockToHTML[2] и entityToHTML [3]— их названия говорят сами за себя — каждая из них возвращает фрагмент разметки на основании типа инлайн стилизации, блока или entity. Также прошу обратить внимание, что в функции blockToHTML, определяя разметку для блока SLIDER, мы сохранили данные ассоциированные с блоком в переменную slides[4], преобразовали в JSON-строку и записали в data-атрибут data-slides.

...
export const options = { // <-- [1]
styleToHTML,
blockToHTML,
entityToHTML,
};

const converterFunction = convertToHTML(options); // <-- [2]

export default contentState => converterFunction(contentState);

Эти функции используются в конфигурационном объекте[1], который передается в метод convertToHTML, его вызов вернет функцию [2], которая в свою очередь получает первым аргументом contentState редактора и возвращает желаемое — html-строку. В файле DraftEditor.js мы импортируем нужный нам метод и используем его в обработчике клика по кнопке “Export & log markup”:

...
import converter from './converter';
...
logMarkup() {
const markup = converter(this.state.editorState.getCurrentContent()); // <-- [1]

document.getElementById('js-markup-container').innerHTML = markup;
console.log('markup ==> ', markup); // <-- [2]

const sliders = document.querySelectorAll('.js-slider');// <-- [3]

_forEach(sliders, slider => {
const description = slider.innerHTML;

slider.innerHTML = ''; // eslint-disable-line

render(( <-- [4]
<ContentSlider
slides={JSON.parse(slider.getAttribute('data-slides').replace(/'/g, '"'))}
descriptionHtml={description}
/>
), slider);
});
}

Сохраняем строку с html в переменную markup[1], для наглядности добавляем разметку на страницу и выводим в консоль [2]. Так как мы ожидаем увидеть на странице работающий слайдер, потребуется еще немного кода. Как вы помните, определяя разметку для блока SLIDER, мы записали массив с url слайдов в data-атрибут data-slides и присвоили особый класс js-slider, теперь, найдя на страницы все DOM-ноды с этим классом [3], используя стандартный метод render из React.js, мы просто рендерим на месте данной ноды компонент ContentSlider, передавая в его свойства массив url слайдов и текст подписи под слайдером [4]. Сам компонент ContentSlider — это урезанный EditorSlider, из которого удален весь лишний функционал.


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