Rich Text Input Box for Web Chat Tools

Keywords: Javascript emoji Attribute

Recently, we are going to develop a chat room application to train our hands. In the process of application development, it is found that emoji can be inserted. The rich text input box pasting pictures actually contains a lot of interesting knowledge, so I intend to record it and share it with you.

Warehouse Address: chat-input-box
Preview address: https://codepen.io/jrainlau/p...

Let's first look at the demo effect:

Do you think it's amazing? Next, I will explain step by step how the functions are implemented.

Rich Textualization of Input Box

Traditional input boxes are made with <textarea>, which has the advantage of simplicity, but the biggest drawback is that they can't display pictures. In order to enable the input box to display pictures (rich text), we can use the < div > with the content ditable= "true" attribute to achieve this function.

Simply create an index.html file and write it as follows:

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

Open the browser and you will see an input box with a picture by default:

The cursor can move around the picture, input content, and even delete the picture by backspace keys - in other words, the picture is also part of the editable content, which means rich text of the input box has been reflected.

The next task is to think about how to paste pictures directly through control + v.

Handling paste events

Anything copied through "copy" or control + c (including screen shots) is stored on the clipboard and can be monitored in the onpaste event of the input box when pasted.

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

The content of the clipboard is stored in the DataTransferItemList object and can be accessed through e.clipboard Data.items:

Careful readers will find that if you directly open the small arrow in front of the DataTransferItemList at the console point, you will find that the length attribute of the object is 0. What about the clipboard content? This is actually a pit in Chrome debugging. In the developer's tool, console.log's object is a reference that changes with the original data. Since the clipboard data has been "pasted" into the input box, the DataTransfer ItemList you see when you expand the small arrow becomes empty. To do this, we can use console.table instead to show real-time results.

Once you understand where the clipboard data is stored, you can write code to process them. Because our rich text input box is relatively simple, we only need to deal with two types of data. One is the ordinary text type data, including emoji expression; the other is the image type data.

New paste.js file:

const onPaste = (e) => {
  // If the clipboard has no data, it returns directly
  if (!(e.clipboardData && e.clipboardData.items)) {
    return
  }
  // Encapsulation with Promise for future use
  return new Promise((resolve, reject) => {
    // The location of the duplicated content in the clipboard is uncertain, so traversal is used to ensure the accuracy of the data.
    for (let i = 0, len = e.clipboardData.items.length; i < len; i++) {
      const item = e.clipboardData.items[i]
      // Text Format Content Processing
      if (item.kind === 'string') {
        item.getAsString((str) => {
          resolve(str)
        })
      // Content Processing in Picture Format
      } else if (item.kind === 'file') {
        const pasteFile = item.getAsFile()
        // Processing pasteFile
        // TODO(pasteFile)
      } else {
        reject(new Error('Not allow to paste this type!'))
      }
    }
  })
}

export default onPaste

Then you can use it directly in the onPaste event:

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

The above code supports text format, and the next step is to process the image format. Students who have played < input type= "file" know that all file format content, including pictures, will be stored in the File object, which is the same in the clipboard. So we can write a set of general functions to read the content of the picture in the File object and convert it into a base64 string.

Paste pictures

In order to display pictures better in the input box, the size of the pictures must be limited, so this image processing function can not only read the pictures in the File object, but also compress them.

Create a new chooseImg.js file:

/**
 * Preview function
 *
 * @param {*} dataUrl base64 Character string
 * @param {*} cb callback
 */
function toPreviewer (dataUrl, cb) {
  cb && cb(dataUrl)
}

/**
 * Picture compression function
 *
 * @param {*} img Picture object
 * @param {*} fileType  Picture type
 * @param {*} maxWidth Maximum width of picture
 * @returns base64 Character string
 */
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
}

/**
 * Select Picture Function
 *
 * @param {*} e input.onchange Event object
 * @param {*} cb callback
 * @param {number} [maxsize=200 * 1024] Picture Maximum Volume
 */
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

The contents of using canvas to compress pictures and using FileReader to read files are not discussed here. Interested readers can refer to them by themselves.

Back to the paste.js function of the previous step, rewrite TODO() to chooseImg():

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

Back in the browser, if we copy a picture and paste it in the input box, we will see the printed image address beginning with data:image/png;base64 on the console.

Insert content in input box

After the first two steps, we can read the text content and image content in the clipboard, and then insert them into the cursor position of the input box correctly.

For insertion, we can do it directly through the document.execCommand method. Detailed usage of this method can be found in MDN document Here we just need to use insertText and 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)
})

But in some versions of Chrome browsers, the insertImage method may fail, and then another method, Selection, can be used to implement it. It is also used for selecting and inserting emoji, so you might as well learn about it first.

When we call window.getSelection() in our code, we get a Selection object. If you select some text on the page and then execute window.getSelection().toString() on the console, you will see that the output is the part of the text you choose.

Corresponding to this subregional text is a range object that can be accessed using window.getSelection().getRangeAt(0). Range includes not only the text of the selected area, but also the starting and ending positions of the area.

We can also manually create a range by document.createRange(), write to it and display it in the input box.

For inserting an image, first get the range from window.getSelection(), and then insert the image into it.

document.querySelector('.editor').addEventListener('paste', async (e) => {
  // Read the contents of the clipboard
  const result = await onPaste(e)
  const imgRegx = /^data:image\/png;base64,/
  // If it's a picture format (base64), insert the < img > tag into the correct position by constructing the range.
  // If it is in text format, the text is inserted through document.execCommand('insertText') method
  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)
  }
})

This method can also do a good job of pasting pictures, and the versatility will be better. Next we will use Selection to insert emoji.

Insert emoji

Whether it's pasting text or pictures, our input box is always in focus. When we select emoji expression from the facial expression panel, the input box loses focus first and then refocuses. Because the document.execCommand method must be focused in the input box to trigger, it cannot be used for processing emoji inserts.

As mentioned in the previous section, Selection allows us to get the starting and ending positions of the selected text in the focusing state. If the text is not selected but only in the focusing state, the values of the two positions are equal (equivalent to the empty selection text), that is, the position of the cursor. As long as we can record this position before defocusing, we can insert emoji into the right place through range.

First, write two tool methods. Create a new cursorPosition.js file:


/**
 * getcursorpos
 * @param {DOMElement} element dom node of input box
 * @return {Number} Cursor position
 */
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
}

/**
 * Setting cursor position
 * @param {DOMElement} element dom node of input box
 * @param {Number} cursorPosition Value of cursor position
 */
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)
}

With these two methods, you can put them into the editor node for use. First, the cursor position is recorded in the keyup and click events of the node:

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

After recording the cursor position, you can insert emoji characters by calling insertEmoji().

insertEmoji (emoji) {
  const text = editor.innerHTML
  // Insert emoji
  editor.innerHTML = text.slice(0, cursorPosition) + emoji + text.slice(cursorPosition, text.length)
  // Move the cursor one bit behind to ensure that it is behind the emoji just inserted
  setCursorPosition(editor, this.cursorPosition + 1)
  // Update locally saved cursor position variables (note that emoji takes up two byte sizes, so add 1)
  cursorPosition = getCursorPosition(editor) + 1 //  emoji occupies two places
}

Epilogue

The code involved in this article has been uploaded to Warehouse For the sake of simplicity, VueJS is used to process it without affecting reading. Finally, I want to say that this Demo only completes the most basic part of the input box. There are still many details to be dealt with about copy and paste (such as copying the in-line style in other places, etc.). It will not be launched here one by one. Interested readers can study it by themselves, and more welcome to leave a message with me.~

Posted by Base on Sun, 05 May 2019 00:16:38 -0700