✨Как с помощью JS реализовать функцию онлайн-заметок «выделение слов»? ✨🖍️

внешний интерфейс JavaScript браузер DOM
✨Как с помощью JS реализовать функцию онлайн-заметок «выделение слов»? ✨🖍️

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

1. Что такое "выделение слов"?

Некоторые учащиеся могут не знать, что означает «выделение слов». Ниже приведено типичное «выделение слов»:

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

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

Конкретный основной код этой статьи был инкапсулирован в независимую библиотеку.web-highlighter, если у вас возникнут вопросы во время чтения, воспользуйтесь кодом ↓↓.

2. Какие проблемы необходимо решить, чтобы реализовать «выделение слов»?

Есть две основные проблемы, которые необходимо решить, чтобы реализовать онлайн-функцию «выделения слов»:

  • Добавьте фон подсветки. То есть, как добавить выделенный фон к соответствующему тексту в соответствии с выбором пользователя на веб-странице;
  • Стойкость и восстановление выделенных участков. То есть, как сохранить выделенную пользователем информацию и точно восстановить ее при следующем просмотре, иначе выделенная пользователем информация будет потеряна при следующем открытии страницы.

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

Ситуация, с которой я столкнулся, заключается в том, что структура макета HTML страницы сложна, и невозможно продвигать бизнес, чтобы изменить HTML в соответствии с требованиями выделения. Это также привело к потребности в более общем решении с целью:Любой контент может быть «выделен» и может быть восстановлен до выделенного состояния при последующем доступе, не заботясь об организационной структуре контента..

Давайте подробно поговорим о том, как решить две вышеупомянутые основные проблемы.

3. Как «Выделить фон»?

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

Например, пользователь выделил текст на изображении выше (то есть синюю часть). Основная идея ее выделения заключается в следующем:

  1. Получить выбранные текстовые узлы: получить все выбранные текстовые узлы с помощью информации об области, выбранной пользователем;
  2. Добавить цвет фона к текстовым узлам: оберните эти текстовые узлы новым элементом с указанным цветом фона.

3.1 Как получить выбранный текстовый узел?

1) API выбора

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

API выбора может возвращать ряд сведений о выборе пользователя. Так можно ли через него напрямую получить все элементы DOM в выборе?

К сожалению нет. Но, к счастью, он может вернуть информацию о первом и последнем узле выбора:

const range = window.getSelection().getRangeAt(0);
const start = {
    node: range.startContainer,
    offset: range.startOffset
};
const end = {
    node: range.endContainer,
    offset: range.endOffset
};

RangeОбъект содержит информацию о начале и конце выделения, включая узел и смещение текста. Излишне говорить об информации об узле, вот объяснение того, что относится к смещению: например, метка<p>这是一段文本的示例</p>, часть, выбранная пользователем, представляет собой четыре слова «фрагмент текста», в это время первый и последний узлы являются текстовыми узлами (текстовый узел) в элементе p, а startOffset и endOffset равны 2 и 6 соответственно.

2) Разделение первого и последнего текстовых узлов

После понимания концепции смещения естественно обнаружить, что существует проблема, которую необходимо решить. Поскольку пользовательский выбор может содержать только часть текстового узла (то есть смещение не равно 0), узлы, содержащиеся в пользовательском выборе, мы в итоге получаем только в надежде получить эту «часть» первого и последнего текстовых узлов. Для этого мы можем использовать.splitText()Разделить текстовые узлы:

// 首节点
if (curNode === $startNode) {
    if (curNode.nodeType === 3) {
        curNode.splitText(startOffset);
        const node = curNode.nextSibling;
        selectedNodes.push(node);
    }
}

// 尾节点
if (curNode === $endNode) {
    if (curNode.nodeType === 3) {
        const node = curNode;
        node.splitText(endOffset);
        selectedNodes.push(node);
    }
}

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

3) Пройдите по дереву DOM

Пока что мы нашли именно первый и последний узлы, поэтому следующим шагом будет поиск всех текстовых узлов «посередине». Это требует обхода дерева DOM.

«Середина» взята в кавычки, потому что визуально эти узлы расположены между началом и концом, но так как DOM представляет собой не линейную структуру, а древовидную структуру, эта «середина» заменяется языком программирования, а это значит, что при обход в глубину, все текстовые узлы между первым и последним узлами. Существует много методов DFS, которые могут быть рекурсивными или стек + цикл, поэтому я не буду здесь вдаваться в подробности.

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

if (curNode.nodeType === 3) {
    selectedNodes.push(curNode);
}

3.2 Как добавить цвет фона к текстовым узлам?

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

В частности, мы можем добавить класс к каждому текстовому узлу какhighlightиз<span>элементы; в то время как стили фона задаются с помощью CSS.highlightнастройки селектора.

// 使用上一步中封装的方法获取选区内的文本节点
const nodes = getSelectedNodes(start, end);

nodes.forEach(node => {
    const wrap = document.createElement('span');
    wrap.setAttribute('class', 'highlight');
    wrap.appendChild(node.cloneNode(false));
    node.parentNode.replaceChild(wrap);
});
.highlight {
    background: #ff9;
}

Это добавит «постоянный» выделенный фон к выделенному тексту.

p.s. Совпадение округов

Однако при выделении текста есть еще одно сложное требование — перекрытие выделенных областей. Например, на первом демонстрационном изображении (ниже) есть перекрытие между первой выделенной областью и второй выделенной областью, то есть слова «эта область высокая».

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

4. Как добиться стойкости и восстановления выделенного выделения?

Пока что мы можем добавить выделенный фон к выделенному тексту. Но есть одна большая проблема:

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

Суть сохранения выделенного выбора заключается в поиске подходящего способа сериализации узлов DOM.

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

4.1 Сериализация идентификаторов узлов DOM

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

Существует несколько распространенных способов идентификации узлов DOM:

  • используя xPath
  • Использование синтаксиса селектора CSS
  • использовать имя тега + индекс

Здесь мы решили использовать третий способ быстрого достижения. Следует отметить, что первый и последний узлы, которые мы получаем через Selection API, обычно являются текстовыми узлами, а записываемые здесь tagName и index являются узлами родительского элемента (узел элемента) текстового узла, а childIndex указывает, что текст node - это первые сыновья своего родителя:

function serialize(textNode, root = document) {
    const node = textNode.parentElement;
    let childIndex = -1;
    for (let i = 0; i < node.childNodes.length; i++) {
        if (textNode === node.childNodes[i]) {
            childIndex = i;
            break;
        }
    }
    
    const tagName = node.tagName;
    const list = root.getElementsByTagName(tagName);
    for (let index = 0; index < list.length; index++) {
        if (node === list[index]) {
            return {tagName, index, childIndex};
        }
    }
    return {tagName, index: -1, childIndex};
}

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

4.2 Десериализация узлов DOM

Основываясь на методе сериализации из предыдущего раздела, после получения данных от серверной части их можно легко десериализовать в узел DOM:

function deSerialize(meta, root = document) {
    const {tagName, index, childIndex} = meta;
    const parent = root.getElementsByTagName(tagName)[index];
    return parent.childNodes[childIndex];
}

На данный момент мы в общих чертах устранили две основные проблемы, и этот релиз кажется пригодным для использования. новообще-то нет, согласно практическому опыту, если используются только вышеуказанные методы лечения, они часто не могут удовлетворить фактические потребности, и возникают некоторые «фатальные проблемы».

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

5. Как реализовать готовую "подсветку слов"?

1) Что не так с приведенной выше схемой?

Во-первых, давайте посмотрим, что не так с приведенной выше схемой.

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

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

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

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

<p>
    非常高兴今天能够在这里和大家分享一下文本高亮(在线笔记)的实现方式。
</p>

В приведенном выше HTML-коде пользователь последовательно выделил две части: «счастье» и «выделение текста». Затем в соответствии с приведенной выше реализацией этот HTML становится следующим:

<p>
    非常
    <span class="highlight">高兴</span>
    今天能够在这里和大家分享一下
    <span class="highlight">文本高亮</span>
    (在线笔记)的实现方式。
</p>

Соответствующие два сериализованных данных:

// “高兴”两个字被高亮时获取的序列化信息
{
    start: {
        tagName: 'p',
        index: 0,
        childIndex: 0,
        offset: 2
    },
    end: {
        tagName: 'p',
        index: 0,
        childIndex: 0,
        offset: 4
    }
}
// “文本高亮”四个字被高亮时获取的序列化信息。
// 这时候由于p下面已经存在了一个高亮信息(即“高兴”)。
// 所以其内部 HTML 结构已被修改,直观来说就是 childNodes 改变了。
// 进而,childIndex属性由于前一个 span 元素的加入,变为了 2。
{
    start: {
        tagName: 'p',
        index: 0,
        childIndex: 2,
        offset: 14
    },
    end: {
        tagName: 'p',
        index: 0,
        childIndex: 2,
        offset: 18
    }
}

Видно, что головной и хвостовой узлы четырех слов «выделение текста»childIndexзаписываются как 2, это связано с изменением предыдущей выделенной области<p>Структура DOM под элементом. Если выделение «счастливого» округа отменено пользователем в это время, выделение не может быть восстановлено при доступе к странице в следующий раз — выделение «счастливого» округа отменяется,<p>Естественно, третьего childNode не будет, поэтому, если childIndex равен 2, соответствующий узел не может быть найден. Это вызвало проблемы с восстановлением выделенного выделения в сохраненных данных.

Кроме того, помните проблему совпадения выбора бликов, упомянутую в конце части 3? Поддержка совпадения выбора склонна к следующему вложению обернутых элементов:

<p>
    非常
    <span class="highlight">高兴</span>
    今天能够在这里和大家分享一下
    <span class="highlight">
        文本
        <span class="highlight">高亮</span>
    </span>
    (在线笔记)的实现方式。
</p>

Это также делает текстовую область сложной вложенной структурой, отличной от исходной HTML-страницы после многократного выделения и удаления выделения. Как и ожидалось, когда мы используем xpath или селектор CSS в качестве идентификатора DOM, проблемы, упомянутые выше, также появятся, а также усложнят реализацию других требований.

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

  • библиотека с открытым исходным кодомRangyСуществует модуль Highlighter, который может обеспечить выделение текста, но он напрямую объединяет два выделения в случае перекрывающихся выделений, что не соответствует потребностям нашего бизнеса.
  • Платный продуктDiigoПрямое совмещение избирательных округов не допускается.
  • Medium.com поддерживает перекрытие избирательных округов, и это очень удобно, что также является целью нашего продукта. Но структура области контента страницы проще и более управляема, чем ситуация, с которой я столкнулся.

Итак, как решить эти проблемы?

2) Другой способ сериализации/десериализации

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

Например, следующий HTML:

<p>
    非常
    <span class="highlight">高兴</span>
    今天能够在这里和大家分享一下
    <span class="highlight">文本高亮</span>
    (在线笔记)的实现方式。
</p>

Для выделенного элемента «Выделение текста» информация, используемая для определения начальной позиции текста,childIndex = 2, offset = 14. и теперь становитсяoffset = 18(от<p>Начинает считаться первый текст "не" под элементом, а после 18 символов это "текст"). Видно, что преимущество такого представления состоит в том, что независимо от<p>Внутри исходного текстового узла находится<span>Независимо от того, как разделен (обернутый) узел, это не повлияет на позиционирование узла при восстановлении выделенного выделения.

В соответствии с этим при сериализации нам нужен метод для «перевода» смещения внутри текстового узла в соответствующее ему общее смещение текста внутри родительского узла:

function getTextPreOffset(root, text) {
    const nodeStack = [root];
    let curNode = null;
    let offset = 0;
    while (curNode = nodeStack.pop()) {
        const children = curNode.childNodes;
        for (let i = children.length - 1; i >= 0; i--) {
            nodeStack.push(children[i]);
        }

        if (curNode.nodeType === 3 && curNode !== text) {
            offset += curNode.textContent.length;
        }
        else if (curNode.nodeType === 3) {
            break;
        }
    }

    return offset;
}

При восстановлении выделенного выделения требуется соответствующий обратный процесс:

function getTextChildByOffset(parent, offset) {
    const nodeStack = [parent];
    let curNode = null;
    let curOffset = 0;
    let startOffset = 0;
    while (curNode = nodeStack.pop()) {
        const children = curNode.childNodes;
        for (let i = children.length - 1; i >= 0; i--) {
            nodeStack.push(children[i]);
        }
        if (curNode.nodeType === 3) {
            startOffset = offset - curOffset;
            curOffset += curNode.textContent.length;
            if (curOffset >= offset) {
                break;
            }
        }
    }
    if (!curNode) {
        curNode = parent;
    }
    return {node: curNode, offset: startOffset};
}

3) Поддержка совпадения выделенных вариантов

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

Во время обработки каждый текстовый сегмент (текстовый узел), который необходимо обернуть, делится на три категории:

  1. Если он вообще не обернут, оберните деталь напрямую.
  2. является частью обернутого текстового узла, используйте.splitText()Разделите его.
  3. Является полностью обернутым текстовым сегментом, обработка узлов не требуется.

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

6. Другие вопросы

После решения некоторых из вышеперечисленных проблем «подсветка текста» в принципе доступна. Осталось несколько «небольших исправлений», если кратко упомянуть.

6.1. Интерактивные события для выделенного выделения, такие как щелчок, наведение

Во-первых, вы можете сгенерировать уникальный идентификатор для каждого выделенного выбора, а затем записать информацию об идентификаторе для всех обернутых элементов в выборе, например, с помощьюdata-highlight-idАтрибуты. А для выделенных перекрывающихся частей можноdata-highlight-extra-idИдентификаторы других перекрывающихся выборок записываются в атрибут.

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

6.2 Отмена выделения (удаление выделенного фона)

Поскольку перекрытие выделения поддерживается при переносе (что соответствует трем упомянутым выше ситуациям, которые необходимо обрабатывать), при удалении выделения выделения также есть три ситуации, которые необходимо обрабатывать отдельно:

  • Удалите обернутый элемент напрямую. То есть нет перекрытия областей выбора.
  • возобновитьdata-highlight-idсвойства иdata-highlight-extra-idАтрибуты. то есть удаленный идентификатор выделения сdata-highlight-idтакой же.
  • только обновлениеdata-highlight-extra-idАтрибуты. То есть идентификатор удаленного выделения — это толькоdata-highlight-extra-idсередина.

6.3 Что насчет динамических страниц, созданных внешним интерфейсом?

Нетрудно заметить, что эта несвязанная функция выделения текста очень зависит от DOM-структуры страницы.Необходимо следить за тем, чтобы DOM-структура при выделении соответствовала структуре при восстановлении, иначе позиция начального узла выделения не может быть быть восстановлены правильно. В соответствии с этим наиболее удобной страницей для выделения «формулировки» должны быть страницы, отрисованные исключительно бэкендом.onloadДостаточно метода срабатывания восстановления выделенного выделения в мониторинге. Но в настоящее время все больше и больше страниц (или частей страниц) динамически генерируются интерфейсом, как решить эту проблему?

У меня была похожая проблема на работе - многие области страницы отображались на переднем плане после запроса ajax. Моя обработка включает в себя следующее:

  • Изолируйте диапазон изменчивости. Измените «корневой узел» в приведенном выше коде сdocumentElementЗамените его другим более конкретным элементом контейнера. Например, бизнес, с которым я сталкиваюсь, будет иметь идентификаторarticle-containerиз<div>загружать динамический контент внутри, тогда я бы указал этоarticle-containerявляется «корневым узлом». Это может предотвратить максимальное влияние внешних изменений DOM на позиционирование выделенной позиции, особенно при изменении страницы.
  • Определяет, когда восстанавливать выделенный фрагмент. Поскольку содержимое может быть сгенерировано динамически, вам нужно дождаться завершения рендеринга DOM для этой части, прежде чем вызывать метод восстановления. Если есть открытое событие прослушивателя, его можно обработать в прослушивателе; или MutationObserver прослушивает элемент iconic, чтобы определить, загружена ли часть.
  • Запишите информацию о бизнес-контенте и обработайте пересмотр области контента. Изменения в структуре DOM области содержимого «разрушительны». Если есть такая ситуация, вы можете попытаться позволить представителю бизнес-контента связать конкретную информацию о контенте, такую ​​​​как информация о абзаце, с элементом DOM, и я убираю эту информацию при выделении для избыточного хранения.После проверки я могу передать это информацию о содержании.«Почистите» сохраненные данные.

6.4 Прочее

Вопрос места и другие детали не будут обсуждаться в этой статье. Для получения подробной информации см.web-highlighterРеализация в этом репозитории.

7. Резюме

Эта статья начинается с двух основных вопросов функции «выделения слов» (как выделить текст выделения пользователя, как восстановить выделенное выделение) на основе средств Selection API, обхода в глубину и сериализации DOM. идентификация узлов.Реализована основная функция «выделения слов». Однако с этой схемой все еще есть некоторые практические проблемы, поэтому соответствующие решения приведены далее в разделе 5.

Основываясь на реальном опыте разработки, я обнаружил, что код для решения вышеупомянутых основных проблем «выделения слов» имеет определенную общность, поэтому исходный код основной части инкапсулирован в независимую библиотеку.web-highlighter, размещенный на github, а также может быть установлен через npm.

Он обслуживает бизнес онлайн-продуктов, и базовую функцию выделения можно активировать с помощью одной строки кода:

(new Highlighter()).run();

Совместимость с IE 10/11, Edge, Firefox 52+, Chrome 15+, Safari 5.1+, Opera 15+.

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