Организуем выделение текста в textarea

В разработке интерфейсов иногда можно встретиться с задачей выделения вводимого пользователем текста в зависимости от определенных условий. (Например, была реализована серверная проверка грамматики, либо необходимо выделять определенным цветом те или иные слова\участки и т.д.)
Однако, элемент textarea не поддерживает html\bb теги. Как один из способов решения — использование contenteditable в элементах div.
В данной небольшой статье я предлагаю более-менее подробно рассмотреть способ выделения текста, используя textarea.

Общая идея решения

Так как не существует способа добавить поддержку тегов в textarea, то следующим вариантом является использование «многослойности».
С помощью z-index и абсолютного позиционирования поместим блок pre за необходимым нам textarea.
В элементе pre настроим шрифт аналогичный textarea и также зададим css свойства, которые нам позволят зеркально повторять текст блока textarea (о них чуть ниже)
Также создадим класс, который будет после каждого изменения содержимого textarea синхронизировать данные, осуществлять поиск данных, необходимых для выделения.
Иллюстрация данного решения:
11059ebe21368277e2f38ffa04b50c0e

Разбираем решение

За основную задачу возьмем — создание класса, который на вход получает значения целевой ноды (textarea), функции — проверки на выделение и значение шрифта (css свойства font)
Конструктор класса должен добавить в DOM-модель документа необходимый элемент pre, позиционировать его и повесить события. Сюда же можно добавить установку css свойств.

/**
  * Создает экземпляр "интерактивного" textarea
  * @name TextareaExtension
  * @param target - целевой нода textarea
  * @param processor - функция для проверки слова на выделение
  * @param font - шрифт 
  */
function TextareaExtension(target , processor, font)
{
    var setStyleOptions = function()
    {
        //Добавляем класс (чтобы не прописывать все свойства), добавляем в DOM, устанавливаем font
        preItem.className = "text-area-selection";
        target.parentNode.appendChild(preItem);
        target.style.font = preItem.style.font = font || "14px Ariel";

        //Определяем позиционирование, прозрачность, сразу же устанавливаем скроллы
        target.style.width = preItem.style.width = target.offsetWidth + "px";
        target.style.height = preItem.style.height = target.offsetHeight + "px";
        preItem.style.top = target.offsetTop + "px";
        preItem.style.left = target.offsetLeft + "px";
        target.style.background = "transparent";
        target.style.overflow = "scroll";

        //Для тега pre свойство margin по умолчанию = 1em 0px. Поставим нулевые значения. 
        //(при использовании, например span вместо pre такая проблема отпадает)
        preItem.style.margin = "0px 0px";
    }

    setStyleOptions();

    //Добавляем события
    if (target.addEventListener) {
        //При изменении анализируем новое состояние textarea
        target.addEventListener("change", this.analyse, false);
        target.addEventListener("keyup", this.analyse, false);
        //Если текста было введено много - необходимо синхронизировать скролы textarea и pre
        target.addEventListener("scroll", this.scrollSync, false);
        //Также ставим обработчик на resize
        target.addEventListener("mousemove", this.resize, false);
    }
    else
        if (target.attachEvent) {
            target.attachEvent("onchange", this.analyse);
            target.attachEvent("onkeyup", this.analyse);
            target.attachEvent("onscroll", this.scrollSync);
            target.attachEvent("mousemove", this.resize);
        }
}

 

Итак, каркас класса создан. Определим оставшиеся методы класса:

this.scrollSync = function () {
        preItem.scrollTop = target.scrollTop;
    };

    this.resize = function () {
        preItem.style.width = target.style.width;
        preItem.style.height = target.style.height;
        preItem.style.top = target.offsetTop  + "px";
        preItem.style.left = target.offsetLeft + "px";
    };

     this.analyse = function (){

        var text = target.value;
        var words = text.split(/[\s]/);
        var textPosition = 0;
        var result = "";

       for (var i in words) {
            if (processor(words[i])) {
                var textIndex;
                if (text.indexOf) {
                    textIndex = text.indexOf(words[i]);
                }
                else textIndex = findText(text, words[i]);

                result += text.substr(0, textIndex) + "<span class='text-color-bordered text-checker'>" + words[i] + "</span>";

                text = text.substr(textIndex + words[i].length, text.length);
            }
        }
        result += text;

        preItem.innerHTML = result;
    };

 

Метод analyse перебирает каждое слово, отправляя его в определенную заранее функцию. Если слово должно выделяться — метод копирует предыдущее содержимое в pre и «оборачивает» необходимое слово в span с классом, определяющим способ выделения (в данном примере — нижнее точечное подчеркивание)
Для браузеров, не поддерживающих функцию indexOf, определим метод прямого поиска — findText (в нем реализуем прямой проход по массивам)

CSS-свойства

Приведем список определенных свойств, а затем разберем их:

.text-area-selection {
    position:absolute;  
    padding:2px; 
    z-index:-1;
    display:block;
    word-wrap:break-word;  
    white-space:pre-wrap;
    color:white;
    overflow:scroll;
}

.text-color-bordered {
    border-bottom:1px dotted red;
}

 

Как уже было сказано, элемент pre должен позиционироваться под textarea, поэтому позиционируем его абсолютно и устанавливаем z-index в -1. Добавляем отступы, скроллы.
Теперь перейдем к определениям word-wrap и white-space. В данной задаче эти свойства играют очень важную роль.
white-space: pre-wrap позволяет учитывать все пробелы в строках и в то же время он не позволяет продолжать текст горизонтально (переносит его), если он не помещается в 1 строку
word-wrap:break-word — определяет поведение текста, при котором слова, не помещающиеся на 1 строку не растягивают элемент, а переносятся на другую строку.

В результате мы получаем выделение текста по результату работы нашей функции:
7b6a9ba56582c2a402e1c467028ff762

Исходники:
Ссылка на GitHub
CodePen

Расширения

В данном примере представлен способ работы для встроенной функции.
В тех или иных ситуациях возможны случаи, когда результат (выделять\не выделять) необходимо получить от сервера. В таком случае возможно воспользоваться паттерном команда, чтобы отправлять серверу только список изменений. А, непосредственно, выделение организовать в отдельной callback функции.