Реализация форматированного текстового редактора с javascript

внешний интерфейс JavaScript браузер iOS

Реализация форматированного текстового редактора с javascript

by.Тянь Гуанъюй 28 часов назад

В недавнем проекте необходимо разработать форматированный текстовый редактор, совместимый с ПК и мобильными устройствами, который включает в себя некоторые специальные функции настройки. Изучив существующие редакторы форматированного текста js, мы обнаружили, что их много на настольных компьютерах и почти нет на мобильных устройствах. Сторона рабочего стола представлена ​​UEditor. Но мы не планируем рассматривать совместимость, поэтому нет необходимости использовать такой тяжелый плагин, как UEditor. С этой целью я решил самостоятельно разработать форматированный текстовый редактор. В этой статье в основном рассказывается, как реализовать редактор форматированного текста и устранить некоторые ошибки между различными браузерами и устройствами.

Фаза подготовки

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

contenteditable = «истина»

Сначала нам нужно сделатьdivстать редактируемым, присоединитьсяcontenteditable="true"характеристики.

<div contenteditable="true" id="rich-editor"></div>

в таком<div>Любой вставленный узел будет доступен для редактирования по умолчанию. Если мы хотим вставить нередактируемый узел, нам нужно указать свойства вставленного узла какcontenteditable="false".

курсорная операция

Разработчики, работающие с форматированным текстовым редактором, должны иметь возможность управлять различной информацией о состоянии курсора, информацией о положении и т. д. браузер предоставляетselectionобъект иrangeобъект для управления курсором.

объект выбора

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

let selection = window.getSelection();

Обычно мы не работаем напрямуюselectionобъект, но им нужно манипулировать с помощьюselecitonОбъект, соответствующий выбранному пользователемranges(регион), широко известный как «драг-синий». Способ его получения следующий:

let range = selection.getRangeAt(0);

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

Объект выбора также имеет два важных метода:addRangeа такжеremoveAllRanges. Используется для добавления объекта диапазона к текущему выбору и удаления всех объектов диапазона соответственно. Позже вы увидите их использование.

объект диапазона

Объект диапазона, полученный через объект выделения, является фокусом нашей операции курсора. Диапазон представляет собой фрагмент документа, содержащий узлы и частичные текстовые узлы. Когда вы впервые видите объект диапазона, вы можете почувствовать себя незнакомым и знакомым.Где вы видели его раньше? Как фронтенд-инженер вы должны были прочитать книгу "JavaScript Advanced Programming Third Edition". В разделе 12.4 автор познакомил нас с интерфейсом диапазона, предоставляемым уровнем DOM2, для лучшего управления страницей. В любом случае, на что я смотрел в то время? ? ? ? Что толку от этого, нет такого спроса. Здесь мы часто используем этот объект. Для следующих узлов:

<div contenteditable="true" id="rich-editor">
    <p>百度EUX团队</p>
</div>

Положение курсора показано на рисунке:

Распечатайте объект диапазона в это время:

Свойства имеют следующие значения:
* startContainer: Начальный узел диапазона.
* endContainer: конечный узел диапазона
* startOffset: Смещение начальной позиции диапазона.
* endOffset: Смещение конечной позиции диапазона.
* commonAncestorContainer: возвращает самый глубокий узел, содержащий startContainer и endContainer.
* свернуто: вернуть значение для суждения Логическое значение, указывающее, совпадают ли начальная и конечная позиции диапазона.

Вот наши startContainer, endContainer и commonAncestorContainer.#textТекстовый узел «Команда Baidu EUX». Поскольку курсор находится за словом «градусы», обе величины startOffset и endOffset равны 2. И нет перетаскивания синего, поэтому значение свернутое верно. Давайте посмотрим на другой пример, который создает синий цвет перетаскивания:

Положение курсора показано на рисунке:

Распечатайте объект диапазона в это время:

Так как startContainer и endContainer больше не согласованы, значение свернутого становится ложным. startOffset и endOffset просто представляют начальную и конечную позиции перетаскивания синего цвета. Чтобы получить больше эффектов, попробуйте сами.

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

  • setStart(): установить начальную точку диапазона
  • setEnd(): установить конечную точку диапазона
  • selectNode(): установить диапазон, содержащий узлы и содержимое узлов
  • коллапс (): свернуть диапазон к указанной конечной точке
  • insertNode(): вставить узел в начале диапазона.
  • cloneRange(): возвращает клонированный объект Range с теми же конечными точками, что и исходный Range.

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

Изменить положение курсора

Мы можем позвонитьsetStart()а такжеsetEnd()метод, чтобы изменить положение курсора или перетащить синий диапазон. Параметры, принимаемые этими двумя методами, являются их соответствующими начальными и конечными узлами и смещениями. Например, если я хочу, чтобы позиция курсора находилась в конце команды Baidu EUX, можно использовать следующий метод:

let range = window.getSelection().getRangeAt(0),
    textEle = range.commonAncestorContainer;
range.setStart(range.startContainer, textEle.length);
range.setEnd(range.endContainer, textEle.length); 

Добавляем таймер, чтобы увидеть эффект:

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

function resetRange(startContainer, startOffset, endContainer, endOffset) {
    let selection = window.getSelection();
        selection.removeAllRanges();
    let range = document.createRange();
    range.setStart(startContainer, startOffset);
    range.setEnd(endContainer, endOffset);
    selection.addRange(range);
}

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

Изменить форматирование текста

Чтобы реализовать редактор форматированного текста, нам нужно иметь возможность изменять формат документа, например выделение полужирным шрифтом, курсивом, цветом текста, списком и т. д. DOM предоставляет редактируемые областиdocument.execCommandметод, который позволяет запускать команды для управления содержимым редактируемой области. Большинство команд влияют на выделение документа (жирный шрифт, курсив и т. д.), а другие вставляют новые элементы (добавляют ссылки) или воздействуют на целые строки (отступ). При использовании contentEditable вызов execCommand() повлияет на текущий активный редактируемый элемент. Синтаксис следующий:

bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)

  • aCommandName: DOMString, имя команды. См. Команды для списка доступных команд.
  • aShowDefaultUI: логическое значение, отображающее ли пользовательский интерфейс, обычно false. Mozilla не реализует это.
  • aValueArgument: для некоторых команд (таких как insertImage) требуются дополнительные параметры (insertImage должен предоставить URL-адрес вставленного изображения), значение по умолчанию — null.

Короче говоря, браузеры могут реализовать большинство функций, которые мы думаем о редакторах форматированного текста, поэтому я не буду демонстрировать их здесь одну за другой. Заинтересованные студенты могут просмотретьMDN — document.execCommand.

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

Начинается настоящая битва, путешествие по заполнению ямы

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

Исправить эффекты браузера по умолчанию

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

перевод строки возврата каретки

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


Видно, что первый введенный текст не переносится, а содержимое, сгенерированное символом новой строки, является элементом переноса.<div>Этикетка. Для того, чтобы текст был<p>элементы завернуты.
При инициализации нам нужно<div>Вставить по умолчанию<p><br></p>элемент(<br>Метка используется для заполнения места и будет автоматически удалена после ввода содержимого). Таким образом, новый контент, генерируемый каждым возвратом каретки, будет<p>Элемент обернут (в редактируемом состоянии новая структура, сгенерированная возвратом каретки и переводом строки, по умолчанию скопирует предыдущее содержимое, узлы переноса, имена классов и другое содержимое).
Нам также нужно прослушать событие keyUpevent.keyCode === 8удалить ключ. Когда все содержимое в редакторе будет очищено (кнопка удаления также будет<p>удаление тега), чтобы присоединиться<p><br></p>метку и поместите курсор внутрь нее.

Вставьте ul и ol в неправильном месте

когда мы звонимdocument.execCommand("insertUnorderedList", false, null)При вставке списка новый список будет вставлен<p>в этикетке.

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

function adjustList() {
    let lists = document.querySelectorAll("ol, ul");
     for (let i = 0; i < lists.length; i++) {
        let ele = lists[i]; // ol
        let parentNode = ele.parentNode;
        if (parentNode.tagName === 'P' && parentNode.lastChild === parentNode.firstChild) {
                parentNode.insertAdjacentElement('beforebegin', ele);
                parentNode.remove()
        }
    }
}

Вот небольшой побочный вопрос, я пытаюсь<li><p></p></li>Сохраняйте такую ​​структуру редактора (по умолчанию нет<p>этикетка). Эффект отлично работает под хромом. Но в сафари возврат каретки никогда не производит новых<li>ярлык, это перейти к эффекту списка, который должен иметь.

Вставить разделительную линию

передачаdocument.execCommand('insertHorizontalRule', false, null);вставит<hr>Этикетка. Однако результат таков:

курсор и<hr>Эффект тот же. Для этого необходимо определить, является ли текущий курсор<li>внутри, если так в<hr>Добавить пустой текстовый узел после#textЕсли нет, добавьте<p><br></p>. Затем поместите в него курсор и найдите его следующими способами.

/**
 * 查找父元素
 * @param {String} root 
 * @param {String | Array} name 
 */
function findParentByTagName(root, name) {
    let parent = root;
    if (typeof name === "string") {
        name = [name];
    }
    while (name.indexOf(parent.nodeName.toLowerCase()) === -1 && parent.nodeName !== "BODY" && parent.nodeName !== "HTML") {
        parent = parent.parentNode;
    }
    return parent.nodeName === "BODY" || parent.nodeName === "HTML" ? null : parent;
},

вставить ссылку

передачаdocument.execCommand('createLink', false, url);метод Мы можем вставить URL-ссылку, но этот метод не поддерживает вставку ссылок с указанным текстом. При этом новые ссылки можно многократно вставлять на позиции, на которые уже есть ссылки. Для этого нам нужно переопределить этот метод.

function insertLink(url, title) {
    let selection = document.getSelection(),
        range = selection.getRangeAt(0);
    if(range.collapsed) {
        let start = range.startContainer,
            parent = Util.findParentByTagName(start, 'a');
        if(parent) {
            parent.setAttribute('src', url);
        }else {
            this.insertHTML(`<a href="${url}">${title}</a>`);
        }
    }else {
        document.execCommand('createLink', false, url);
    }
} 

Установите заголовки h1 ~ h6

В браузере нет готового метода, но мы можем использоватьdocument.execCommand('formatBlock', false, tag), для достижения код выглядит следующим образом:

function setHeading(heading) {
    let formatTag = heading,
        formatBlock = document.queryCommandValue("formatBlock");
    if (formatBlock.length > 0 && formatBlock.toLowerCase() === formatTag) {
        document.execCommand('formatBlock', false, ``);
    } else {
        document.execCommand('formatBlock', false, ``);
    }
}

Вставить пользовательский контент

Когда редактор загружает или загружает вложение, вставьте<div>Карточка узла для редактирования. Здесь мы используемdocument.execCommand('insertHTML', false, html);для вставки содержимого. Чтобы предотвратить редактирование div, установитеcontenteditable="false"Ой.

паста для обработки

В редакторе форматированного текста эффект вставки по умолчанию соответствует следующим правилам:

  1. Если это текст с форматированием, форматирование сохраняется (форматирование будет преобразовано в форму html-тегов)
  2. Вставьте содержимое смешанного изображения и текста, изображение может отображаться, а src — это реальный адрес изображения.
  3. При вставке путем копирования изображения содержимое не может быть вставлено
  4. Вставить содержимое другого формата, не удается вставить содержимое

Чтобы иметь возможность контролировать то, что наклеено, мы слушаемpasteмероприятие. Объект события этого события будет содержать объект буфера обмена clipboardData. Мы можем использовать метод getData этого объекта для получения форматированного и неформатированного содержимого следующим образом.

let plainText = event.clipboardData.getData('text/plain');  // 无格式文本
let plainHTML = event.clipboardData.getData('text/html');   // 有格式文本

звонить послеdocument.execCommand('insertText', false, plainText);илиdocument.execCommand('insertHTML', false, plainHTML;чтобы переписать эффект вставки в редакторе.

Однако для правила 3 ​​вышеуказанная схема не может быть обработана. Здесь мы хотим представитьevent.clipboardData.items. Это массив, содержащий все объекты содержимого буфера обмена. Например, если вы копируете изображение для вставки, тоevent.clipboardData.itemsДлина 2:
items[0]— это имя изображения, items[0].kind — это «строка», items[0].type — это «text/plain» или «text/html». Способ получения контента следующий:

items[0].getAsString(str => {
    // 处理 str 即可
}) 

items[1]Это двоичные данные изображения, items[1].kind — это «файл», а items[1].type — это формат изображения. Чтобы получить содержимое внутри, нам нужно создатьFileReaderобъект. Пример кода выглядит следующим образом:

let file = items[1].getAsFile();
// file.size 为文件大小
let reader = new FileReader();
reader.onload = function() {
    // reader.result 为文件内容,就可以做上传操作了
}
if(/image/.test(item.type)) {
    reader.readAsDataURL(file);   // 读取为 base64 格式
}

После обработки изображения как насчет копирования и вставки содержимого в других форматах? В Mac, если вы копируете файл на диске, длина event.clipboardData.items равна 2. items[0] — это по-прежнему имя файла, но items[1] — это изображение, да, миниатюра файла.

Обработка метода ввода

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

  • CompositionStart: когда в браузере есть непрямой ввод текста, событие CompositionStart запускается в синхронном режиме.
  • Compositionend: когда браузер напрямую вводит текст, CompositionEnd будет запускаться в синхронном режиме.

Устранение проблем с мобильным устройством

Что касается мобильных устройств, то проблемы с текстовыми редакторами в основном связаны с курсором и клавиатурой. Я представлю здесь несколько больших ям.

получить фокус автоматически

Если вы хотите, чтобы наш редактор автоматически получал фокус, всплывающая программная клавиатура, вы можете использоватьfocus()метод. Однако под ios жизнь и смерть не имеют результатов. В основном это связано с тем, что в ios Safari коду не разрешено получать фокус по соображениям безопасности. Щелкнуть можно только через взаимодействие с пользователем. К счастью, это ограничение можно снять:

[self.appWebView setKeyboardDisplayRequiresUserAction:NO]

Возврат каретки и перевод строки под iOS, полоса прокрутки не будет прокручиваться автоматически

В iOS, когда мы вводим возврат каретки и перевод строки, полоса прокрутки не прокручивается вместе с ней. Таким образом, курсор может быть заблокирован клавиатурой, и это не очень хорошо. Чтобы решить эту проблему, нам нужно контролироватьselectionchangeСобытие, когда оно запускается, каждый раз вычисляет расстояние до верхней части редактора курсора, а затем вызывает window.scroll() для его решения. Проблема в том, как мы вычисляем позицию текущего курсора, если мы вычисляем только позицию родительского элемента, где находится курсор, могут быть отклонения (расчет многострочного текста не точен). Мы можем сделать это, создав временный<span>Элемент находит позицию курсора и вычисляет<span>положение элемента. код показывает, как показано ниже:

function getCaretYPosition() {
    let sel = window.getSelection(),
        range = sel.getRangeAt(0);
    let span = document.createElement('span');
    range.collapse(false);
    range.insertNode(span);
    var topPosition = span.offsetTop;
    span.parentNode.removeChild(span);
    return topPosition;
}

Когда я был счастлив, Android-сторона откликалась, и редактор все больше и больше застревал. Что за черт? Я проверил это в Интернете в Chrome и нашелselectionchangeФункция работает всегда, независимо от того, выполняется операция или нет.
Этот факт был обнаружен при расследовании поодиночке.range.insertNodeФункция также срабатываетselectionchangeмероприятие. Это образует бесконечный цикл. Этот бесконечный цикл не будет происходить в сафари, а только в сафари, для этого нам нужно добавить оценку типа браузера.

Клавиатура всплывает, чтобы заблокировать часть ввода

Основное решение этой проблемы в Интернете — установка таймера. Ограничения и интерфейс, действительно может использовать только такое топорное решение. Наконец, мы позволяем ученикам iOS вычесть высоту программной клавиатуры из высоты веб-просмотра, когда клавиатура всплывает.

CGFloat webviewY = 64.0 + self.noteSourceView.height;
self.appWebView.frame = CGRectMake(0, webviewY, BDScreenWidth, BDScreenHeight - webviewY - height); 

Не удалось вставить изображение

На мобильной стороне вызовите jsbridge, чтобы вызвать фотоальбом для выбора изображения. звонить послеinsertImageФункция для вставки изображения в редактор. Однако вставить картинку не получается. Я наконец узнал, что это потому, что в раннем сафари, если редактор теряет фокус, тоselectionа такжеrangeОбъект будет уничтожен. так позвониinsertImage, положение курсора не может быть получено, поэтому происходит сбой. Для этого необходимо увеличитьbackupRange()а такжеrestoreRange()функция. Запишите информацию о диапазоне, когда страница теряет фокус, и восстановить информацию о диапазоне перед вставкой изображения.

backupRange() {
    let selection = window.getSelection();
    let range = selection.getRangeAt(0);
    this.currentSelection = {
        "startContainer": range.startContainer,
        "startOffset": range.startOffset,
        "endContainer": range.endContainer,
        "endOffset": range.endOffset
    }
}
restoreRange() {
    if (this.currentSelection) {
        let selection = window.getSelection();
            selection.removeAllRanges();
        let range = document.createRange();
        range.setStart(this.currentSelection.startContainer, this.currentSelection.startOffset);
        range.setEnd(this.currentSelection.endContainer, this.currentSelection.endOffset);
        // 向选区中添加一个区域
        selection.addRange(range);
    }
}

В хроме потеря фокуса не очищаетсяselecitonобъект иrangeобъект, поэтому мы можем легко одинfocus()Вот и все.

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

Другие функции

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

формат перевода строки возврата каретки

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

Получить стиль текущей позиции курсора

Здесь мы в основном рассматриваем такие стили, как подчеркивание и зачеркивание, которые описываются классами надписей, поэтому нам нужно пройти уровень надписей. Перейдите непосредственно к коду:

function getCaretStyle() {
    let selection = window.getSelection(),
        range = selection.getRangeAt(0);
        aimEle = range.commonAncestorContainer,
        tempEle = null;
    let tags = ["U", "I", "B", "STRIKE"],
        result = [];
    if(aimEle.nodeType === 3) {
        aimEle = aimEle.parentNode;
    }
    tempEle = aimEle;
    while(block.indexOf(tempEle.nodeName.toLowerCase()) === -1) {
        if(tags.indexOf(tempEle.nodeName) !== -1) {
            result.push(tempEle.nodeName);
        }
        tempEle = tempEle.parentNode;
    }
    let viewStyle = {
        "italic": result.indexOf("I") !== -1 ? true : false,
        "underline": result.indexOf("U") !== -1 ? true : false,
        "bold": result.indexOf("B") !== -1 ? true : false,
        "strike": result.indexOf("STRIKE") !== -1 ? true : false
    }
    let styles = window.getComputedStyle(aimEle, null);
    viewStyle.fontSize = styles["fontSize"],
    viewStyle.color = styles["color"],
    viewStyle.fontWeight = styles["fontWeight"],
    viewStyle.fontStyle = styles["fontStyle"],
    viewStyle.textDecoration = styles["textDecoration"];
    viewStyle.isH1 = Util.findParentByTagName(aimEle, "h1") ? true : false;
    viewStyle.isH2 = Util.findParentByTagName(aimEle, "h2") ? true : false;
    viewStyle.isP = Util.findParentByTagName(aimEle, "p") ? true : false;
    viewStyle.isUl = Util.findParentByTagName(aimEle, "ul") ? true : false;
    viewStyle.isOl = Util.findParentByTagName(aimEle, "ol") ? true : false;
    return viewStyle;
} 

Последнее слово

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

Справочное содержание

Как один (4)