Создание редактора форматированного текста (WYSIWYG)

Опубликовано: 2022-03-10
Краткий обзор ↬ В этой статье мы узнаем, как создать редактор WYSIWYG/Rich-Text, который поддерживает форматированный текст, изображения, ссылки и некоторые тонкие функции приложений для обработки текста. Мы будем использовать SlateJS для создания оболочки редактора, а затем добавим панель инструментов и пользовательские конфигурации. Код приложения доступен для ознакомления на GitHub.

В последние годы в области создания и представления контента на цифровых платформах произошли серьезные изменения. Повсеместный успех таких продуктов, как Quip, Google Docs и Dropbox Paper, показал, как компании стремятся создать лучший опыт для создателей контента в корпоративной сфере и пытаются найти инновационные способы сломать традиционные шаблоны того, как контент делится и потребляется. Воспользовавшись широким охватом платформ социальных сетей, появилась новая волна независимых создателей контента, использующих такие платформы, как Medium, для создания контента и обмена им со своей аудиторией.

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

Понимание структуры документа

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

Узлы документов

Узлы документа используются для представления содержимого документа. Распространенными типами узлов, которые может содержать документ с форматированным текстом, являются абзацы, заголовки, изображения, видео, кодовые блоки и кавычки. Некоторые из них могут содержать другие узлы в качестве дочерних элементов внутри себя (например, узлы абзаца содержат внутри себя текстовые узлы). Узлы также содержат любые свойства, характерные для представляемого ими объекта, которые необходимы для отображения этих узлов в редакторе. (например, узлы изображения src свойство источника изображения, блоки кода могут содержать свойство language и т. д.).

В основном есть два типа узлов, которые представляют, как они должны отображаться:

  • Блочные узлы (аналог HTML-концепции элементов уровня блока), каждый из которых отображается на новой строке и занимает доступную ширину. Блочные узлы могут содержать внутри себя другие блочные узлы или встроенные узлы. Наблюдение здесь состоит в том, что узлы верхнего уровня документа всегда будут узлами блоков.
  • Встроенные узлы (аналог HTML-концепции встроенных элементов), которые начинают рендеринг в той же строке, что и предыдущий узел. Существуют некоторые различия в том, как встроенные элементы представлены в разных библиотеках редактирования. SlateJS позволяет встроенным элементам быть узлами. DraftJS, еще одна популярная библиотека редактирования форматированного текста, позволяет использовать концепцию сущностей для визуализации встроенных элементов. Ссылки и встроенные изображения являются примерами встроенных узлов.
  • Void Nodes — SlateJS также позволяет эту третью категорию узлов, которые мы будем использовать позже в этой статье для рендеринга мультимедиа.

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

Еще после прыжка! Продолжить чтение ниже ↓

Атрибуты

Подобно концепции атрибутов HTML, атрибуты в документе с форматированным текстом используются для представления неконтентных свойств узла или его дочерних элементов. Например, текстовый узел может иметь атрибуты стиля символа, которые сообщают нам, является ли текст полужирным/курсивным/подчеркнутым и так далее. Хотя в этой статье заголовки представлены как сами узлы, другим способом их представления может быть то, что узлы имеют стили абзаца ( paragraph и h1-h6 ) в качестве атрибутов.

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

Изображение, показывающее пример документа внутри редактора с представлением его структуры слева
Пример документа и его структурное представление. (Большой превью)

Вот некоторые из вещей, на которые стоит обратить внимание в связи со структурой:

  • Текстовые узлы представлены как {text: 'text content'}
  • Свойства узлов хранятся непосредственно на узле (например, url для ссылок и caption к изображениям).
  • Специфическое для SlateJS представление текстовых атрибутов разбивает текстовые узлы на отдельные узлы при изменении стиля символа. Следовательно, текст ' Duis aute irure dolor ' является собственным текстовым узлом с bold: true . То же самое и с курсивом, подчеркиванием и кодовым стилем текста в этом документе.

Места и выбор

При создании редактора форматированного текста крайне важно иметь представление о том, как наиболее детализированная часть документа (скажем, символ) может быть представлена ​​в виде координат. Это помогает нам перемещаться по структуре документа во время выполнения, чтобы понять, где мы находимся в иерархии документов. Что наиболее важно, объекты местоположения дают нам способ представить выбор пользователя, который довольно широко используется для адаптации пользовательского опыта редактора в реальном времени. Мы будем использовать выделение для создания нашей панели инструментов позже в этой статье. Примерами этого могут быть:

  • Курсор пользователя в настоящее время находится внутри ссылки, может быть, нам следует показать ему меню для редактирования/удаления ссылки?
  • Пользователь выбрал изображение? Может быть, мы дадим им меню для изменения размера изображения.
  • Если пользователь выбирает определенный текст и нажимает кнопку DELETE, мы определяем, какой текст был выбран пользователем, и удаляем его из документа.

Документ SlateJS по Location подробно объясняет эти структуры данных, но мы быстро рассмотрим их здесь, поскольку мы используем эти термины в разных случаях в статье и показываем пример на следующей диаграмме.

  • Дорожка
    Путь, представленный массивом чисел, — это способ добраться до узла в документе. Например, путь [2,3] представляет 3-й дочерний узел 2-го узла в документе.
  • Точка
    Более детальное расположение содержимого, представленное путем + смещение. Например, точка {path: [2,3], offset: 14} представляет 14-й символ 3-го дочернего узла внутри 2-го узла документа.
  • Спектр
    Пара точек (называемых anchor и focus ), представляющих диапазон текста внутри документа. Эта концепция исходит из веб-интерфейса Selection API, где anchor — это место, где начинается выбор пользователя, а focus — то, где он заканчивается. Свернутый диапазон/выделение обозначает, где точки привязки и фокуса совпадают (например, подумайте о мигающем курсоре при вводе текста).

В качестве примера предположим, что выбор пользователя в приведенном выше примере документа — ipsum :

Изображение с текстом `ipsum`, выделенным в редакторе
Пользователь выбирает слово ipsum . (Большой превью)

Выбор пользователя можно представить в виде:

 { anchor: {path: [2,0], offset: 5}, /*0th text node inside the paragraph node which itself is index 2 in the document*/ focus: {path: [2,0], offset: 11}, // space + 'ipsum' }`

Настройка редактора

В этом разделе мы собираемся настроить приложение и получить базовый редактор форматированного текста, работающий с SlateJS. Стандартное приложение будет create-react-app с добавленными к нему зависимостями SlateJS. Мы создаем пользовательский интерфейс приложения, используя компоненты из react-bootstrap . Давайте начнем!

Создайте папку с именем wysiwyg-editor и запустите приведенную ниже команду из каталога, чтобы настроить приложение реакции. Затем мы запускаем команду запуска yarn start , которая должна запустить локальный веб-сервер (по умолчанию порт 3000) и показать вам экран приветствия React.

 npx create-react-app . yarn start

Затем мы переходим к добавлению зависимостей SlateJS в приложение.

 yarn add slate slate-react

slate — это основной пакет SlateJS, а slate-react react включает в себя набор компонентов React, которые мы будем использовать для рендеринга редакторов Slate. SlateJS предоставляет еще несколько пакетов, организованных по функциям, которые можно было бы добавить в их редактор.

Сначала мы создаем папку utils , содержащую все служебные модули, которые мы создаем в этом приложении. Начнем с создания ExampleDocument.js , который возвращает базовую структуру документа, содержащую абзац с некоторым текстом. Этот модуль выглядит следующим образом:

 const ExampleDocument = [ { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, ], }, ]; export default ExampleDocument;

Теперь мы добавляем папку с именем components , в которой будут храниться все наши компоненты React, и делаем следующее:

  • Добавьте к нему наш первый компонент React Editor.js . На данный момент он возвращает только div .
  • Обновите компонент App.js , чтобы сохранить документ в его состоянии, которое инициализировано нашим ExampleDocument выше.
  • Визуализируйте редактор внутри приложения и передайте состояние документа и обработчик onChange в редактор, чтобы состояние нашего документа обновлялось по мере его обновления пользователем.
  • Мы также используем компоненты навигации React bootstrap, чтобы добавить панель навигации в приложение.

Компонент App.js теперь выглядит следующим образом:

 import Editor from './components/Editor'; function App() { const [document, updateDocument] = useState(ExampleDocument); return ( <> <Navbar bg="dark" variant="dark"> <Navbar.Brand href="#"> <img alt="" src="/app-icon.png" width="30" height="30" className="d-inline-block align-top" />{" "} WYSIWYG Editor </Navbar.Brand> </Navbar> <div className="App"> <Editor document={document} onChange={updateDocument} /> </div> </> );

Затем внутри компонента Editor мы создаем экземпляр редактора SlateJS и храним его внутри useMemo , чтобы объект не менялся между повторными рендерингами.

 // dependencies imported as below. import { withReact } from "slate-react"; import { createEditor } from "slate"; const editor = useMemo(() => withReact(createEditor()), []);

createEditor предоставляет нам экземпляр editor SlateJS, который мы широко используем в приложении для доступа к выборкам, выполнения преобразований данных и так далее. withReact — это плагин SlateJS, который добавляет поведение React и DOM в объект редактора. Плагины SlateJS — это функции Javascript, которые получают объект editor и прикрепляют к нему некоторую конфигурацию. Это позволяет веб-разработчикам добавлять конфигурации в свой экземпляр редактора SlateJS компонуемым способом.

Теперь мы импортируем и визуализируем компоненты <Slate /> и <Editable /> из SlateJS с помощью свойства документа, полученного из App.js. Slate предоставляет набор контекстов React, которые мы используем для доступа в коде приложения. Editable — это компонент, который отображает иерархию документов для редактирования. В целом модуль Editor.js на данном этапе выглядит следующим образом:

 import { Editable, Slate, withReact } from "slate-react"; import { createEditor } from "slate"; import { useMemo } from "react"; export default function Editor({ document, onChange }) { const editor = useMemo(() => withReact(createEditor()), []); return ( <Slate editor={editor} value={document} onChange={onChange}> <Editable /> </Slate> ); }

На данный момент у нас есть необходимые компоненты React и редактор, заполненный примером документа. Теперь наш редактор должен быть настроен, позволяя нам вводить и изменять содержимое в режиме реального времени — как показано на скринкасте ниже.

Базовая настройка редактора в действии

Теперь давайте перейдем к следующему разделу, где мы настроим редактор для отображения стилей символов и узлов абзаца.

НАСТРОЙКА ТЕКСТА И ПАНЕЛЬ ИНСТРУМЕНТОВ

Узлы стиля абзаца

В настоящее время наш редактор использует рендеринг SlateJS по умолчанию для любых новых типов узлов, которые мы можем добавить в документ. В этом разделе мы хотим иметь возможность отображать узлы заголовков. Чтобы иметь возможность сделать это, мы предоставляем поддержку функции renderElement для компонентов Slate. Эта функция вызывается Slate во время выполнения, когда он пытается пройти по дереву документа и отобразить каждый узел. Функция renderElement получает три параметра:

  • attributes
    Специфика SlateJS, которую необходимо применить к элементу DOM верхнего уровня, возвращаемому этой функцией.
  • element
    Сам объект узла, как он существует в структуре документа
  • children
    Дочерние элементы этого узла, как определено в структуре документа.

Мы добавляем нашу реализацию renderElement в хук под названием useEditorConfig , где мы будем добавлять больше конфигураций редактора по мере продвижения. Затем мы используем хук для экземпляра редактора внутри Editor.js .

 import { DefaultElement } from "slate-react"; export default function useEditorConfig(editor) { return { renderElement }; } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { case "paragraph": return <p {...attributes}>{children}</p>; case "h1": return <h1 {...attributes}>{children}</h1>; case "h2": return <h2 {...attributes}>{children}</h2>; case "h3": return <h3 {...attributes}>{children}</h3>; case "h4": return <h4 {...attributes}>{children}</h4>; default: // For the default case, we delegate to Slate's default rendering. return <DefaultElement {...props} />; } }

Поскольку эта функция дает нам доступ к element (который является самим узлом), мы можем настроить renderElement для реализации более индивидуального рендеринга, который делает больше, чем просто проверка element.type . Например, у вас может быть узел изображения со свойством isInline , которое мы могли бы использовать для возврата другой структуры DOM, которая помогает нам отображать встроенные изображения в отличие от блочных изображений.

Теперь мы обновляем компонент Editor, чтобы использовать этот хук, как показано ниже:

 const { renderElement } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} /> );

С пользовательским рендерингом мы обновляем ExampleDocument, чтобы включить наши новые типы узлов и убедиться, что они правильно отображаются в редакторе.

 const ExampleDocument = [ { type: "h1", children: [{ text: "Heading 1" }], }, { type: "h2", children: [{ text: "Heading 2" }], }, // ...more heading nodes 
Изображение, показывающее различные заголовки и узлы абзацев, отображаемые в редакторе
Узлы заголовков и абзацев в редакторе. (Большой превью)

Стили персонажей

Подобно renderElement , SlateJS выдает свойство функции, называемое renderLeaf, которое можно использовать для настройки рендеринга текстовых узлов ( Leaf относится к текстовым узлам, которые являются листьями/узлами самого низкого уровня дерева документа). По примеру renderElement пишем реализацию для renderLeaf .

 export default function useEditorConfig(editor) { return { renderElement, renderLeaf }; } // ... function renderLeaf({ attributes, children, leaf }) { let el = <>{children}</>; if (leaf.bold) { el = <strong>{el}</strong>; } if (leaf.code) { el = <code>{el}</code>; } if (leaf.italic) { el = <em>{el}</em>; } if (leaf.underline) { el = <u>{el}</u>; } return <span {...attributes}>{el}</span>; }

Важным наблюдением вышеприведенной реализации является то, что она позволяет нам соблюдать семантику HTML для стилей символов. Поскольку renderLeaf дает нам доступ к самому leaf текстового узла, мы можем настроить функцию для реализации более индивидуального рендеринга. Например, у вас может быть способ позволить пользователям выбирать highlightColor для текста и проверять это свойство листа здесь, чтобы прикрепить соответствующие стили.

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

 # src/components/Editor.js const { renderElement, renderLeaf } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} renderLeaf={renderLeaf} /> );
 # src/utils/ExampleDocument.js { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, { text: "Bold text.", bold: true, code: true }, { text: "Italic text.", italic: true }, { text: "Bold and underlined text.", bold: true, underline: true }, { text: "variableFoo", code: true }, ], }, 
Стили символов в пользовательском интерфейсе и то, как они отображаются в дереве DOM
Стили символов в пользовательском интерфейсе и то, как они отображаются в дереве DOM. (Большой превью)

Добавление панели инструментов

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

 const PARAGRAPH_STYLES = ["h1", "h2", "h3", "h4", "paragraph", "multiple"]; const CHARACTER_STYLES = ["bold", "italic", "underline", "code"]; export default function Toolbar({ selection, previousSelection }) { return ( <div className="toolbar"> {/* Dropdown for paragraph styles */} <DropdownButton className={"block-style-dropdown"} disabled={false} title={getLabelForBlockStyle("paragraph")} > {PARAGRAPH_STYLES.map((blockType) => ( <Dropdown.Item eventKey={blockType} key={blockType}> {getLabelForBlockStyle(blockType)} </Dropdown.Item> ))} </DropdownButton> {/* Buttons for character styles */} {CHARACTER_STYLES.map((style) => ( <ToolBarButton key={style} icon={<i className={`bi ${getIconForButton(style)}`} />} isActive={false} /> ))} </div> ); } function ToolBarButton(props) { const { icon, isActive, ...otherProps } = props; return ( <Button variant="outline-primary" className="toolbar-btn" active={isActive} {...otherProps} > {icon} </Button> ); }

Мы абстрагируем кнопки от компонента ToolbarButton , который является оболочкой вокруг компонента React Bootstrap Button. Затем мы визуализируем панель инструментов над компонентом « Editable внутри Editor и проверяем, отображается ли панель инструментов в приложении.

Изображение, показывающее панель инструментов с кнопками над редактором
Панель инструментов с кнопками (большой предварительный просмотр)

Вот три ключевые функции, которые должны поддерживаться панелью инструментов:

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

Давайте посмотрим, как эти функции работают в редакторе, прежде чем мы начнем их реализовывать.

Переключение стилей символов

Прослушивание выбора

Самое главное, что панель инструментов должна иметь возможность выполнять вышеуказанные функции, — это состояние выделения документа. На момент написания этой статьи SlateJS не предоставляет метод onSelectionChange , который мог бы предоставить нам последнее состояние выбора документа. Однако при изменении выделения в редакторе SlateJS вызывает метод onChange , даже если содержимое документа не изменилось. Мы используем это как способ получать уведомления об изменении выбора и сохранять его в состоянии компонента Editor . Мы абстрагируем это от хука useSelection , где мы могли бы сделать более оптимальное обновление состояния выбора. Это важно, так как выбор — это свойство, которое довольно часто изменяется для экземпляра редактора WYSIWYG.

 import areEqual from "deep-equal"; export default function useSelection(editor) { const [selection, setSelection] = useState(editor.selection); const setSelectionOptimized = useCallback( (newSelection) => { // don't update the component state if selection hasn't changed. if (areEqual(selection, newSelection)) { return; } setSelection(newSelection); }, [setSelection, selection] ); return [selection, setSelectionOptimized]; }

Мы используем этот хук внутри компонента Editor , как показано ниже, и передаем выделение компоненту панели инструментов.

 const [selection, setSelection] = useSelection(editor); const onChangeHandler = useCallback( (document) => { onChange(document); setSelection(editor.selection); }, [editor.selection, onChange, setSelection] ); return ( <Slate editor={editor} value={document} onChange={onChangeHandler}> <Toolbar selection={selection} /> ...

Оценка производительности

В приложении, где у нас есть гораздо большая кодовая база редактора с гораздо большим количеством функций, важно сохранять и прослушивать изменения выбора эффективным способом (например, с помощью некоторой библиотеки управления состоянием), поскольку компоненты, прослушивающие изменения выбора, скорее всего, тоже будут отображаться. довольно часто. Один из способов сделать это — иметь оптимизированные селекторы поверх состояния выбора, которые содержат конкретную информацию о выборе. Например, редактор может захотеть отобразить меню изменения размера изображения при выборе изображения. В таком случае может быть полезно иметь селектор isImageSelected , вычисляемый из состояния выбора редактора, и меню «Изображение» будет перерисовываться только при изменении значения этого селектора. Redux's Reselect — одна из таких библиотек, которая позволяет создавать селекторы.

Мы не используем selection внутри панели инструментов до более позднего времени, но передавая его в качестве реквизита, панель инструментов перерисовывается каждый раз, когда выделение изменяется в редакторе. Мы делаем это, потому что мы не можем полагаться исключительно на изменение содержимого документа, чтобы вызвать повторную визуализацию в иерархии ( App -> Editor -> Toolbar ), поскольку пользователи могут просто продолжать щелкать документ, тем самым изменяя выбор, но никогда не изменяя содержимое документа. сам.

Переключение стилей символов

Теперь мы переходим к получению активных стилей символов из SlateJS и использованию их в редакторе. Давайте добавим новый JS-модуль EditorUtils , в котором будут размещаться все служебные функции, которые мы создадим в будущем, чтобы получать/делать что-то с помощью SlateJS. Наша первая функция в модуле — это getActiveStyles , которая дает Set активных стилей в редакторе. Мы также добавляем функцию для переключения стиля в функцию редактора — toggleStyle :

 # src/utils/EditorUtils.js import { Editor } from "slate"; export function getActiveStyles(editor) { return new Set(Object.keys(Editor.marks(editor) ?? {})); } export function toggleStyle(editor, style) { const activeStyles = getActiveStyles(editor); if (activeStyles.has(style)) { Editor.removeMark(editor, style); } else { Editor.addMark(editor, style, true); } }

Обе функции принимают объект editor , который является экземпляром Slate, в качестве параметра, как и многие вспомогательные функции, которые мы добавим позже в статье. В терминологии Slate стили форматирования называются метками, и мы используем вспомогательные методы в интерфейсе редактора для получения, добавления и удалите эти метки. Мы импортируем эти вспомогательные функции на панель инструментов и подключаем их к кнопкам, которые мы добавили ранее.

 # src/components/Toolbar.js import { getActiveStyles, toggleStyle } from "../utils/EditorUtils"; import { useEditor } from "slate-react"; export default function Toolbar({ selection }) { const editor = useEditor(); return <div ... {CHARACTER_STYLES.map((style) => ( <ToolBarButton key={style} characterStyle={style} icon={<i className={`bi ${getIconForButton(style)}`} />} isActive={getActiveStyles(editor).has(style)} onMouseDown={(event) => { event.preventDefault(); toggleStyle(editor, style); }} /> ))} </div>

useEditor — это хук Slate, который дает нам доступ к экземпляру Slate из контекста, в котором он был прикреплен компонентом &lt;Slate> выше в иерархии рендеринга.

Может возникнуть вопрос, почему мы используем здесь onMouseDown вместо onClick ? Существует открытая проблема Github о том, как Slate превращает selection в null , когда редактор каким-либо образом теряет фокус. Таким образом, если мы прикрепим обработчики onClick к кнопкам на панели инструментов, selection станет null , и пользователи потеряют позицию курсора, пытаясь переключить стиль, что не очень удобно. Вместо этого мы переключаем стиль, присоединяя событие onMouseDown , которое предотвращает сброс выбора. Другой способ сделать это — отслеживать выбор самостоятельно, чтобы знать, какой был последний выбор, и использовать его для переключения стилей. Мы вводим концепцию previousSelection позже в статье, но для решения другой проблемы.

SlateJS позволяет нам настраивать обработчики событий в редакторе. Мы используем это для подключения сочетаний клавиш для переключения стилей символов. Для этого мы добавляем объект KeyBindings внутрь useEditorConfig , где мы предоставляем обработчик событий onKeyDown , прикрепленный к Editable компоненту. Мы используем is-hotkey для определения комбинации клавиш и переключения соответствующего стиля.

 # src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { const onKeyDown = useCallback( (event) => KeyBindings.onKeyDown(editor, event), [editor] ); return { renderElement, renderLeaf, onKeyDown }; } const KeyBindings = { onKeyDown: (editor, event) => { if (isHotkey("mod+b", event)) { toggleStyle(editor, "bold"); return; } if (isHotkey("mod+i", event)) { toggleStyle(editor, "italic"); return; } if (isHotkey("mod+c", event)) { toggleStyle(editor, "code"); return; } if (isHotkey("mod+u", event)) { toggleStyle(editor, "underline"); return; } }, }; # src/components/Editor.js ... <Editable renderElement={renderElement} renderLeaf={renderLeaf} onKeyDown={onKeyDown} /> 
Стили символов переключаются с помощью сочетаний клавиш.

Работа раскрывающегося списка стилей абзаца

Давайте перейдем к работе с раскрывающимся списком «Стили абзацев». Подобно тому, как раскрывающиеся списки в стиле абзаца работают в популярных приложениях для обработки текстов, таких как MS Word или Google Docs, мы хотим, чтобы стили блоков верхнего уровня в выборе пользователя отражались в раскрывающемся списке. Если в выборе есть один согласованный стиль, мы обновляем значение раскрывающегося списка, чтобы оно соответствовало этому. Если их несколько, мы устанавливаем раскрывающееся значение «Несколько». Это поведение должно работать как для свернутого, так и для расширенного выбора.

Чтобы реализовать это поведение, нам нужно иметь возможность находить блоки верхнего уровня, охватывающие выбор пользователя. Для этого мы используем Slate Editor.nodes — вспомогательную функцию, обычно используемую для поиска узлов в дереве, отфильтрованном по различным параметрам.

 nodes( editor: Editor, options?: { at?: Location | Span match?: NodeMatch<T> mode?: 'all' | 'highest' | 'lowest' universal?: boolean reverse?: boolean voids?: boolean } ) => Generator<NodeEntry<T>, void, undefined>

Вспомогательная функция принимает экземпляр Editor и объект options , который является способом фильтрации узлов в дереве по мере его обхода. Функция возвращает генератор NodeEntry . NodeEntry в терминологии Slate — это кортеж узла и пути к нему — [node, pathToNode] . Найденные здесь параметры доступны для большинства вспомогательных функций Slate. Давайте рассмотрим, что означает каждый из них:

  • at
    Это может быть путь/точка/диапазон, который вспомогательная функция будет использовать для определения области обхода дерева. По умолчанию это editor.selection , если он не указан. Мы также используем значение по умолчанию для нашего варианта использования ниже, поскольку нас интересуют узлы в пределах выбора пользователя.
  • match
    Это функция сопоставления, которую можно предоставить, которая вызывается на каждом узле и включается, если это совпадение. Мы используем этот параметр в нашей реализации ниже, чтобы фильтровать только блочные элементы.
  • mode
    Сообщите вспомогательным функциям, интересуются ли мы всеми узлами самого высокого или самого низкого уровня at заданной функции match местоположения. Этот параметр (установленный на highest ) помогает нам избежать попыток самостоятельно пройти по дереву вверх, чтобы найти узлы верхнего уровня.
  • universal
    Отметьте для выбора полного или частичного совпадения узлов. (Выпуск GitHub с предложением для этого флага имеет несколько примеров, объясняющих это)
  • reverse
    Если поиск узла должен быть в направлении, обратном начальной и конечной точкам переданного местоположения.
  • voids
    Если поиск должен фильтровать только пустые элементы.

SlateJS предоставляет множество вспомогательных функций, которые позволяют запрашивать узлы различными способами, перемещаться по дереву, обновлять узлы или выборки сложными способами. Стоит покопаться в некоторых из этих интерфейсов (перечисленных в конце этой статьи) при создании сложных функций редактирования поверх Slate.

С учетом этого фона вспомогательной функции ниже приведена реализация getTextBlockStyle .

 # src/utils/EditorUtils.js export function getTextBlockStyle(editor) { const selection = editor.selection; if (selection == null) { return null; } const topLevelBlockNodesInSelection = Editor.nodes(editor, { at: editor.selection, mode: "highest", match: (n) => Editor.isBlock(editor, n), }); let blockType = null; let nodeEntry = topLevelBlockNodesInSelection.next(); while (!nodeEntry.done) { const [node, _] = nodeEntry.value; if (blockType == null) { blockType = node.type; } else if (blockType !== node.type) { return "multiple"; } nodeEntry = topLevelBlockNodesInSelection.next(); } return blockType; }

Оценка производительности

Текущая реализация Editor.nodes находит все узлы по всему дереву на всех уровнях, которые находятся в пределах диапазона параметра at , а затем запускает для них фильтры соответствия (проверьте nodeEntries и фильтрацию позже — источник). Это нормально для небольших документов. Однако для нашего варианта использования, если пользователь выбрал, скажем, 3 заголовка и 2 абзаца (каждый абзац содержит, скажем, 10 текстовых узлов), он будет циклически проходить не менее 25 узлов (3 + 2 + 2 * 10) и пытаться запускать фильтры. на них. Поскольку мы уже знаем, что нас интересуют только узлы верхнего уровня, мы могли бы найти начальный и конечный индексы блоков верхнего уровня из выборки и выполнить итерацию самостоятельно. Такая логика будет перебирать только 3 записи узла (2 заголовка и 1 абзац). Код для этого будет выглядеть примерно так:

 export function getTextBlockStyle(editor) { const selection = editor.selection; if (selection == null) { return null; } // gives the forward-direction points in case the selection was // was backwards. const [start, end] = Range.edges(selection); //path[0] gives us the index of the top-level block. let startTopLevelBlockIndex = start.path[0]; const endTopLevelBlockIndex = end.path[0]; let blockType = null; while (startTopLevelBlockIndex <= endTopLevelBlockIndex) { const [node, _] = Editor.node(editor, [startTopLevelBlockIndex]); if (blockType == null) { blockType = node.type; } else if (blockType !== node.type) { return "multiple"; } startTopLevelBlockIndex++; } return blockType; }

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

Как только мы реализуем getTextBlockStyle , переключение стиля блока становится относительно простым. Если текущий стиль не соответствует тому, который пользователь выбрал в раскрывающемся списке, мы переключаем стиль на этот. Если это уже то, что выбрал пользователь, мы переключаем его на абзац. Поскольку мы представляем стили абзаца в виде узлов в структуре нашего документа, переключение стиля абзаца, по сути, означает изменение свойства type в узле. Мы используем Transforms.setNodes , предоставленный Slate, для обновления свойств узлов.

Наша toggleBlockType выглядит следующим образом:

 # src/utils/EditorUtils.js export function toggleBlockType(editor, blockType) { const currentBlockType = getTextBlockStyle(editor); const changeTo = currentBlockType === blockType ? "paragraph" : blockType; Transforms.setNodes( editor, { type: changeTo }, // Node filtering options supported here too. We use the same // we used with Editor.nodes above. { at: editor.selection, match: (n) => Editor.isBlock(editor, n) } ); }

Наконец, мы обновляем раскрывающийся список Paragraph-Style, чтобы использовать эти служебные функции.

 #src/components/Toolbar.js const onBlockTypeChange = useCallback( (targetType) => { if (targetType === "multiple") { return; } toggleBlockType(editor, targetType); }, [editor] ); const blockType = getTextBlockStyle(editor); return ( <div className="toolbar"> <DropdownButton ..... disabled={blockType == null} title={getLabelForBlockStyle(blockType ?? "paragraph")} onSelect={onBlockTypeChange} > {PARAGRAPH_STYLES.map((blockType) => ( <Dropdown.Item eventKey={blockType} key={blockType}> {getLabelForBlockStyle(blockType)} </Dropdown.Item> ))} </DropdownButton> .... ); 
Выбор нескольких типов блоков и изменение типа в раскрывающемся списке.

ССЫЛКИ

В этом разделе мы добавим поддержку для отображения, добавления, удаления и изменения ссылок. Мы также добавим функциональность Link-Detector — очень похоже на то, как Google Docs или MS Word, которые сканируют текст, набранный пользователем, и проверяют, есть ли там ссылки. Если они есть, они преобразуются в объекты ссылок, так что пользователю не нужно использовать кнопки панели инструментов, чтобы сделать это самостоятельно.

Рендеринг ссылок

В нашем редакторе мы собираемся реализовать ссылки как встроенные узлы с помощью SlateJS. Мы обновляем конфигурацию нашего редактора, чтобы помечать ссылки как встроенные узлы для SlateJS, а также предоставляем компонент для рендеринга, чтобы Slate знал, как отображать узлы ссылок.

 # src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { ... editor.isInline = (element) => ["link"].includes(element.type); return {....} } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { ... case "link": return <Link {...props} url={element.url} />; ... } }
 # src/components/Link.js export default function Link({ element, attributes, children }) { return ( <a href={element.url} {...attributes} className={"link"}> {children} </a> ); }

We then add a link node to our ExampleDocument and verify that it renders correctly (including a case for character styles inside a link) in the Editor.

 # src/utils/ExampleDocument.js { type: "paragraph", children: [ ... { text: "Some text before a link." }, { type: "link", url: "https://www.google.com", children: [ { text: "Link text" }, { text: "Bold text inside link", bold: true }, ], }, ... } 
Image showing Links rendered in the Editor and DOM tree of the editor
Links rendered in the Editor (Large preview)

Adding A Link Button To The Toolbar

Let's add a Link Button to the toolbar that enables the user to do the following:

  • Selecting some text and clicking on the button converts that text into a link
  • Having a blinking cursor (collapsed selection) and clicking the button inserts a new link there
  • If the user's selection is inside a link, clicking on the button should toggle the link — meaning convert the link back to text.

To build these functionalities, we need a way in the toolbar to know if the user's selection is inside a link node. We add a util function that traverses the levels in upward direction from the user's selection to find a link node if there is one, using Editor.above helper function from SlateJS.

 # src/utils/EditorUtils.js export function isLinkNodeAtSelection(editor, selection) { if (selection == null) { return false; } return ( Editor.above(editor, { at: selection, match: (n) => n.type === "link", }) != null ); }

Now, let's add a button to the toolbar that is in active state if the user's selection is inside a link node.

 # src/components/Toolbar.js return ( <div className="toolbar"> ... {/* Link Button */} <ToolBarButton isActive={isLinkNodeAtSelection(editor, editor.selection)} label={<i className={`bi ${getIconForButton("link")}`} />} /> </div> ); 
Link button in Toolbar becomes active if selection is inside a link.

To toggle links in the editor, we add a util function toggleLinkAtSelection . Let's first look at how the toggle works when you have some text selected. When the user selects some text and clicks on the button, we want only the selected text to become a link. What this inherently means is that we need to break the text node that contains selected text and extract the selected text into a new link node. The before and after states of these would look something like below:

Before and After node structures after a link is inserted
Before and After node structures after a link is inserted. (Большой превью)

If we had to do this by ourselves, we'd have to figure out the range of selection and create three new nodes (text, link, text) that replace the original text node. SlateJS has a helper function called Transforms.wrapNodes that does exactly this — wrap nodes at a location into a new container node. We also have a helper available for the reverse of this process — Transforms.unwrapNodes which we use to remove links from selected text and merge that text back into the text nodes around it. With that, toggleLinkAtSelection has the below implementation to insert a new link at an expanded selection.

 # src/utils/EditorUtils.js export function toggleLinkAtSelection(editor) { if (!isLinkNodeAtSelection(editor, editor.selection)) { const isSelectionCollapsed = Range.isCollapsed(editor.selection); if (isSelectionCollapsed) { Transforms.insertNodes( editor, { type: "link", url: '#', children: [{ text: 'link' }], }, { at: editor.selection } ); } else { Transforms.wrapNodes( editor, { type: "link", url: '#', children: [{ text: '' }] }, { split: true, at: editor.selection } ); } } else { Transforms.unwrapNodes(editor, { match: (n) => Element.isElement(n) && n.type === "link", }); } }

If the selection is collapsed, we insert a new node there with Transform.insertNodes that inserts the node at the given location in the document. We wire this function up with the toolbar button and should now have a way to add/remove links from the document with the help of the link button.

 # src/components/Toolbar.js <ToolBarButton ... isActive={isLinkNodeAtSelection(editor, editor.selection)} onMouseDown={() => toggleLinkAtSelection(editor)} /> 

Link Editor Menu

So far, our editor has a way to add and remove links but we don't have a way to update the URLs associated with these links. How about we extend the user experience to allow users to edit it easily with a contextual menu? To enable link editing, we will build a link-editing popover that shows up whenever the user selection is inside a link and lets them edit and apply the URL to that link node. Let's start with building an empty LinkEditor component and rendering it whenever the user selection is inside a link.

# src/components/LinkEditor.js export default function LinkEditor() { return ( <Card className={"link-editor"}> <Card.Body></Card.Body> </Card> ); }
 # src/components/Editor.js <div className="editor"> {isLinkNodeAtSelection(editor, selection) ? <LinkEditor /> : null} <Editable renderElement={renderElement} renderLeaf={renderLeaf} onKeyDown={onKeyDown} /> </div>

Поскольку мы визуализируем LinkEditor вне редактора, нам нужен способ сообщить LinkEditor где находится ссылка в дереве DOM, чтобы она могла отображать себя рядом с редактором. Мы делаем это с помощью API Slate React, чтобы найти узел DOM, соответствующий выбранному узлу ссылки. Затем мы используем getBoundingClientRect() , чтобы найти границы элемента DOM ссылки и границы компонента редактора и вычислить top и left значения для редактора ссылок. Обновления кода для Editor и LinkEditor приведены ниже:

 # src/components/Editor.js const editorRef = useRef(null) <div className="editor" ref={editorRef}> {isLinkNodeAtSelection(editor, selection) ? ( <LinkEditor editorOffsets={ editorRef.current != null ? { x: editorRef.current.getBoundingClientRect().x, y: editorRef.current.getBoundingClientRect().y, } : null } /> ) : null} <Editable renderElement={renderElement} ...
 # src/components/LinkEditor.js import { ReactEditor } from "slate-react"; export default function LinkEditor({ editorOffsets }) { const linkEditorRef = useRef(null); const [linkNode, path] = Editor.above(editor, { match: (n) => n.type === "link", }); useEffect(() => { const linkEditorEl = linkEditorRef.current; if (linkEditorEl == null) { return; } const linkDOMNode = ReactEditor.toDOMNode(editor, linkNode); const { x: nodeX, height: nodeHeight, y: nodeY, } = linkDOMNode.getBoundingClientRect(); linkEditorEl.style.display = "block"; linkEditorEl.style.top = `${nodeY + nodeHeight — editorOffsets.y}px`; linkEditorEl.style.left = `${nodeX — editorOffsets.x}px`; }, [editor, editorOffsets.x, editorOffsets.y, node]); if (editorOffsets == null) { return null; } return <Card ref={linkEditorRef} className={"link-editor"}></Card>; }

SlateJS внутренне поддерживает сопоставление узлов с соответствующими элементами DOM. Мы получаем доступ к этой карте и находим элемент DOM ссылки, используя ReactEditor.toDOMNode .

Выделение внутри ссылки показывает всплывающее окно редактора ссылок.

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

Теперь мы добавляем элемент ввода и кнопку в LinkEditor , которые позволяют пользователю вводить URL-адрес и применять его к узлу ссылки. Мы используем пакет isUrl для проверки URL.

 # src/components/LinkEditor.js import isUrl from "is-url"; export default function LinkEditor({ editorOffsets }) { const [linkURL, setLinkURL] = useState(linkNode.url); // update state if `linkNode` changes useEffect(() => { setLinkURL(linkNode.url); }, [linkNode]); const onLinkURLChange = useCallback( (event) => setLinkURL(event.target.value), [setLinkURL] ); const onApply = useCallback( (event) => { Transforms.setNodes(editor, { url: linkURL }, { at: path }); }, [editor, linkURL, path] ); return ( ... <Form.Control size="sm" type="text" value={linkURL} onChange={onLinkURLChange} /> <Button className={"link-editor-btn"} size="sm" variant="primary" disabled={!isUrl(linkURL)} onClick={onApply} > Apply </Button> ... );

С подключенными элементами формы давайте посмотрим, работает ли редактор ссылок должным образом.

Редактор теряет выбор при нажатии внутри редактора ссылок

Как мы видим здесь на видео, когда пользователь пытается нажать на ввод, редактор ссылок исчезает. Это связано с тем, что когда мы визуализируем редактор ссылок вне Editable компонента, когда пользователь щелкает элемент ввода, SlateJS считает, что редактор потерял фокус, и сбрасывает selection на null , что удаляет LinkEditor поскольку isLinkActiveAtSelection больше не true . Существует открытая проблема GitHub, в которой говорится об этом поведении Slate. Один из способов решить эту проблему — отслеживать предыдущий выбор пользователя по мере его изменения, и когда редактор теряет фокус, мы могли бы просмотреть предыдущий выбор и по-прежнему отображать меню редактора ссылок, если в предыдущем выборе была ссылка. Давайте обновим хук useSelection , чтобы он запоминал предыдущий выбор и возвращал его компоненту Editor.

 # src/hooks/useSelection.js export default function useSelection(editor) { const [selection, setSelection] = useState(editor.selection); const previousSelection = useRef(null); const setSelectionOptimized = useCallback( (newSelection) => { if (areEqual(selection, newSelection)) { return; } previousSelection.current = selection; setSelection(newSelection); }, [setSelection, selection] ); return [previousSelection.current, selection, setSelectionOptimized]; }

Затем мы обновляем логику в компоненте Editor , чтобы отображать меню ссылок, даже если в предыдущем выборе была ссылка.

 # src/components/Editor.js const [previousSelection, selection, setSelection] = useSelection(editor); let selectionForLink = null; if (isLinkNodeAtSelection(editor, selection)) { selectionForLink = selection; } else if (selection == null && isLinkNodeAtSelection(editor, previousSelection)) { selectionForLink = previousSelection; } return ( ... <div className="editor" ref={editorRef}> {selectionForLink != null ? ( <LinkEditor selectionForLink={selectionForLink} editorOffsets={..} ... );

Затем мы обновляем LinkEditor , чтобы использовать selectionForLink для поиска узла ссылки, рендеринга под ним и обновления его URL-адреса.

 # src/components/Link.js export default function LinkEditor({ editorOffsets, selectionForLink }) { ... const [node, path] = Editor.above(editor, { at: selectionForLink, match: (n) => n.type === "link", }); ... 
Редактирование ссылки с помощью компонента LinkEditor.

Обнаружение ссылок в тексте

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

Ссылки обнаруживаются по мере того, как пользователь их вводит.

Шаги логики для включения этого поведения:

  1. Поскольку документ изменяется при вводе пользователем, найдите последний символ, вставленный пользователем. Если этот символ является пробелом, мы знаем, что перед ним должно быть слово.
  2. Если последним символом был пробел, мы помечаем его как конечную границу предшествующего ему слова. Затем мы проходим назад символ за символом внутри текстового узла, чтобы найти, где это слово началось. Во время этого обхода мы должны быть осторожны, чтобы не перейти край начала узла в предыдущий узел.
  3. Как только мы нашли начальную и конечную границы слова ранее, мы проверяем строку слова и смотрим, был ли это URL-адрес. Если это так, мы конвертируем его в узел ссылки.

Наша логика находится в служебной функции identifyLinksInTextIfAny , которая находится в EditorUtils и вызывается внутри компонента onChange в Editor .

 # src/components/Editor.js const onChangeHandler = useCallback( (document) => { ... identifyLinksInTextIfAny(editor); }, [editor, onChange, setSelection] );

Вот identifyLinksInTextIfAny с реализованной логикой для шага 1:

 export function identifyLinksInTextIfAny(editor) { // if selection is not collapsed, we do not proceed with the link // detection if (editor.selection == null || !Range.isCollapsed(editor.selection)) { return; } const [node, _] = Editor.parent(editor, editor.selection); // if we are already inside a link, exit early. if (node.type === "link") { return; } const [currentNode, currentNodePath] = Editor.node(editor, editor.selection); // if we are not inside a text node, exit early. if (!Text.isText(currentNode)) { return; } let [start] = Range.edges(editor.selection); const cursorPoint = start; const startPointOfLastCharacter = Editor.before(editor, editor.selection, { unit: "character", }); const lastCharacter = Editor.string( editor, Editor.range(editor, startPointOfLastCharacter, cursorPoint) ); if(lastCharacter !== ' ') { return; }

Есть две вспомогательные функции SlateJS, которые упрощают работу.

  • Editor.before — дает нам точку перед определенным местом. Он принимает unit в качестве параметра, чтобы мы могли запросить символ/слово/блок и т. д. перед передачей location .
  • Editor.string — Получает строку внутри диапазона.

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

Диаграмма, поясняющая, куда указывают cursorPoint и startPointOfLastCharacter после шага 1, на примере
cursorPoint и startPointOfLastCharacter после шага 1 с примером текста. (Большой превью)

Если бы текст «ABCDE» был первым текстовым узлом первого абзаца в документе, наши значения баллов были бы —

 cursorPoint = { path: [0,0], offset: 5} startPointOfLastCharacter = { path: [0,0], offset: 4}

Если последним символом был пробел, мы знаем, где он начался — startPointOfLastCharacter. Давайте перейдем к шагу 2, где мы движемся назад посимвольно, пока либо не найдем другой пробел, либо начало самого текстового узла.

 ... if (lastCharacter !== " ") { return; } let end = startPointOfLastCharacter; start = Editor.before(editor, end, { unit: "character", }); const startOfTextNode = Editor.point(editor, currentNodePath, { edge: "start", }); while ( Editor.string(editor, Editor.range(editor, start, end)) !== " " && !Point.isBefore(start, startOfTextNode) ) { end = start; start = Editor.before(editor, end, { unit: "character" }); } const lastWordRange = Editor.range(editor, end, startPointOfLastCharacter); const lastWord = Editor.string(editor, lastWordRange);

Вот диаграмма, которая показывает, куда указывают эти разные точки, когда мы обнаруживаем, что последним введенным словом является ABCDE .

Диаграмма, объясняющая, где находятся разные точки после шага 2 обнаружения ссылки, с примером
Где разные точки после шага 2 определения ссылки с примером. (Большой превью)

Обратите внимание, что start и end — это точки до и после пробела. Точно так же startPointOfLastCharacter и cursorPoint — это точки до и после только что вставленного пробела. Следовательно [end,startPointOfLastCharacter] дает нам последнее вставленное слово.

Мы записываем значение lastWord в консоль и проверяем значения по мере ввода.

Консоль регистрирует проверку последнего слова, введенного пользователем после выполнения логики на шаге 2.

Теперь, когда мы определили, каким было последнее слово, введенное пользователем, мы проверяем, действительно ли это был URL-адрес, и преобразуем этот диапазон в объект ссылки. Это преобразование похоже на то, как кнопка ссылки на панели инструментов преобразует выбранный пользователем текст в ссылку.

 if (isUrl(lastWord)) { Promise.resolve().then(() => { Transforms.wrapNodes( editor, { type: "link", url: lastWord, children: [{ text: lastWord }] }, { split: true, at: lastWordRange } ); }); }

onChange identifyLinksInTextIfAny , поэтому мы не хотели бы обновлять структуру документа внутри onChange . Следовательно, мы помещаем это обновление в нашу очередь задач с помощью Promise.resolve().then(..) .

Давайте посмотрим, как логика объединится в действии! Мы проверяем, вставляем ли мы ссылки в конце, в середине или в начале текстового узла.

Ссылки обнаруживаются по мере их ввода пользователем.

На этом мы завершили работу со ссылками в редакторе и перешли к изображениям.

Обработка изображений

В этом разделе мы сосредоточимся на добавлении поддержки для рендеринга узлов изображений, добавлении новых изображений и обновлении подписей к изображениям. Изображения в нашей структуре документа будут представлены как узлы Void. Узлы Void в SlateJS (аналогично элементам Void в спецификации HTML) таковы, что их содержимое не является редактируемым текстом. Это позволяет нам визуализировать изображения как пустоты. Из-за гибкости рендеринга в Slate мы по-прежнему можем рендерить наши собственные редактируемые элементы внутри элементов Void — что мы и будем делать для редактирования подписей к изображениям. В SlateJS есть пример, который демонстрирует, как вы можете встроить весь Rich Text Editor в элемент Void.

Для рендеринга изображений мы настраиваем редактор для обработки изображений как элементов Void и предоставляем реализацию рендеринга того, как должны рендериться изображения. Мы добавляем изображение в наш ExampleDocument и проверяем, правильно ли оно отображается с заголовком.

 # src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { const { isVoid } = editor; editor.isVoid = (element) => { return ["image"].includes(element.type) || isVoid(element); }; ... } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { case "image": return <Image {...props} />; ... `` `` # src/components/Image.js function Image({ attributes, children, element }) { return ( <div contentEditable={false} {...attributes}> <div className={classNames({ "image-container": true, })} > <img src={String(element.url)} alt={element.caption} className={"image"} /> <div className={"image-caption-read-mode"}>{element.caption}</div> </div> {children} </div> ); }

Две вещи, которые следует помнить при попытке отобразить пустые узлы с помощью SlateJS:

  • Для корневого элемента DOM должно быть установлено значение contentEditable={false} , чтобы SlateJS обрабатывал его содержимое именно так. Без этого, когда вы взаимодействуете с пустым элементом, SlateJS может попытаться вычислить выборки и т. д. и в результате сломаться.
  • Даже если узлы Void не имеют дочерних узлов (например, наш узел изображения), нам все равно нужно визуализировать children узлы и предоставить пустой текстовый узел в качестве дочернего (см. ExampleDocument ниже), который обрабатывается как точка выбора узла Void. элемент SlateJS

Теперь мы обновляем ExampleDocument , чтобы добавить изображение и убедиться, что оно отображается с заголовком в редакторе.

 # src/utils/ExampleDocument.js const ExampleDocument = [ ... { type: "image", url: "/photos/puppy.jpg", caption: "Puppy", // empty text node as child for the Void element. children: [{ text: "" }], }, ]; 
Изображение, отображаемое в редакторе
Изображение, отрендеренное в редакторе. (Большой превью)

Теперь давайте сосредоточимся на редактировании титров. Мы хотим, чтобы это было беспроблемным для пользователя: когда они нажимают на заголовок, мы показываем ввод текста, где они могут редактировать заголовок. Если они щелкнут за пределами ввода или нажмут клавишу RETURN, мы рассматриваем это как подтверждение применения подписи. Затем мы обновляем заголовок узла изображения и переключаем заголовок обратно в режим чтения. Давайте посмотрим на это в действии, чтобы иметь представление о том, что мы строим.

Редактирование подписи к изображению в действии.

Давайте обновим наш компонент изображения, чтобы иметь состояние для режимов чтения и редактирования подписи. Мы обновляем состояние локального заголовка по мере того, как пользователь обновляет его, и когда он щелкает ( onBlur ) или нажимает RETURN ( onKeyDown ), мы применяем заголовок к узлу и снова переключаемся в режим чтения.

 const Image = ({ attributes, children, element }) => { const [isEditingCaption, setEditingCaption] = useState(false); const [caption, setCaption] = useState(element.caption); ... const applyCaptionChange = useCallback( (captionInput) => { const imageNodeEntry = Editor.above(editor, { match: (n) => n.type === "image", }); if (imageNodeEntry == null) { return; } if (captionInput != null) { setCaption(captionInput); } Transforms.setNodes( editor, { caption: captionInput }, { at: imageNodeEntry[1] } ); }, [editor, setCaption] ); const onCaptionChange = useCallback( (event) => { setCaption(event.target.value); }, [editor.selection, setCaption] ); const onKeyDown = useCallback( (event) => { if (!isHotkey("enter", event)) { return; } applyCaptionChange(event.target.value); setEditingCaption(false); }, [applyCaptionChange, setEditingCaption] ); const onToggleCaptionEditMode = useCallback( (event) => { const wasEditing = isEditingCaption; setEditingCaption(!isEditingCaption); wasEditing && applyCaptionChange(caption); }, [editor.selection, isEditingCaption, applyCaptionChange, caption] ); return ( ... {isEditingCaption ? ( <Form.Control autoFocus={true} className={"image-caption-input"} size="sm" type="text" defaultValue={element.caption} onKeyDown={onKeyDown} onChange={onCaptionChange} onBlur={onToggleCaptionEditMode} /> ) : ( <div className={"image-caption-read-mode"} onClick={onToggleCaptionEditMode} > {caption} </div> )} </div> ...

На этом функция редактирования подписи завершена. Теперь мы переходим к добавлению пользователям способа загрузки изображений в редактор. Давайте добавим кнопку на панель инструментов, которая позволяет пользователям выбирать и загружать изображение.

 # src/components/Toolbar.js const onImageSelected = useImageUploadHandler(editor, previousSelection); return ( <div className="toolbar"> .... <ToolBarButton isActive={false} as={"label"} htmlFor="image-upload" label={ <> <i className={`bi ${getIconForButton("image")}`} /> <input type="file" className="image-upload-input" accept="image/png, image/jpeg" onChange={onImageSelected} /> </> } /> </div>

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

Прежде чем мы реализуем useImageUploadHandler , мы настроим сервер, на который можно будет загрузить изображение. Мы настраиваем сервер Express и устанавливаем два других пакета — cors и multer , которые обрабатывают для нас загрузку файлов.

 yarn add express cors multer

Затем мы добавляем скрипт src/server.js , который настраивает сервер Express с помощью cors и multer и предоставляет конечную точку /upload , в которую мы будем загружать изображение.

 # src/server.js const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, "./public/photos/"); }, filename: function (req, file, cb) { cb(null, file.originalname); }, }); var upload = multer({ storage: storage }).single("photo"); app.post("/upload", function (req, res) { upload(req, res, function (err) { if (err instanceof multer.MulterError) { return res.status(500).json(err); } else if (err) { return res.status(500).json(err); } return res.status(200).send(req.file); }); }); app.use(cors()); app.listen(port, () => console.log(`Listening on port ${port}`));

Теперь, когда у нас есть настройка сервера, мы можем сосредоточиться на обработке загрузки изображений. Когда пользователь загружает изображение, может пройти несколько секунд, прежде чем изображение будет загружено, и у нас есть URL-адрес для него. Однако мы делаем то, что дает пользователю немедленную обратную связь о том, что идет загрузка изображения, чтобы он знал, что изображение вставляется в редактор. Вот шаги, которые мы реализуем, чтобы заставить это поведение работать:

  1. Как только пользователь выбирает изображение, мы вставляем узел изображения в позицию курсора пользователя с установленным на нем флагом isUploading , чтобы мы могли показать пользователю состояние загрузки.
  2. Отправляем запрос на сервер для загрузки изображения.
  3. Как только запрос выполнен и у нас есть URL-адрес изображения, мы устанавливаем его для изображения и удаляем состояние загрузки.

Давайте начнем с первого шага, где мы вставляем узел изображения. Теперь сложная часть здесь заключается в том, что мы сталкиваемся с той же проблемой с выбором, что и с кнопкой ссылки на панели инструментов. Как только пользователь нажимает кнопку «Изображение» на панели инструментов, редактор теряет фокус и выделение становится null . Если мы попытаемся вставить изображение, мы не будем знать, где был курсор пользователя. Отслеживание previousSelection выбора дает нам это местоположение, и мы используем его для вставки узла.

 # src/hooks/useImageUploadHandler.js import { v4 as uuidv4 } from "uuid"; export default function useImageUploadHandler(editor, previousSelection) { return useCallback( (event) => { event.preventDefault(); const files = event.target.files; if (files.length === 0) { return; } const file = files[0]; const fileName = file.name; const formData = new FormData(); formData.append("photo", file); const id = uuidv4(); Transforms.insertNodes( editor, { id, type: "image", caption: fileName, url: null, isUploading: true, children: [{ text: "" }], }, { at: previousSelection, select: true } ); }, [editor, previousSelection] ); }

Когда мы вставляем новый узел изображения, мы также присваиваем ему идентификатор id , используя пакет uuid. Мы обсудим в реализации шага (3), зачем нам это нужно. Теперь мы обновляем компонент изображения, чтобы использовать флаг isUploading для отображения состояния загрузки.

 {!element.isUploading && element.url != null ? ( <img src={element.url} alt={caption} className={"image"} /> ) : ( <div className={"image-upload-placeholder"}> <Spinner animation="border" variant="dark" /> </div> )}

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

Загрузка изображения, создающая узел изображения с состоянием загрузки.

Переходя к шагу (2), мы будем использовать библиотеку axois для отправки запроса на сервер.

 export default function useImageUploadHandler(editor, previousSelection) { return useCallback((event) => { .... Transforms.insertNodes( … {at: previousSelection, select: true} ); axios .post("/upload", formData, { headers: { "content-type": "multipart/form-data", }, }) .then((response) => { // update the image node. }) .catch((error) => { // Fire another Transform.setNodes to set an upload failed state on the image }); }, [...]); }

Мы проверяем, что загрузка изображения работает, и изображение отображается в папке public/photos приложения. Теперь, когда загрузка изображения завершена, мы переходим к шагу (3), где мы хотим установить URL-адрес изображения в функции resolve() обещания axios. Мы могли бы обновить изображение с помощью Transforms.setNodes , но у нас есть проблема — у нас нет пути к только что вставленному узлу изображения. Давайте посмотрим, какие у нас есть варианты, чтобы добраться до этого изображения —

  • Разве мы не можем использовать editor.selection , так как выбор должен быть на только что вставленном узле изображения? Мы не можем этого гарантировать, поскольку во время загрузки изображения пользователь мог щелкнуть где-то еще, и выбор мог измениться.
  • Как насчет использования previousSelection , который мы использовали для вставки узла изображения в первую очередь? По той же причине, по которой мы не можем использовать editor.selection , мы не можем использовать и previousSelection , так как он тоже мог измениться.
  • В SlateJS есть модуль History, который отслеживает все изменения, происходящие с документом. Мы могли бы использовать этот модуль для поиска в истории и поиска последнего вставленного узла изображения. Это также не совсем надежно, если для загрузки изображения потребовалось больше времени, и пользователь вставил больше изображений в разные части документа до завершения первой загрузки.
  • В настоящее время API Transform.insertNodes не возвращает никакой информации о вставленных узлах. Если бы он мог вернуть пути к вставленным узлам, мы могли бы использовать это, чтобы найти точный узел изображения, который мы должны обновить.

Поскольку ни один из вышеперечисленных подходов не работает, мы применяем id к вставленному узлу изображения (на шаге (1)) и снова используем тот же id , чтобы найти его после завершения загрузки изображения. При этом наш код для шага (3) выглядит следующим образом:

 axios .post("/upload", formData, { headers: { "content-type": "multipart/form-data", }, }) .then((response) => { const newImageEntry = Editor.nodes(editor, { match: (n) => n.id === id, }); if (newImageEntry == null) { return; } Transforms.setNodes( editor, { isUploading: false, url: `/photos/${fileName}` }, { at: newImageEntry[1] } ); }) .catch((error) => { // Fire another Transform.setNodes to set an upload failure state // on the image. });

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

Загрузка изображений работает от начала до конца

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

Заключение

В этой статье мы создали редактор WYSIWYG, который имеет базовый набор функций и некоторые микро-пользовательские возможности, такие как обнаружение ссылок, редактирование ссылок на месте и редактирование подписей к изображениям, которые помогли нам глубже изучить SlateJS и концепции редактирования форматированного текста в Общая. Если это проблемное пространство, связанное с редактированием форматированного текста или обработкой текста, вас интересует, некоторые из интересных проблем, которые можно решить, могут быть следующими:

  • Сотрудничество
  • Расширенные возможности редактирования текста, поддерживающие выравнивание текста, встроенные изображения, копирование и вставку, изменение шрифта и цвета текста и т. д.
  • Импорт из популярных форматов, таких как документы Word и Markdown.

Если вы хотите узнать больше о SlateJS, вот несколько полезных ссылок.

  • Примеры SlateJS
    Множество примеров, выходящих за рамки основ и создающих функциональные возможности, которые обычно можно найти в редакторах, таких как «Поиск и выделение», «Предварительный просмотр уценки» и «Упоминания».
  • Документация по API
    Ссылка на множество вспомогательных функций, предоставляемых SlateJS, которые, возможно, захочется держать под рукой при попытке выполнить сложные запросы/преобразования на объектах SlateJS.

Наконец, Slack Channel SlateJS — это очень активное сообщество веб-разработчиков, создающих приложения для редактирования форматированного текста с использованием SlateJS, и отличное место, где можно узнать больше о библиотеке и получить помощь, если это необходимо.