Эмуляция текстового поля input

Современный front end порой требует решения ряда нетривиальных задач, подходы к которым редко освещаются в тематических ресурсах. В частности, одна из таких дилемм, с которой можно столкнуться в процессе разработки, например, веб-чата, — это создание однострочного поля ввода (input) с возможностью вставки смайлов emoji.

Зачем заменять стандартный текстовый input?

Стандартное текстовое поле в HTML (<input type="text" />) предназначается для ввода, редактирования и отправки исключительно простого текста, поэтому оно не может быть использовано там, где внутри самого поля требуется интерактивность и наличие других дочерних элементов DOM, которые добавляются или удаляться по желанию пользователя. Речь идёт, в частности, о возможности вставки смайликов, выделении и стилизации отдельного контента при помощи CSS, перетаскивании элементов внутри поля и т. п. Подобная интерактивность элементов ввода, которую можно встретить в веб-чатах, социальных сетях и сервисах, реализована при помощи HTML5, а именно — благодаря атрибуту contenteditable, позволяющему пользователю редактировать содержимое страницы непосредственно в браузере.

Атрибут обеспечивает возможность создания уникальных WYSIWYG-редакторов и полей ввода, избегающих ограничений обычных тегов для создания форм. Однако, чтобы сделать элемент с contenteditable доступным для корректного кроссбраузерного редактирования и придать ему визуальную интерактивность, требуется решить ряд задач на CSS и JS.

Использование атрибута вместе с необходимыми стилями позволяет любому блоку визуально эмулировать <input type="text" /> или <textarea>, при том возможности редактирования такого блока будут куда шире, чем у стандартных элементов формы.

Блок с contenteditable заменяет input для ввода сообщений ВКонтакте Блок с contenteditable заменяет input для ввода сообщений ВКонтакте
Так в HTML-разметке выглядит поле для отправки сообщений ВКонтакте. Без атрибута contenteditable невозможно создать по-настоящему многофункциональную область ввода.

Создание однострочного поля ввода

В сети можно встретить примеры реализации текстовых полей с возможностью вставки смайликов emoji и выделением дочерних элементов посредством contenteditable, но подавляющее большинство из них — это аналоги многострочных <textarea>. Принцип эмуляции <input> и <textarea> практически одинаков за исключением того, что в однострочном текстовом поле присутствует горизонтальная прокрутка и не допускается перенос строк, что должно учитываться в CSS и JS. Существует также несколько «подводных камней», связанных с обеспечением кроссбраузерности и ограничениями при манипуляциях с контентом внутри поля.

HTML-разметка

Из верстки страницы отправки сообщений ВКонтакте видно, что текстовое поле состоит как минимум из двух элементов — непосредственно блока для ввода и его шаблонного текста, то есть аналога атрибута placeholder, который представлен отдельным элементом. Технически, их можно объединить и в один блок с псевдоэлементом ::before или ::after в качестве замещающего текста, но в этом случае в Firefox появляется неустранимый баг, из-за которого неправильно вычисляется вертикальное положение каретки, поэтому для подсказки предпочтительнее использовать отдельный элемент, который будет «накладываться» на поле ввода посредством абсолютного позиционирования и отображаться тогда, когда оно не содержит контента:

<div id="input-box">
  <div id="input-box-field" contenteditable="true"></div>
  <div id="input-box-placeholder"></div>
</div>

В будущем строка ввода #input-box-field обзаведётся атрибутами tabindex, spellcheck и autocomplete, значения которых можно будет заранее преднастроить в аргументах при вызове соответствующей JS-функции.

Для выбора с последующим добавлением смайлов в текстовую строку потребуется отдельный блок, который будет содержать список всех доступных emoji, — панель смайликов:

<div id="emoji-box"></div>

Кроссбраузерное (S)CSS-оформление

Общее визуальное оформление строки (цвета, габариты блока, курсор, позиционирование подсказки и прочее) полностью зависит от фантазии верстальщика, но есть пара моментов, которые рекомендуется учесть в CSS:

  1. текстовый блок должен горизонтально прокручиваться, а видимый скроллбар следует скрывать;
  2. стоит подстраховаться от попадания в поле посторонних тегов: например, во время нажатия клавиши Enter (в ситуации с однострочным полем эта клавиша должна отвечать за отправку формы, а не за перенос строк) Firefox автоматически вставляет в область с contenteditable перенос строки — <br type="_moz" />, а начиная с версии 60 этот элемент был заменен на <div>:
// обертка для строки ввода
#input-box {
  cursor: text;
  position: relative;
  overflow: hidden;
  max-height: 1rem; // контент должен обрезаться по области видимого содержимого дочерней строки (1rem используется в качестве примера)

  // строка ввода
  #input-box-field {
    position: absolute;
    top: 0;
    width: 100%;
    white-space: nowrap;
    overflow-x: scroll;
    min-height: 2rem; // вертикальное расширение строки и визуальное скрытие горизонтального скроллбара (для этого высота должна быть больше высоты родительного элемента)
    resize: none;

    // отображение или скрытие элемента placeholder
    &:not(:empty) ~ #input-box-placeholder { display: none; }

    // скрытие лишних элементов внутри поля, которые могут в него попасть из-за особенностей браузеров
    br, p, div { display: none; }

  }

  // placeholder
  #input-box-placeholder {
    position: absolute;
    top: 0;
    width: 100%;
    pointer-events: none; // пользователь никак не должен взаимодействовать с этим элементом напрямую
    &::before { content: attr(data-ph); } // вывод текста placeholder через атрибут data-p
  }

}

(S)CSS для смайликов emoji

Для отображения emoji необходимо создать соответствующий спрайт и настроить элемент, который будет выводить конкретное изображение. Для облегчения работы автором уже был собран миниатюрный emoji-спрайт с наиболее популярными смайлами, размер каждого из которых составляет стандартные 16px. Такие же смайлы используются ВКонтакте.

В строку ввода #input-box-field смайл будет вставляться через элемент <img>, атрибут src которого содержит прозрачное изображение в base64, класс .emoji — общие стили, а второй класс .e[номер смайла] — свойство background-position, содержащее координаты каждого смайла из спрайта, благодаря чему осуществляется наложение конкретного emoji на исходную прозрачную картинку:

<img class="emoji e[номер смайла]" src="data:image/png;base64,..." width="16" height="16" alt="" />

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

.emoji {
  cursor: default;
  width: 16px;
  height: 16px;
  display: inline-block;
  background: url(https://web3r.ru/img/emoji.png) no-repeat;
  &.e1 { background-position: -0 -0; }
  &.e2 { background-position: -16px -0; }
  &.e3 { background-position: -32px -0px; }
  // etc.
}

Не стоит забывать и о стилях для смайла, который уже окажется в строке ввода. Его следует вертикально выровнять относительно текста, способ выравнивания будет зависеть от метрик конкретного шрифта. Дополнительно для emoji необходимо задать фиксированные размеры — минимальную и максимальной ширину и высоту, что предотвращает возможность пользовательского изменения размера картинок («resize controls»), попавших в блок с contenteditable, в браузере IE:

#input-box-field {
  .emoji {
    display: inline;
    cursor: text;
    max-width: 16px;
    min-width: 16px;
    max-height: 16px;
    min-height: 16px;
    vertical-align: middle;
  }
}

Панель выбора смайликов

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

<div id="emoji-box">
  <div>
    <span class="emoji e1"></span>
    <span class="emoji e2"></span>
    <span class="emoji e3"></span>
    <!-- etc. -->
  </div>
</div>

JavaScript

JS для текстового поля представлен объектом einput(), в метод init() которого в качестве аргумента может быть передан объект с предварительными настройками:

einput.init({
  fieldId:      "input-box",         // идентификатор элемента, куда вставляется поле
  placeholder:  "Type something...", // замещающий текст для поля
  autocomplete: false,               // атрибут autocomplete
  spellcheck:   true,                // атрибут spellcheck
  autofocus:    true,                // автоматический фокус в поле при загрузки страницы
  typefocus:    true,                // автоматический фокус в поле при печатании
  pastecheck:   true,                // форматирование вставляемого контента
  tabindex:     "-1",                // атрибут tabindex
  maxlength:    1000                 // максимальный размер контента в поле
  callback:     function(text) {     // функция, выполняемая после отправки сообщения (нажатия клавиши Enter)
    console.log(text);
    einput.clear();
  },
  panelId:      "emoji-box",         // идентификатор элемента, куда вставляется панель смайлов
  emojiCount:   207                  // максимальное количество генерируемых смайлов (число в спрайте)
});

У объект einput также есть следующие методы, доступные в глобальном window:

  1. einput.init(settings) — инициализация функции с заданными настройками (см. выше);
  2. einput.format(e, maxlength) — форматирование, т. е. проверка и очистка контента от HTML-тегов при вставке контента в текстовое поле, а также ограничение на максимальную длину сообщения (аналог атрибута maxlength);
  3. einput.insert(e) — вставка emoji в любую часть содержимого строки сразу после позиции каретки или в конец введеного контента;
  4. einput.clear(tag) — очистка поля от контента либо от указанных тегов (например, <br />);
  5. einput.get() — получение данных, введеных в поле, с заменой смайлов на символы с номером;
  6. einput.caret(el, place) — фокусировка в поле и установка каретки в опредленное положение (в качестве аргумента el используется как само поле, так и отдельный смайл или пробел после него);

Для удобства дальнейших маниупцляций с DOM (например, в callback-фукнциях) в einput.els представлен список сгенерированных элементов (обертка для поля, поле, placeholder, обертка для панели, панель смайлов).

/**
 * Анонимная самовызывающаяся функция-обертка
 * @param {document} d - получает документ
 */
!function(d) {

  "use strict";

  /**
   * Переменная необходима для правильной работы форматирования в IE
   * @type {boolean}
   */
  var _onPaste_Strip = false;

  /**
   * Главный объект
   * @type {Object}
   */
  var einput = {};

  /**
   * Shortcut для preventDefault()
   * @param {Event} e - получает событие
   */
  var prevent = function(e) {
    e.preventDefault();
  };

  /**
   * Функция форматирования контента при вставке в поле
   * @param {Event} e - событие
   * @param {Number} maxlength - максимальная длина контента
   */
  einput.format = function(e, maxlength) {
    if (e.originalEvent && e.originalEvent.clipboardData && e.originalEvent.clipboardData.getData) {
      e.preventDefault();
      var text = e.originalEvent.originalEvent.clipboardData.getData("text/plain");
      if (text.length > maxlength) text = text.substring(0, maxlength);
      d.execCommand("insertText", false, text);
    } else if (e.clipboardData && e.clipboardData.getData) {
      e.preventDefault();
      var text = e.clipboardData.getData("text/plain");
      if (text.length > maxlength) text = text.substring(0, maxlength);
      d.execCommand("insertText", false, text);
    } else if (window.clipboardData && window.clipboardData.getData) {
      if (!_onPaste_Strip) {
        _onPaste_Strip = true;
        e.preventDefault();
        d.execCommand("ms-pasteTextOnly", false);
      }
      _onPaste_Strip = false;
    }
  };

  /**
   * Функция установки каретки в необходимую позицию
   * @param {Object} el - необходимый элемент
   * @param {String} [place] - положение каретки
   */
  einput.caret = function(el,place) {
    el = el || einput.els.field;
    var range = d.createRange();
    var sel = function() {
      var s = window.getSelection();
      s.removeAllRanges();
      s.addRange(range);
    };
    switch(place) {
      case "after": // после указанного элемента
        range.setStartAfter(el);
        range.setEndAfter(el);
        if(d.getSelection) sel();
        break;
      case "select": // выделение всего контента в элементе
        range.selectNodeContents(el);
        sel();
        break;
      default: // вставка в элемент после контента
        el.focus();
        range.selectNodeContents(el);
        range.collapse(false);
        sel();
    }
  };

  /**
   * Функция очистки поля
   * @param {String} tag - удаляемый тег
   */
  einput.clear = function(tag) {
    var el = einput.els.field;
    if(tag) {
      var tags = el.getElementsByTagName(tag);
      while (tags[0]) tags[0].parentNode.removeChild(tags[0]);
    } else while (el.firstChild) el.removeChild(el.firstChild);
    einput.caret();
  };

  /**
   * Функция получения контента поля
   * @returns {String} - возвращает введеный текст
   */
  einput.get = function() {
    einput.clear("br");
    return einput.els.field.innerHTML.replace(//g, "*$1*").replace(/ /g, " ").replace(/ss+/g, " ").trim();
  };

  /**
   * Функция вставки смайлика в строку
   * @param {Event} e - событие, возникающее при клике по смайлику из панели
   */
  einput.insert = function(e) {
    var el = einput.els.field;

    // пробелы до и после смайла
    var spaceBefore = d.createTextNode("u00A0");
    var spaceAfter = d.createTextNode("u00A0");

    // генерация смайла
    var img = d.createElement("img");
    img.classList.add("emoji");
    img.classList.add(e.target.classList[1]);
    img.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAAtJREFUCNdjIBEAAAAwAAFletZ8AAAAAElFTkSuQmCC";
    img.width = 16;
    img.height = 16;
    img.setAttribute("onresizestart", "return false");
    img.setAttribute("oncontrolselect", "return false");

    // вставка смайлика в опредленное кареткой место (если поддерживается браузером)
    if (window.getSelection) {
      var sel = window.getSelection();
      if (sel.getRangeAt && sel.rangeCount) {
        var currentEl = sel.focusNode.tagName ? sel.focusNode : sel.focusNode.parentNode;
        if (currentEl === el || currentEl === el.parentNode) {
          var range = window.getSelection().getRangeAt(0);
          range.insertNode(spaceBefore);
          range.insertNode(img);
          if (el.innerHTML.length > 0) range.insertNode(spaceAfter);
          einput.caret(spaceBefore,"after");
          return true;
        }
      }
    }

    // Стандартная вставка смайла, если положение каретки не задано
    el.appendChild(spaceBefore);
    el.appendChild(img);
    el.appendChild(spaceAfter);
    einput.caret();
  };

  /**
   * Функция инициализации поля и панели смайлов
   * @param {Object} [settings] - принимает объект с настройками
   */
  einput.init = function(settings) {

    // преднастройки
    settings = Object.assign({},{
      inputId:      "input-box",         
      placeholder:  "Type something...", 
      autocomplete: false,            
      spellcheck:   true,               
      autofocus:    true,  
      typefocus:    true,         
      pastecheck:   true,
      tabindex:     "-1",  
      maxlength:    1000, 
      callback:     function(text) { 
        console.log(text);
        einput.clear();
      },
      panelId:           "emoji-box",  
      emojiCount:        207 
    }, settings);

    // подготовка атрибутов autocomplete и spellcheck
    settings.autocomplete = (settings.autocomplete) ? "on" : "off";
    settings.spellcheck = (settings.spellcheck) ? "true" : "false";

    // поиск начальных элементов
    var inputBox = d.getElementById(settings.inputId);
    var emojiBox = d.getElementById(settings.panelId);

    // генерация поля ввода
    inputBox.setAttribute("contenteditable","false");
    inputBox.insertAdjacentHTML('afterbegin', '
'); // генерация панели смайлов emojiBox.insertAdjacentHTML("afterbegin", "
"); var frag = d.createDocumentFragment(); var emoji = {}; for (var k = 0; k < settings.emojiCount; k++) { emoji["*" + k] = k; var smile = d.createElement("span"); smile.classList.add("emoji"); smile.classList.add("e" + k); frag.appendChild(smile); } emojiBox.firstChild.appendChild(frag); // вывод элементов для глобального использования einput.els = { fieldBox: inputBox, field: inputBox.firstChild, fieldPh: inputBox.lastChild, panelBox: emojiBox, panel: emojiBox.firstChild }; // добавление обработчиков var input = einput.els.field; input.addEventListener("drag", prevent); input.addEventListener("drop", prevent); input.addEventListener("resize", prevent); input.addEventListener("click", function(e) {// вставка каретки после добавленного смайла if (e.target.classList.contains("emoji")) einput.caret(input,"after"); }); input.addEventListener("keypress", function(e) { var key = e.which || e.keyCode; var l = this.textContent.length; // ... if (l > settings.maxlength) (key === 8 || key === 46 || key === 39 || key === 37) ? null : prevent(e); if (key === 13) { // переопредление действия клавиши Enter // изменение стандартного браузерного форматирования contenteditable if(d.queryCommandSupported("defaultParagraphSeparator")) d.execCommand("defaultParagraphSeparator", false, ""); if(d.queryCommandSupported("insertHTML")) d.execCommand("insertHTML", false, ""); if(d.queryCommandSupported("insertBrOnReturn")) d.execCommand("insertBrOnReturn", false, ""); // вызов callback, в который передается текст settings.callback(einput.get()); prevent(e); } }); if (settings.autofocus) einput.caret(); if (settings.pastecheck) input.addEventListener("paste", function(e) { einput.format(e, settings.maxlength); }); if (settings.typefocus) { d.addEventListener("keydown", function(e) { if (d.activeElement !== input) einput.caret(); e.stopPropagation(); }); } // отключение возможности resize для строки if(d.queryCommandSupported("enableObjectResizing")) d.execCommand("enableObjectResizing", false, false); // удаление лишних дочерних
, которые добавляет в строку Firefox if ("MozAppearance" in d.documentElement.style) { input.addEventListener("keyup", function(e) { if (input.textContent.length < 2 && e.key === "Backspace") { einput.clear("br"); input.innerHTML = input.innerHTML.trim(); einput.caret(input); } e.stopPropagation(); }); } // добавление событий для панели var panel = einput.els.panel; panel.addEventListener("click", function(e) { if (e.target.classList.contains("emoji")) einput.insert(e); }); panel.addEventListener("mousedown", prevent); }; window.einput = einput; }(document); // добавление события, обрабатывающего отображение или скрытие панели смайлов document.getElementById("btn").addEventListener("click", function(e) { this.classList.toggle("active"); einput.els.panelBox.classList.toggle("show"); }); // запуск основной функции einput.init();

Для правильной работы скрипта в Opera 12 Presto и Internet Explorer 10−11 требуется полифилл для метода Object.assign() и подключение или удаление классов через element.classList с использованием только одного класса, т.к. IE не умеет добавлять или удалять сразу по нескольку значений за раз.

Пример работы emoji input

Скрипт тестировался во всех современных (и не очень) браузерах: Chrome, Firefox, Opera 12, Internet Explorer 10−11 и Edge. Следует обратить внимание, что некоторые функции вроде автоматического фокуса в поле ввода при печатании не будут работать, пока <iframe> не станет активным элементом (соответственно, такой ситуации не возникнет, если открыть фрейм напрямую как отдельное окно).

В заключение

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