Как мне взбрело в голову свой Notion-like редактор написать
Введение
Мне в голову пришла идея пет-проекта, который изначально никак не был связан с текстовым редактором. Однако, в процессе работы все дошло до того, что пользователям нужно где-то набирать текст. Я люблю Notion и пишу там много и часто, поэтому решил сделать похожий (но сильно упрощенный) редактор в своём проекте. Не столько из нужды, сколько из любопытства, ведь я никогда не занимался ничем подобным и мало что знал о том, как писать текстовые редакторы.
В статье хочу рассказать про атрибут contenteditable
у HTML-элементов, про сопутствующие проблемы при его использовании, про кастомное форматирование и про работу с выделенными участками текста.
Дисклеймер
Цель статьи — не демонстрация готового продукта или best practices для его реализации. Некоторые проблемы могут показаться глупыми, но как и сказано выше, это мой первый опыт работы с contenteditable
и текстовыми редакторами. Приведенные примеры могут быть неэлегантными, неоптимальными и содержать множество допущений. Они нужны чтобы наглядно показать возможности, которые могут помочь добиться результата.
Глава 1. Один за всех и все в одном
Для начала расскажу, что вообще за contenteditable.
HTML-атрибут Для начала накидаем HTML. В браузере это выглядит как три редактируемых строки текста. Для удобства я буду звать contenteditable дивы «блоками». Для пользователя всё должно работать, как единое пространство для редактирования. Сейчас блоки никак между собой не связаны. Я не могу переключаться между строками с помощью клавиатуры (↑ и ↓). Первое, что приходит в голову — повесить обработчик на событие Определение конкретной строки в случае многострочного текста, так как перемещение должно происходить только с первой и последней строки. Сохранение позиции каретки на том же месте, а для этого нужно понимать размер текущей строки и строки, на которую будет перемещаться каретка. Определение наличия элемента, на который пользователь хочет переключиться. Это лишь часть проблем, которые пришлось бы решать при ручной реализации переключения между блоками. На самом деле, работа с кареткой — довольно сложная задача, а идеальное повторение её поведения и вовсе может привести к безумию. Заниматься всем этим мне, конечно же, не хотелось, поэтому я снова пошел копаться в Notion. Заметил, что там все блоки обернуты в Выглядит это примерно так: Умно, подумал я. Такое решение позволяет объединить все блоки, так как теперь они сами часть редактируемого элемента. Это решает почти все упомянутые проблемы. Но добавляет новые… Браузер обрабатывает события в редактируемых элементах особым образом. Поскольку содержимое внутри элементов На помощь приходит делегирование событий. Можно подписаться на Прежде, чем я продолжу, давайте вспомним о событиях Мы не сможем получить доступ к изменяемому элементу через Метод возвращает объект C его помощью мы можем получить информацию о положении и содержимом выделения. Что может быть полезным: Если Вот пример, для наглядности. Проблема решена. Внутри обработчика события Кроме того, событие Самым большим камнем преткновения стало форматирование текста. С одной стороны, нативное форматирование отлично работает без какого-либо вмешательства, с другой стороны: на этом его плюсы заканчиваются. Основные минусы: Его сложно контролировать и мы не сможем держать его в рамках конкретных блоков; Потенциально большая вложенность тегов, что усложняет структуру документа и последующую работу с ней. Notion не использует нативное форматирование, вместо этого выделенный текст оборачивается в единственный Предполагаю, что и при экспорте в разные форматы, и при совместном редактировании это играет определенную роль. В итоге я решил сделать то же самое. Напомню, как примерно выглядит наш DOM. Допустим, я захотел сделать текст в первом блоке жирным. Первым делом надо как-то получить выделенный текст. Сделать это можно через интерфейс для работы с выделением. Range — интерфейс, предоставляющий фрагмент документа, который может содержать узлы и части текстовых узлов данного документа. Мы будем использовать его для работы с выделениями. Самое простое решение — метод Готово, теперь, структура документа выглядит так: Если выделенный контент нужно предварительно как-то обработать, можно воспользоваться другими методами Что за фрагмент? Такими нехитрыми способами можно легко получить выделенный текст и работать с ним. Усложним структуру документа и добавим много разного форматирования. В браузере он бы выглядел так: Немного поговорим о содержании, с точки зрения узлов. Внутри блока узлы могут быть двух типов: Я выделю часть первого блока и нажму Здесь сложность возрастает, в этот раз мы работаем уже с двумя типами узлов. Допишем нашу функцию, чтобы она работала с двумя типами узлов: То же самое нужно проделать для каждого стиля. В нашем случае, структура первого блока теперь выглядит так: Внутри можно заметить 3 А теперь поговорим о выделении, так как в зависимости от используемых технологий, оно может сильно усложнить жизнь на этом этапе. Например, если использовать ванильный JavaScript, то после проделанных манипуляций выделение адаптируется к изменениям и останется на своем месте, а вот если бы мы делали то же самое, например, в React, выделение бы исчезло. Это связано с тем, как React управляет DOM. При любой перерисовке компонента выделение будет пропадать. Хорошая новость в том, что выделение можно вернуть. Ну что ж, значит будем сохранять данные о выделении, а потом восстанавливать его. Правда, тут тоже есть нюансы: мы превратили несколько элементов в один, следовательно, структура DOM изменилась, а вот объект Попробуем создать новый Отлично, теперь мы можем использовать функцию Готово! Теперь мы можем восстановить выделение независимо от того, как поменялась структура DOM. Расскажу подробнее о выделении. В функции Когда мы используем С другими типами узлов, такими как элементы, Вернёмся пока к такой структуре: Пользователь выделяет кусок текста: и нажимает Определить, какие блоки были выделены; Определить, какие блоки были выделены частично Определить, нужно ли добавить форматирование или убрать (если оно уже есть); Применить/удалить форматирование к выделенной части. Сохранить выделение после всех манипуляций. С чего начать? Использовать Получить фрагмент через Если подумать, все задачи мы уже умеем решать в рамках работы с одним блоком, поэтому я предлагаю поступить именно так. Первый и последний блок мы можем легко получить с помощью Каждый блок будем обрабатывать отдельно: создавать для него На самом деле, нам нужно только два объекта А теперь вернём выделение. Готово, выделение для нескольких блоков восстановлено. Есть ещё много интересных сценариев работы с текстом, о которых я тут не рассказал. Например, удаление блоков, когда выделено сразу несколько. Это можно сделать с помощью В конце концов у меня получилось создать что-то отдаленно похожее на Notion. Для меня это был интересный опыт. Как я говорил в самом начале: я никогда раньше не работал с текстом так плотно. Этот опыт позволил мне погрузиться в детали работы с Наверняка многие подумали (и не ошиблись), что подход с ручным изменением DOM может быть не самым удобным и эффективным. Это действительно так. Всё-таки DOM — это довольно низкоуровневое API, не предназначенное для работы с высокоуровневыми абстракциями, такими как блоки текста. Вместо того, чтобы манипулировать DOM напрямую, мы можем работать с собственной моделью данных, которая представляет собой дерево блоков, и затем рендерить эту модель в DOM. Это позволит легко обрабатывать такие сложные операции, как выделение текста, применение к нему форматирования, и многие другие, а также отделит логику работы с данными от логики рендеринга, оптимизирует и сильно упростит код. Вероятно, в Notion так и делают и именно это станет моим следующим шагом. Спасибо, что дочитали мою первую статью до конца! contenteditable
позволяет сделать элемент на веб-странице редактируемым. Применяя его к элементу, например keydown
и вручную переносить каретку на блок выше или ниже. Однако, такой подход ломает нативное поведение каретки, и его реализация потребовала бы изрядных усилий для решения следующих задач: contenteditable
элемент.Глава 2. Слышу звон событий, да не знаю чьих
contenteditable
динамически изменяется пользователем, браузер, как я понял, самостоятельно управляет процессами ввода текста, удаления и форматирования у дочерних элементов, не позволяя обрабатывать эти процессы через JavaScript.content-editable-root
, но вот незадача, event.target
всегда ссылается на этот элемент, а не на потомков, которые вызывают событие.beforeinput
и input
. Именно они срабатываю перед и после изменения значения таких элементов как: input, textarea и любых элементов с атрибутом contenteditable
. Через эти события мы и будем работать с конкретными элементами. В большей степени нас интересует beforeinput
.event.target
, но выход есть, и это — Window.getSelection ().Selection
, который представляет собой диапазон текста, выделенный пользователем, или текущее положение каретки.Selection.anchorNode
— возвращает узел DOM, в котором начинается выделение.Selection.anchorOffset
— возвращает смещение (индекс) внутри anchorNode
, где начинается выделение.Selection.focusNode
— возвращает узел DOM, в котором заканчивается выделение.Selection.focusOffset
— возвращает смещение (индекс) внутри focusNode
, где заканчивается выделение.Selection.isCollapsed
— возвращает true
, если выделение свернуто (начало и конец выделения совпадают), и false
, если выделение имеет длину.isCollapsed
равно true
, то anchorNode
и focusNode
будут указывать на один и тот же узел, а anchorOffset
и focusOffset
будут равны. В этом случае каретка находится внутри этого узла на позиции, указанной anchorOffset
.function getCaretPosition() {
const selection = window.getSelection();
if (selection.isCollapsed) {
// Каретка находится в этом узле
const caretNode = selection.anchorNode;
// А это позиция каретки внутри узла
const caretOffset = selection.anchorOffset;
...
// Тут можно, например, через caretNode.parentNode добраться до блока, в котором лежит этот узел.
} else {
// Выделение начинается в этом узле
const startContainer = selection.anchorNode;
// А заканчивается в этом узле
const endConteiner = selection.focusOffset;
...
}
}
beforeinput
, мы можем определить, какой элемент изменяется.beforeinput
предоставляет доступ к свойству inputType
, которое указывает тип ввода: insertText
, insertLineBreak
, insertParagraph
, deleteContentBackward
и многие другие. Что сильно упрощает классификацию действий пользователя. Полный список можно увидеть на W3C, всего их несколько десятков.Глава 3. Укрощение строптивого форматирования
span
, к которому затем добавляются разные стили. Если форматирование применяются к части уже отформатированного текста, он разделяются на несколько span
элементов. Внутри блока может быть либо TEXT_NODE
, либо ELEMENT_NODE
в виде span
, внутри которого будут только TEXT_NODE
.Глава 3.1. Форматируем простой текст
Range.surroundContents()
. Он оборачивает содержимое диапозона в новый элемент. Достаточно сделать что-то вроде: function makeSelectedTextBold() {
const range = window.getSelection().getRangeAt(0);
const span = document.createElement('span');
span.classList.add('bold');
range.surroundContents(span);
}
Range.extractContents()
или Range.cloneContents()
. Первый извлекает выделенный контент из активного DOM, второй просто копирует. Оба возвращают [DocumentFramgent](
с выделенным контентом.DocumentFramgent
представляет из себя минимальный объект документа, который не является часть активного дерева. Манипуляции с ним никак не отразятся в активном DOM. Это удобный и эффективный способ работать с временными узлами.function makeSelectedTextBold() {
const range = window.getSelection().getRangeAt(0);
/* Извлекаем контент, теперь его нет в активном DOM */
const fragment = range.extractContents();
/* Мы можем, например, пройтись по дочерним элементам фрагмента
и провалидировать их. */
formatFragment(fragment);
/* Затем так же создаём span */
const span = document.createElement('span');
span.classList.add('bold');
/* Запихиваем туда наш фрагмент */
span.appendChild(fragment);
/* И добавляем в Range */
range.insertNode(span);
}
Глава 3.2. Форматируем сложный текст
TEXT_NODE
, либо ELEMENT_NODE
с TEXT_NODE
внутри. Таким образом мы поддерживаем плоскую структуру дерева в блоке.ctrl
+ b
, чтобы сделать весь текст жирным.TEXT_NODE
нужно обернуть в span.bold
, а для уже существующего span
нужно добавить класс, если его нет (или убрать, если ко всему выделенному тексту уже применено выбранное форматирование).function makeSelectedTextBold() {
const range = window.getSelection().getRangeAt(0);
/* Извлекаем контент, теперь его нет в активном DOM */
const fragment = range.extractContents();
/* Проверяем, все ли дочерние элементы являются span-элементами
и имеют ли они класс bold */
const shouldRemoveFormatting = Array.from(fragment.childNodes)
.every(child => (
child instanceof HTMLSpanElement &&
child.classList.contains('bold')
));
/* Если все элементы уже bold, отменяем форматирование */
if (shouldRemoveFormatting) {
for (const child of fragment.childNodes) {
child.classList.remove('bold');
/* На самом деле тут ещё нужно проверять, осталось ли у элемента какое-то форматирование? Если нет, то его нужно превратить в обычный текстовый узел. */
}
} else {
/* А если нет, то пройдемся по дочерним элементам фрагмента
и обработаем их */
for (const child of fragment.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
const span = document.createElement('span');
span.classList.add('bold');
span.appendChild(child.cloneNode());
/* Заменяем текстовый узел на только что созданный span */
fragment.replaceChild(span, child);
}
if (child.nodeType === Node.ELEMENT_NODE) {
if (!child.classList.contain('bold')) {
child.classList.add('bold');
}
}
}
}
range.insertNode(fragment);
}
span
с одинаковым форматированием. Плодить узлы мы не хотим, поэтому их надо объединить в один. Сделать это довольно просто: проходимся по дочерним элементам блока, объединяем все смежные блоки с одинаковыми классами. Итог должен быть таким: Range
имеет начальную позицию (якорь) и конечную позицию (фокус). Начальная позиция представлена свойствами startContainer
и startOffset
, а конечная — свойствами endContainer
и endOffset
. Эти свойства указывают на узлы (чаще всего текстовые, но не всегда) DOM и смещения внутри этих узлов, где начинается и заканчивается выделение.Глава 3.3. Сохраняем выделение
Range
остался старым.Range
, пройтись по дочерним элементам измененного блока, затем найти начало и конец выделения в новой структуре используя Range.startOffset
и Range.endOffset
? Да, но… нет. Вернее, не все так просто. startOffset
и endOffset
указывают на смещение относительно startContainer
и endContainer
, а не родительского узла. Это значит, что нам заранее стоит посчитать смещения именно для блока. Напишем для этого несколько функций./* Получает индексы выделения внутри узла */
function getSelectionOffsets(blockNode) {
// Получаем объект выделения из глобального объекта `window`
const selection = window.getSelection();
// Получаем первый выделенный диапазон
const range = selection.getRangeAt(0);
// Получаем начальный узел выделения
const startNode = range.startContainer;
// Получаем конечный узел выделения
const endNode = range.endContainer;
// Вычисляем смещение начала выделения относительно родительского узла
const startOffset = getNodeOffset(blockNode, startNode, range.startOffset);
// Вычисляем смещение конца выделения относительно родительского узла
const endOffset = getNodeOffset(blockNode, endNode, range.endOffset);
// Возвращаем объект с начальным и конечным смещениями выделения
return { startOffset, endOffset };
}
/* Возвращает смещение node внутри blockNode */
function getNodeOffset(blockNode, node, offset) {
// Инициализируем переменную для текущего узла, начиная с первого дочернего узла родительского узла
let currentNode = blockNode.firstChild;
// Инициализируем переменную для текущего смещения
let currentOffset = 0;
// Проходим по всем дочерним узлам родительского узла
while (currentNode !== null) {
// Если текущий узел совпадает с искомым узлом или является его родительским узлом,
// возвращаем текущее смещение плюс смещение внутри искомого узла
if (currentNode === node || currentNode === node.parentNode) {
return currentOffset + offset;
}
// Если текущий узел является текстовым узлом, увеличиваем текущее смещение на длину его текстового содержимого
if (currentNode.nodeType === Node.TEXT_NODE) {
currentOffset += currentNode.textContent.length || 0;
}
// Если текущий узел является элементом, увеличиваем текущее смещение на длину текстового содержимого всех его потомков
else if (currentNode.nodeType === Node.ELEMENT_NODE) {
currentOffset += getNodeTextContent(currentNode).length;
}
// Переходим к следующему дочернему узлу
currentNode = currentNode.nextSibling;
}
// Если искомый узел не найден, возвращаем текущее смещение
return currentOffset;
}
/* Возвращает текстовое содержимое всех потомков узла */
function getNodeTextContent(node) {
// Если узел является текстовым узлом, возвращаем его текстовое содержимое
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent;
}
// Инициализируем переменную для текстового содержимого узла
let textContent = '';
// Инициализируем переменную для текущего дочернего узла, начиная с первого дочернего узла
let currentNode = node.firstChild;
// Проходим по всем дочерним узлам узла
while (currentNode !== null) {
// Рекурсивно вызываем функцию `getNodeTextContent` для текущего дочернего узла и добавляем его текстовое содержимое
textContent += getNodeTextContent(currentNode);
// Переходим к следующему дочернему узлу
currentNode = currentNode.nextSibling;
}
// Возвращаем текстовое содержимое узла
return textContent;
}
getSelectionOffsets
для получения смещения относительно родителя. Настало время восстановить утраченое выделение. Напишем ещё одну функцию.function setRangeSelection(node, startOffset, endOffset) {
// Объявляем переменные для отслеживания текущего смещения,
// узла начала выделения, смещения начала выделения,
// узла конца выделения и смещения конца выделения
let currentOffset = 0;
let startNode = null;
let startNodeOffset = 0;
let endNode = null;
let endNodeOffset = 0;
// Создаем TreeWalker для обхода текстовых узлов внутри node
const treeWalker = document.createTreeWalker(
node,
NodeFilter.SHOW_TEXT,
null
);
// Получаем начальный текстовый узел с помощью treeWalker.nextNode().
let currentNode = treeWalker.nextNode();
while (currentNode) {
const nodeLength = currentNode.nodeValue.length;
// Проверяем, находится ли `startOffset` внутри текущего текстового узла
if (startOffset >= currentOffset && startOffset < currentOffset + nodeLength) {
// Если да, то обновляем `startNode` и `startNodeOffset`
startNode = currentNode;
startNodeOffset = startOffset - currentOffset;
}
// Проверяем, находится ли `endOffset` внутри текущего текстового узла
if (endOffset > currentOffset && endOffset <= currentOffset + nodeLength) {
// Если да, то обновляем `endNode` и `endNodeOffset`
endNode = currentNode;
endNodeOffset = endOffset - currentOffset;
}
currentOffset += nodeLength;
currentNode = treeWalker.nextNode();
}
// Если `startNode` и `endNode` были найдены
if (startNode && endNode) {
// Создаем новый объект Range иустанавливаем начало и конец диапазона
const range = document.createRange();
range.setStart(startNode, startNodeOffset);
range.setEnd(endNode, endNodeOffset);
const selection = window.getSelection();
// Очищаем текущее выделение
selection.removeAllRanges();
// Добавляем новый диапазон выделения
selection.addRange(range);
}
}
setRangeSelection()
, я прохожу только текстовые узлы, игнорируя все остальные. Это подходит для данной ситуации, но следует помнить, что выделение может начинаться и заканчиваться в любых типах узлов, включая текстовые узлы, элементы, комментарии и даже между ними. Мы сознательно стремимся держать выделение только в текстовых узлах, чтобы было проще с ним работать. К тому же, функции Range.setStart()
и Range.setEnd()
работают по-разному для разных типов узлов.Range.setStart()/Range.setEnd()
с текстовым узлом, мы указываем, с какого символа в слове или фразе должно начинаться выделение. Например, если текстовый узел содержит слово «привет» и мы устанавливаем начало выделения с помощью Range.setStart()
на позицию 2, то выделение начнется с буквы «и» в слове «привет».Range.setStart()
указывает, в каком дочернем узле должно начинаться выделение. Например, если у нас есть элемент , содержащий несколько текстовых узлов, и мы устанавливаем начало выделения с помощью
Range.setStart()
на позицию 1, то выделение начнется со второго дочернего узла элемента .
Глава 3.4. Форматируем несколько блоков за раз
ctrl
+ b
. Перед нами встаёт ряд задач: surroundContents()
не вариант. Даже если внутри только текстовые узлы, выделение охватывает сразу несколько блоков.extractContents()
? Тоже может быть проблематично, так как даже если выделение не полностью охватывает какие-то блоки, во фрагмент всё равно попадут все, даже частично выделенные, Range.startContainer
и Range.endContainer
. Найти блоки между ними труда не составит, и мы уже точно знаем, что они выделены целиком, а это упрощает нам задачу.Range
и работать с ним. В конце у нас будет несколько объектов Range
, которые мы просто объединим. Писать код для этой части я не буду, тут с лихвой должно хватать предыдущих примеров.Range
, для первого и последнего блока. Напишем функцию, которая их объединяет.function mergeRanges(range1, range2) {
// Создаем новый диапазон, который будет объединять range1 и range2
const newRange = document.createRange();
// Определяем начальный и конечный узлы для объединенного диапазона
const startNode = range1.startContainer.nodeType === Node.TEXT_NODE
? range1.startContainer // Если начальный контейнер - текстовый узел, используем его напрямую
: range1.startContainer.childNodes[range1.startOffset]; // Иначе используем дочерний узел, соответствующий смещению
const endNode = range2.endContainer.nodeType === Node.TEXT_NODE
? range2.endContainer // Если конечный контейнер - текстовый узел, используем его напрямую
: range2.endContainer.childNodes[range2.endOffset - 1]; // Иначе используем дочерний узел, соответствующий смещению
// Устанавливаем начало объединенного диапазона
newRange.setStart(startNode, range1.startOffset);
// Устанавливаем конец объединенного диапазона
newRange.setEnd(endNode, range2.endOffset);
// Возвращаем новый объединенный диапазон
return newRange;
}
const mergedRange = mergeRanges(firstBlockRange, secondBlockRange);
// На всякий случай очищаем текущее выделение
selection.removeAllRanges();
// Добавляем слитый диапазон
selection.addRange(range);
Заключение
Enter
, Backspace
или даже ctrl+v
. Каждый из этих случае будет обрабатываться по-разному. Но статья и так получилось довольно объемной, так что об этом в другой раз.contenteditable
-элементами, DOM, и понять, как можно работать с выделением и форматированием текста.