Введение

Мне в голову пришла идея пет-проекта, который изначально никак не был связан с текстовым редактором. Однако, в процессе работы все дошло до того, что пользователям нужно где-то набирать текст. Я люблю Notion и пишу там много и часто, поэтому решил сделать похожий (но сильно упрощенный) редактор в своём проекте. Не столько из нужды, сколько из любопытства, ведь я никогда не занимался ничем подобным и мало что знал о том, как писать текстовые редакторы.

В статье хочу рассказать про атрибут contenteditable у HTML-элементов, про сопутствующие проблемы при его использовании, про кастомное форматирование и про работу с выделенными участками текста.

Дисклеймер

Цель статьи — не демонстрация готового продукта или best practices для его реализации. Некоторые проблемы могут показаться глупыми, но как и сказано выше, это мой первый опыт работы с contenteditable и текстовыми редакторами. Приведенные примеры могут быть неэлегантными, неоптимальными и содержать множество допущений. Они нужны чтобы наглядно показать возможности, которые могут помочь добиться результата.

Глава 1. Один за всех и все в одном

Для начала расскажу, что вообще за 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 и вручную переносить каретку на блок выше или ниже. Однако, такой подход ломает нативное поведение каретки, и его реализация потребовала бы изрядных усилий для решения следующих задач:

  1. Определение конкретной строки в случае многострочного текста, так как перемещение должно происходить только с первой и последней строки.

  2. Сохранение позиции каретки на том же месте, а для этого нужно понимать размер текущей строки и строки, на которую будет перемещаться каретка.

  3. Определение наличия элемента, на который пользователь хочет переключиться.

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

Заниматься всем этим мне, конечно же, не хотелось, поэтому я снова пошел копаться в 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>

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

Но добавляет новые…

Глава 2. Слышу звон событий, да не знаю чьих

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

На помощь приходит делегирование событий. Можно подписаться на content-editable-root, но вот незадача, event.target всегда ссылается на этот элемент, а не на потомков, которые вызывают событие.

Прежде, чем я продолжу, давайте вспомним о событиях beforeinput и input. Именно они срабатываю перед и после изменения значения таких элементов как: input, textarea и любых элементов с атрибутом contenteditable. Через эти события мы и будем работать с конкретными элементами. В большей степени нас интересует beforeinput.

Мы не сможем получить доступ к изменяемому элементу через event.target, но выход есть, и это — Window.getSelection().

Метод возвращает объект Selection, который представляет собой диапазон текста, выделенный пользователем, или текущее положение каретки.

C его помощью мы можем получить информацию о положении и содержимом выделения. Что может быть полезным:

  1. Selection.anchorNode — возвращает узел DOM, в котором начинается выделение.

  2. Selection.anchorOffset — возвращает смещение (индекс) внутри anchorNode, где начинается выделение.

  3. Selection.focusNode — возвращает узел DOM, в котором заканчивается выделение.

  4. Selection.focusOffset — возвращает смещение (индекс) внутри focusNode, где заканчивается выделение.

  5. 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. Укрощение строптивого форматирования

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

  1. Его сложно контролировать и мы не сможем держать его в рамках конкретных блоков;

  2. Потенциально большая вложенность тегов, что усложняет структуру документа и последующую работу с ней.

Notion не использует нативное форматирование, вместо этого выделенный текст оборачивается в единственный span, к которому затем добавляются разные стили. Если форматирование применяются к части уже отформатированного текста, он разделяются на несколько span элементов. Внутри блока может быть либо TEXT_NODE, либо ELEMENT_NODE в виде span, внутри которого будут только TEXT_NODE.

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

Глава 3.1. Форматируем простой текст

Напомню, как примерно выглядит наш 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);
}

Такими нехитрыми способами можно легко получить выделенный текст и работать с ним.

Глава 3.2. Форматируем сложный текст

Усложним структуру документа и добавим много разного форматирования.

<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 , чтобы сделать весь текст жирным.

Untitled

Здесь сложность возрастает, в этот раз мы работаем уже с двумя типами узлов. 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 и смещения внутри этих узлов, где начинается и заканчивается выделение.

Глава 3.3. Сохраняем выделение

Ну что ж, значит будем сохранять данные о выделении, а потом восстанавливать его. Правда, тут тоже есть нюансы: мы превратили несколько элементов в один, следовательно, структура 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>.

Глава 3.4. Форматируем несколько блоков за раз

Вернёмся пока к такой структуре:

<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. Перед нами встаёт ряд задач:

  1. Определить, какие блоки были выделены;

  2. Определить, какие блоки были выделены частично

  3. Определить, нужно ли добавить форматирование или убрать (если оно уже есть);

  4. Применить/удалить форматирование к выделенной части.

  5. Сохранить выделение после всех манипуляций.

С чего начать?

Использовать 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(mergedRange);

Готово, выделение для нескольких блоков восстановлено.

Заключение

Есть ещё много интересных сценариев работы с текстом, о которых я тут не рассказал. Например, удаление блоков, когда выделено сразу несколько. Это можно сделать с помощью Enter, Backspace или даже ctrl+v. Каждый из этих случае будет обрабатываться по-разному. Но статья и так получилось довольно объемной, так что об этом в другой раз.

В конце концов у меня получилось создать что-то отдаленно похожее на Notion. Для меня это был интересный опыт. Как я говорил в самом начале: я никогда раньше не работал с текстом так плотно. Этот опыт позволил мне погрузиться в детали работы с contenteditable-элементами, DOM, и понять, как можно работать с выделением и форматированием текста.

Наверняка многие подумали (и не ошиблись), что подход с ручным изменением DOM может быть не самым удобным и эффективным. Это действительно так. Всё-таки DOM — это довольно низкоуровневое API, не предназначенное для работы с высокоуровневыми абстракциями, такими как блоки текста.

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

Спасибо, что дочитали мою первую статью до конца!

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


  1. Popou
    20.05.2024 09:37
    +4

    Здорово и полезно, но в случае если вам реально нужно будет сделать редактор, мой совет это editor.js он жутко удобный, вроде бы habr его же и использует, да и на notion он очень сильно похож


    1. lil_mikazuki Автор
      20.05.2024 09:37
      +1

      Я пока вот этим всем занимался, смотрел их исходники тоже. Довльно удобная штука)
      Они, кстати говоря, используют базовое форматирование, насколько я понял. Сами ничего не костылят)


  1. nkhizhniy
    20.05.2024 09:37
    +2

    Вот такие фронтэндеры реально крутые!


  1. JerryI
    20.05.2024 09:37

    Теперь вижу сколько усилий требуется чтобы иметь условный Code Mirror js…


  1. VADemon
    20.05.2024 09:37
    +1

    Когда речь шла о нескольких выделенных блоках -- это только про div-ы? Потому что для редактора будет удобно использовать мультивыделение, которое, как я понял, нестандартно и есть только в Firefox (ctrl+click, очень удобно с таблицами).


    1. lil_mikazuki Автор
      20.05.2024 09:37
      +1

      Я не пробовал, но должно работать и с мультивыделением. Просто в Selection сразу будет несколько Range. Можно в консоли быстренько проверить примерно так:

      let selection = document.getSelection();
      for (let i = 0; i < selection.rangeCount; i++) {
      console.log(selection.getRangeAt(i).toString()));
      }

      У меня Firefox нет, если получится, расскажите)


      1. VADemon
        20.05.2024 09:37
        +1

        Да, работает исправно! (в коде лишняя правая скобка)

        Мне в голову пришла идея пет-проекта
        который изначально 
        был связан с текстовым редактором. 


  1. realbot
    20.05.2024 09:37

    А зачем снова писать quill, lexical и пр.? )


    1. lil_mikazuki Автор
      20.05.2024 09:37
      +1

      Моя мотивация была простой: развлечься и научиться чему-то новому. Захват рынка текстовых редакторов в план (пока что) не входит


  1. RomanKu
    20.05.2024 09:37
    +2

    Ох, какой замечательный велосипед в хорошем смысле этого слова, вспоминается один из моих первых проектов - текстовый редактор по типу wordpad на visual basic, тогда тоже много возился с форматированием документа

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

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


  1. georgiyozhegov
    20.05.2024 09:37
    +1

    В коде перед заключением range нужно заменить на mergedRange


    1. lil_mikazuki Автор
      20.05.2024 09:37

      Спасибо! Исправил