Поле ввода расширенного текста для инструмента веб-чата

JavaScript

Эта статья и мояСтолбец ошибки сегментаСинхронизировать.

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

Адрес склада:chat-input-box

Адрес предварительного просмотра:codepen

Во-первых, давайте взглянем на демонстрационный эффект:

Как вы думаете, это удивительно? Далее я объясню шаг за шагом, как реализованы функции.

Форматированный текст поля ввода

Используются традиционные входные ящики<textarea>Его преимущество в том, что он очень прост, но самый большой недостаток в том, что он не может отображать изображения. Чтобы в поле ввода отображались изображения (форматированный текст), мы можем использовать настройкиcontenteditable="true"атрибут<div>для достижения этой функции.

просто создайтеindex.htmlфайл, а затем напишите следующее:

<div class="editor" contenteditable="true">
  <img src="https://static.easyicon.net/preview/121/1214124.gif" alt="">
</div>

Откройте браузер, и вы увидите поле ввода с изображением по умолчанию:

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

Следующая задача — подумать о том, как напрямую передатьcontrol + vВставьте картинку в.

Обработка событий вставки

любой путем «копирования» илиcontrol + cСкопированный контент (включая скриншоты) будет сохранен в буфере обмена и может отображаться в поле ввода при вставке.onpasteСобытие отслеживается.

document.querySelector('.editor').addEventListener('paste', (e) => {
	console.log(e.clipboardData.items)
})

Содержимое буфера обмена хранится вDataTransferItemListобъект, вы можете пройтиe.clipboardData.itemsДоступ к:

Внимательные читатели обнаружат, что если щелкнуть прямо на консолиDataTransferItemListМаленькая стрелка перед объектом найдетlengthсвойство равно 0. А содержимое буфера обмена? По сути, это небольшая яма отладки Chrome. В инструментах разработчикаconsole.logПолучаемый объект представляет собой ссылку, которая изменяется по мере изменения исходных данных. Поскольку данные буфера обмена были «вставлены» в поле ввода, то, что вы видите после расширения маленькой стрелкиDataTransferItemListстановится пустым. Для этого мы можем вместо этого использоватьconsole.tableОтображать результаты в реальном времени.

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

новыйpaste.jsдокумент:

const onPaste = (e) => {
  // 如果剪贴板没有数据则直接返回
  if (!(e.clipboardData && e.clipboardData.items)) {
    return
  }
  // 用Promise封装便于将来使用
  return new Promise((resolve, reject) => {
    // 复制的内容在剪贴板里位置不确定,所以通过遍历来保证数据准确
    for (let i = 0, len = e.clipboardData.items.length; i < len; i++) {
      const item = e.clipboardData.items[i]
      // 文本格式内容处理
      if (item.kind === 'string') {
        item.getAsString((str) => {
          resolve(str)
        })
      // 图片格式内容处理
      } else if (item.kind === 'file') {
        const pasteFile = item.getAsFile()
        // 处理pasteFile
        // TODO(pasteFile)
      } else {
        reject(new Error('Not allow to paste this type!'))
      }
    }
  })
}

export default onPaste

Тогда ты можешьonPasteОн используется непосредственно в мероприятии:

document.querySelector('.editor').addEventListener('paste', async (e) => {
	const result = await onPaste(e)
    console.log(result)
})

Приведенный выше код поддерживает текстовый формат, и следующим шагом будет обработка формата изображения. играл<input type="file">учащихся узнают, что все форматы файлов, включая изображения, хранятся вFileВнутри объекта это то же самое внутри буфера обмена. Итак, мы можем написать набор общих функций для чтенияFileсодержимое изображения в объекте и преобразовать его вbase64нить.

вставить изображение

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

создать новыйchooseImg.jsдокумент:

/**
 * 预览函数
 *
 * @param {*} dataUrl base64字符串
 * @param {*} cb 回调函数
 */
function toPreviewer (dataUrl, cb) {
  cb && cb(dataUrl)
}

/**
 * 图片压缩函数
 *
 * @param {*} img 图片对象
 * @param {*} fileType  图片类型
 * @param {*} maxWidth 图片最大宽度
 * @returns base64字符串
 */
function compress (img, fileType, maxWidth) {
  let canvas = document.createElement('canvas')
  let ctx = canvas.getContext('2d')

  const proportion = img.width / img.height
  const width = maxWidth
  const height = maxWidth / proportion

  canvas.width = width
  canvas.height = height

  ctx.fillStyle = '#fff'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  ctx.drawImage(img, 0, 0, width, height)

  const base64data = canvas.toDataURL(fileType, 0.75)
  canvas = ctx = null

  return base64data
}

/**
 * 选择图片函数
 *
 * @param {*} e input.onchange事件对象
 * @param {*} cb 回调函数
 * @param {number} [maxsize=200 * 1024] 图片最大体积
 */
function chooseImg (e, cb, maxsize = 200 * 1024) {
  const file = e.target.files[0]

  if (!file || !/\/(?:jpeg|jpg|png)/i.test(file.type)) {
    return
  }

  const reader = new FileReader()
  reader.onload = function () {
    const result = this.result
    let img = new Image()

    if (result.length <= maxsize) {
      toPreviewer(result, cb)
      return
    }

    img.onload = function () {
      const compressedDataUrl = compress(img, file.type, maxsize / 1024)
      toPreviewer(compressedDataUrl, cb)
      img = null
    }

    img.src = result
  }

  reader.readAsDataURL(file)
}

export default chooseImg

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

Вернуться к предыдущему шагуpaste.jsФункция, к которойTODO()переписать какchooseImg()Только что:

const imgEvent = {
  target: {
    files: [pasteFile]
  }
}
chooseImg(imgEvent, (url) => {
  resolve(url)
})

Возвращаясь к браузеру, если мы скопируем изображение и выполним действие вставки в поле ввода, мы увидим, что вывод выводится на консоль.data:image/png;base64Адрес изображения в начале.

Вставить содержимое в поле ввода

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

Для вставки контента мы можем напрямую передатьdocument.execCommandспособ продолжить. Подробное использование этого метода можно найти вДокументация MDNнаходится внутри, здесь нам просто нужно использоватьinsertTextа такжеinsertImageВот и все.

document.querySelector('.editor').addEventListener('paste', async (e) => {
	const result = await onPaste(e)
    const imgRegx = /^data:image\/png;base64,/
    const command = imgRegx.test(result) ? 'insertImage': 'insertText'
    
    document.execCommand(command, false, result)
})

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

когда мы вызываем кодwindow.getSelection()получитSelectionобъект. Если выделить какой-то текст на странице, то выполнить в консолиwindow.getSelection().toString(), вы увидите, что вывод — это часть выбранного вами текста.

Этой части регионального текста соответствуетrangeобъект, используяwindow.getSelection().getRangeAt(0)I.e. Может получить доступ к нему.rangeВключает не только содержимое текста выделенной области, но и начальную точку области.startOffsetи конечное положениеendOffset.

Мы также можем пройтиdocument.createRange()Способ создания вручнуюrange, запишите в него содержимое и отобразите его в поле ввода.

Для вставки изображений начните сwindow.getSelection()Получатьrange, и вставьте в него изображение.

document.querySelector('.editor').addEventListener('paste', async (e) => {
  // 读取剪贴板的内容
  const result = await onPaste(e)
  const imgRegx = /^data:image\/png;base64,/
  // 如果是图片格式(base64),则通过构造range的办法把<img>标签插入正确的位置
  // 如果是文本格式,则通过document.execCommand('insertText')方法把文本插入
  if (imgRegx.test(result)) {
    const sel = window.getSelection()
    if (sel && sel.rangeCount === 1 && sel.isCollapsed) {
      const range = sel.getRangeAt(0)
      const img = new Image()
      img.src = result
      range.insertNode(img)
      range.collapse(false)
      sel.removeAllRanges()
      sel.addRange(range)
    }
  } else {
    document.execCommand('insertText', false, result)
  }
})

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

Вставить эмодзи

Будь то вставка текста или изображения, наше поле ввода всегда в фокусе. И когда мы выбираем эмодзи на панели эмодзи, поле ввода сначала будет не в фокусе (размыто), а затем перефокусировано. из-заdocument.execCommandМетоды должны запускать поле ввода состояния в фокусе, поэтому невозможно использовать вставку смайликов.

Как упоминалось в предыдущем разделе,SelectionДавайте получим начальную позицию выделенного текста в состоянии фокуса.startOffsetи конечное положениеendOffsetrangeВставьте смайлик в нужное место.

Сначала напишите два служебных метода. создать новыйcursorPosition.jsдокумент:


/**
 * 获取光标位置
 * @param {DOMElement} element 输入框的dom节点
 * @return {Number} 光标位置
 */
export const getCursorPosition = (element) => {
  let caretOffset = 0
  const doc = element.ownerDocument || element.document
  const win = doc.defaultView || doc.parentWindow
  const sel = win.getSelection()
  if (sel.rangeCount > 0) {
    const range = win.getSelection().getRangeAt(0)
    const preCaretRange = range.cloneRange()
    preCaretRange.selectNodeContents(element)
    preCaretRange.setEnd(range.endContainer, range.endOffset)
    caretOffset = preCaretRange.toString().length
  }
  return caretOffset
}

/**
 * 设置光标位置
 * @param {DOMElement} element 输入框的dom节点
 * @param {Number} cursorPosition 光标位置的值
 */
export const setCursorPosition = (element, cursorPosition) => {
  const range = document.createRange()
  range.setStart(element.firstChild, cursorPosition)
  range.setEnd(element.firstChild, cursorPosition)
  const sel = window.getSelection()
  sel.removeAllRanges()
  sel.addRange(range)
}

С помощью этих двух методов вы можете поместить их в узел редактора и использовать. сначала в узлеkeyupа такжеclickЗапишите положение курсора в событии:

let cursorPosition = 0
const editor = document.querySelector('.editor')
editor.addEventListener('click', async (e) => {
  cursorPosition = getCursorPosition(editor)
})
editor.addEventListener('keyup', async (e) => {
  cursorPosition = getCursorPosition(editor)
})

После записи положения курсора можно позвонитьinsertEmoji()Метод для вставки символов эмодзи.

insertEmoji (emoji) {
  const text = editor.innerHTML
  // 插入 emoji
  editor.innerHTML = text.slice(0, cursorPosition) + emoji + text.slice(cursorPosition, text.length)
  // 光标位置后挪一位,以保证在刚插入的 emoji 后面
  setCursorPosition(editor, this.cursorPosition + 1)
  // 更新本地保存的光标位置变量(注意 emoji 占两个字节大小,所以要加1)
  cursorPosition = getCursorPosition(editor) + 1 //  emoji 占两位
}

конец

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