Как мне взбрело в голову свой Notion-like редактор написать
- среда, 22 мая 2024 г. в 00:00:16
Мне в голову пришла идея пет-проекта, который изначально никак не был связан с текстовым редактором. Однако, в процессе работы все дошло до того, что пользователям нужно где-то набирать текст. Я люблю Notion и пишу там много и часто, поэтому решил сделать похожий (но сильно упрощенный) редактор в своём проекте. Не столько из нужды, сколько из любопытства, ведь я никогда не занимался ничем подобным и мало что знал о том, как писать текстовые редакторы.
В статье хочу рассказать про атрибут contenteditable
у HTML-элементов, про сопутствующие проблемы при его использовании, про кастомное форматирование и про работу с выделенными участками текста.
Цель статьи — не демонстрация готового продукта или best practices для его реализации. Некоторые проблемы могут показаться глупыми, но как и сказано выше, это мой первый опыт работы с contenteditable
и текстовыми редакторами. Приведенные примеры могут быть неэлегантными, неоптимальными и содержать множество допущений. Они нужны чтобы наглядно показать возможности, которые могут помочь добиться результата.
Для начала расскажу, что вообще за contenteditable.
HTML-атрибут contenteditable
позволяет сделать элемент на веб-странице редактируемым. Применяя его к элементу, например <div>
, мы даем пользователю возможность изменять содержимое элемента прямо в браузере, как в текстовом редакторе. Этот атрибут предоставляет удобный способ создания редактируемых областей без использования сложных JavaScript-библиотек. Однако, работа с ним требует внимания, так как браузеры могут по-разному интерпретировать редактируемый контент, что иногда приводит к неожиданному поведению.
Для начала накидаем HTML. В браузере это выглядит как три редактируемых строки текста.
<div data-id="1" contenteditable>
Анекдот:
</div>
<div data-id="2" contenteditable>
В семье семян случилось горе.
</div>
<div data-id="3" contenteditable>
Отца посадили.
</div>
Для удобства я буду звать contenteditable дивы «блоками».
Для пользователя всё должно работать, как единое пространство для редактирования. Сейчас блоки никак между собой не связаны. Я не могу переключаться между строками с помощью клавиатуры (↑ и ↓).
Первое, что приходит в голову - повесить обработчик на событие keydown
и вручную переносить каретку на блок выше или ниже. Однако, такой подход ломает нативное поведение каретки, и его реализация потребовала бы изрядных усилий для решения следующих задач:
Определение конкретной строки в случае многострочного текста, так как перемещение должно происходить только с первой и последней строки.
Сохранение позиции каретки на том же месте, а для этого нужно понимать размер текущей строки и строки, на которую будет перемещаться каретка.
Определение наличия элемента, на который пользователь хочет переключиться.
Это лишь часть проблем, которые пришлось бы решать при ручной реализации переключения между блоками. На самом деле, работа с кареткой — довольно сложная задача, а идеальное повторение её поведения и вовсе может привести к безумию.
Заниматься всем этим мне, конечно же, не хотелось, поэтому я снова пошел копаться в Notion. Заметил, что там все блоки обернуты в contenteditable
элемент.
Выглядит это примерно так:
<div data-content-editable-root="true" contenteditable>
<div data-id="1" contenteditable>Анекдот:</div>
<div data-id="2" contenteditable>В семье семян случилось горе.</div>
<div data-id="3" contenteditable>Отца посадили.</div>
</div>
Умно, подумал я. Такое решение позволяет объединить все блоки, так как теперь они сами часть редактируемого элемента. Это решает почти все упомянутые проблемы.
Но добавляет новые…
Браузер обрабатывает события в редактируемых элементах особым образом. Поскольку содержимое внутри элементов contenteditable
динамически изменяется пользователем, браузер, как я понял, самостоятельно управляет процессами ввода текста, удаления и форматирования у дочерних элементов, не позволяя обрабатывать эти процессы через JavaScript.
На помощь приходит делегирование событий. Можно подписаться на content-editable-root
, но вот незадача, event.target
всегда ссылается на этот элемент, а не на потомков, которые вызывают событие.
Прежде, чем я продолжу, давайте вспомним о событиях beforeinput
и input
. Именно они срабатываю перед и после изменения значения таких элементов как: input, textarea и любых элементов с атрибутом contenteditable
. Через эти события мы и будем работать с конкретными элементами. В большей степени нас интересует beforeinput
.
Мы не сможем получить доступ к изменяемому элементу через event.target
, но выход есть, и это — Window.getSelection().
Метод возвращает объект
Selection
, который представляет собой диапазон текста, выделенный пользователем, или текущее положение каретки.
C его помощью мы можем получить информацию о положении и содержимом выделения. Что может быть полезным:
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, всего их несколько десятков.
Самым большим камнем преткновения стало форматирование текста. С одной стороны, нативное форматирование отлично работает без какого-либо вмешательства, с другой стороны: на этом его плюсы заканчиваются. Основные минусы:
Его сложно контролировать и мы не сможем держать его в рамках конкретных блоков;
Потенциально большая вложенность тегов, что усложняет структуру документа и последующую работу с ней.
Notion не использует нативное форматирование, вместо этого выделенный текст оборачивается в единственный span
, к которому затем добавляются разные стили. Если форматирование применяются к части уже отформатированного текста, он разделяются на несколько span
элементов. Внутри блока может быть либо TEXT_NODE
, либо ELEMENT_NODE
в виде span
, внутри которого будут только TEXT_NODE
.
Предполагаю, что и при экспорте в разные форматы, и при совместном редактировании это играет определенную роль. В итоге я решил сделать то же самое.
Напомню, как примерно выглядит наш DOM.
<div data-content-editable-root="true" contenteditable>
<div data-id="1" contenteditable>Анекдот:</div>
<div data-id="2" contenteditable>В семье семян случилось горе.</div>
<div data-id="3" contenteditable>Отца посадили.</div>
</div>
Допустим, я захотел сделать текст в первом блоке жирным. Первым делом надо как-то получить выделенный текст. Сделать это можно через интерфейс для работы с выделением.
Range — интерфейс, предоставляющий фрагмент документа, который может содержать узлы и части текстовых узлов данного документа. Мы будем использовать его для работы с выделениями.
Самое простое решение — метод Range.surroundContents()
. Он оборачивает содержимое диапозона в новый элемент. Достаточно сделать что-то вроде:
function makeSelectedTextBold() {
const range = window.getSelection().getRangeAt(0);
const span = document.createElement('span');
span.classList.add('bold');
range.surroundContents(span);
}
Готово, теперь, структура документа выглядит так:
<div data-content-editable-root="true" contenteditable>
<div data-id="1" contenteditable>
<span class="bold">Анекдот:</span>
</div>
<div data-id="2" contenteditable>В семье семян случилось горе.</div>
<div data-id="3" contenteditable>Отца посадили.</div>
</div>
Если выделенный контент нужно предварительно как-то обработать, можно воспользоваться другими методами 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);
}
Такими нехитрыми способами можно легко получить выделенный текст и работать с ним.
Усложним структуру документа и добавим много разного форматирования.
<div data-content-editable-root="true" contenteditable>
<div data-id="1" contenteditable>
Дорогая,
<span class="bold">я вышел сегодня</span>
из дому
<span class="italic">поздно вечером</span>
</div>
<div data-id="2" contenteditable>
подышать
<span class="bold italic">свежим воздухом, веющим</span>
с океана.
</div>
<div data-id="3" contenteditable>
Закат
<span class="underline bold">догорал</span>
в партере китайским веером,
</div>
<div data-id="4" contenteditable>
<span class="italic strikethrough">
и туча клубилась, как крышка концертного фортепьяно.
</span>
</div>
</div>
В браузере он бы выглядел так:
Немного поговорим о содержании, с точки зрения узлов. Внутри блока узлы могут быть двух типов: 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);
}
То же самое нужно проделать для каждого стиля. В нашем случае, структура первого блока теперь выглядит так:
<div data-id="1" contenteditable>
<span class="bold">Дорогая, </span>
<span class="bold">я вышел сегодня </span>
<span class="bold">из дому </span>
<span class="italic">поздно вечером</span>
</div>
Внутри можно заметить 3 span
с одинаковым форматированием. Плодить узлы мы не хотим, поэтому их надо объединить в один. Сделать это довольно просто: проходимся по дочерним элементам блока, объединяем все смежные блоки с одинаковыми классами. Итог должен быть таким:
<div data-id="1" contenteditable>
<span class="bold">Дорогая, я вышел сегодня из дому </span>
<span class="italic">поздно вечером</span>
</div>
А теперь поговорим о выделении, так как в зависимости от используемых технологий, оно может сильно усложнить жизнь на этом этапе. Например, если использовать ванильный JavaScript, то после проделанных манипуляций выделение адаптируется к изменениям и останется на своем месте, а вот если бы мы делали то же самое, например, в React, выделение бы исчезло. Это связано с тем, как React управляет DOM. При любой перерисовке компонента выделение будет пропадать.
Хорошая новость в том, что выделение можно вернуть. Range
имеет начальную позицию (якорь) и конечную позицию (фокус). Начальная позиция представлена свойствами startContainer
и startOffset
, а конечная — свойствами endContainer
и endOffset
. Эти свойства указывают на узлы (чаще всего текстовые, но не всегда) DOM и смещения внутри этих узлов, где начинается и заканчивается выделение.
Ну что ж, значит будем сохранять данные о выделении, а потом восстанавливать его. Правда, тут тоже есть нюансы: мы превратили несколько элементов в один, следовательно, структура DOM изменилась, а вот объект 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);
}
}
Готово! Теперь мы можем восстановить выделение независимо от того, как поменялась структура DOM.
Расскажу подробнее о выделении. В функции setRangeSelection()
, я прохожу только текстовые узлы, игнорируя все остальные. Это подходит для данной ситуации, но следует помнить, что выделение может начинаться и заканчиваться в любых типах узлов, включая текстовые узлы, элементы, комментарии и даже между ними. Мы сознательно стремимся держать выделение только в текстовых узлах, чтобы было проще с ним работать. К тому же, функции Range.setStart()
и Range.setEnd()
работают по-разному для разных типов узлов.
Когда мы используем Range.setStart()/Range.setEnd()
с текстовым узлом, мы указываем, с какого символа в слове или фразе должно начинаться выделение. Например, если текстовый узел содержит слово "привет" и мы устанавливаем начало выделения с помощью Range.setStart()
на позицию 2, то выделение начнется с буквы "и" в слове "привет".
С другими типами узлов, такими как элементы, Range.setStart()
указывает, в каком дочернем узле должно начинаться выделение. Например, если у нас есть элемент <span>
, содержащий несколько текстовых узлов, и мы устанавливаем начало выделения с помощью Range.setStart()
на позицию 1, то выделение начнется со второго дочернего узла элемента <span>
.
Вернёмся пока к такой структуре:
<div data-content-editable-root="true" contenteditable>
<div data-id="1" contenteditable>Анекдот:</div>
<div data-id="2" contenteditable>В семье семян случилось горе.</div>
<div data-id="3" contenteditable>Отца посадили.</div>
</div>
Пользователь выделяет кусок текста:
и нажимает ctrl
+ b
. Перед нами встаёт ряд задач:
Определить, какие блоки были выделены;
Определить, какие блоки были выделены частично
Определить, нужно ли добавить форматирование или убрать (если оно уже есть);
Применить/удалить форматирование к выделенной части.
Сохранить выделение после всех манипуляций.
С чего начать?
Использовать surroundContents()
не вариант. Даже если внутри только текстовые узлы, выделение охватывает сразу несколько блоков.
Получить фрагмент через extractContents()
? Тоже может быть проблематично, так как даже если выделение не полностью охватывает какие-то блоки, во фрагмент всё равно попадут все, даже частично выделенные, <div contenteditable>
, просто внутри у них будет только выделенная часть. Потом придётся разруливать. Да и работа сразу с несколькими блоками потребует дополнительной логики.
Если подумать, все задачи мы уже умеем решать в рамках работы с одним блоком, поэтому я предлагаю поступить именно так. Первый и последний блок мы можем легко получить с помощью 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
. Каждый из этих случае будет обрабатываться по-разному. Но статья и так получилось довольно объемной, так что об этом в другой раз.
В конце концов у меня получилось создать что-то отдаленно похожее на Notion. Для меня это был интересный опыт. Как я говорил в самом начале: я никогда раньше не работал с текстом так плотно. Этот опыт позволил мне погрузиться в детали работы с contenteditable
-элементами, DOM, и понять, как можно работать с выделением и форматированием текста.
Наверняка многие подумали (и не ошиблись), что подход с ручным изменением DOM может быть не самым удобным и эффективным. Это действительно так. Всё-таки DOM — это довольно низкоуровневое API, не предназначенное для работы с высокоуровневыми абстракциями, такими как блоки текста.
Вместо того, чтобы манипулировать DOM напрямую, мы можем работать с собственной моделью данных, которая представляет собой дерево блоков, и затем рендерить эту модель в DOM. Это позволит легко обрабатывать такие сложные операции, как выделение текста, применение к нему форматирования, и многие другие, а также отделит логику работы с данными от логики рендеринга, оптимизирует и сильно упростит код. Вероятно, в Notion так и делают и именно это станет моим следующим шагом.
Спасибо, что дочитали мою первую статью до конца!