This article is in sync with my Segmentfault column.

Recently toss over Websocket, plan to develop a chat room application practice hand. In the process of application development, I found that the rich text input box which can insert emojis and paste pictures actually contains a lot of interesting knowledge, so I decided to record and share it with everyone.

Warehouse address: chat-input-box

Preview address: Codepen

First look at the demo:

Isn’t that amazing? I’m going to walk you through how this works step by step.

Input box rich text

Traditional input fields are created using

with the contenteditable=”true” property set.

Simply create an index. HTML file and write the following:

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

Open your browser and you’ll see an input field that already has an image by default:

The cursor can move back and forth over the image, enter content, and even delete the image with the backspace key — in other words, the image is part of the editable content, which means that the text of the input box is already textual.

The next task is to figure out how to paste the image directly through Control + V.

Handling paste Events

Anything copied using Copy or Control + C (including screenshots) is stored on the clipboard and can be listened to in the onPaste event of the input box as it is pasted.

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

The contents of the clipboard are stored in the DataTransferItemList object, which can be accessed via e.clipboardData.items:

The careful reader will notice that if you click on the little arrow in front of the DataTransferItemList directly on the console, the object’s Length property is 0. What about the clipboard? This is actually a pit for Chrome debugging. In developer tools, the console.log object is a reference that changes as the raw data changes. Since the clipboard data has already been “pasted” into the input box, the DataTransferItemList you see when you expand the small arrow is empty. To do this, we can use console.table to show the results in real time.

Once you know where the clipboard data is stored, you can write code to process it. Because our rich text input box is relatively simple, we only need to process two types of data. One is common text type data, including emoji; The second is image type data.

To create a paste. Js file:

const onPaste = (e) = > {
  // If the clipboard has no data, it returns directly
  if(! (e.clipboardData && e.clipboardData.items)) {return
  }
  // Use the Promise wrapper for future use
  return new Promise((resolve, reject) = > {
    // Copy the contents of the clipboard position is uncertain, so through traversal to ensure that the data is accurate
    for (let i = 0, len = e.clipboardData.items.length; i < len; i++) {
      const item = e.clipboardData.items[i]
      // Text formatting content processing
      if (item.kind === 'string') {
        item.getAsString((str) = > {
          resolve(str)
        })
      // Image format content processing
      } else if (item.kind === 'file') {
        const pasteFile = item.getAsFile()
        / / pasteFile processing
        // TODO(pasteFile)
      } else {
        reject(new Error('Not allow to paste this type! ')}}})}export default onPaste
Copy the code

You can then use it directly inside the onPaste event:

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

The above code supports text formatting, and the next step is to deal with image formatting. Those of you who have played will know that all file format content, including images, is stored in the File object, as well as in the clipboard. So we can write a generic set of functions that specifically read the image content of a File object and convert it to a Base64 string.

Paste pictures

To better display images in the input field, the size of the image must be limited, so the image processing function can not only read the image inside the File object, but also compress it.

Create a new chooseimg.js file:

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

FileType Image type * @param {*} maxWidth Maximum image width * @returns Base64 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
}

@param {*} cb callback * @param {number} [maxsize=200 * 1024] Maximum size of picture */
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

Copy the code

The content of using Canvas to compress images and using FileReader to read files is not described here, but can be found by interested readers.

Go back to paste.js and rewrite TODO() to chooseImg() :

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

Back in the browser, if we copy an image and paste it in the input box, we will see it printed on the console as data:image/ PNG; The address of the image starting with base64.

Insert content into the input box

After the first two steps, we are now ready to read the text and image content from the clipboard. The next step is to insert them correctly into the cursor position of the input box.

For inserting content, we can do it directly through the document.execCommand method. The details of this method can be found in the MDN documentation. In this case, we only 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)
})
Copy the code

However, in some versions of Chrome, the insertImage method may not work, so you can use Selection as an alternative. It will also be used for selecting and inserting emojis later, so it’s worth checking out first.

We get a Selection object when we call window.getSelection() in our code. 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 selected text.

Corresponding to the text in this section is a range object, which can be accessed using window.getSelection().getrangeat (0). Range not only contains the content of the selected region text, but also includes the starting position of the region startOffset and the ending position endOffset.

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

For inserting images, get the range from window.getSelection() and then insert images 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 is a picture format (base64), insert the  tag in the correct position by constructing range
  // If the format is text, the text is inserted through the 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)
  }
})
Copy the code

This method can also complete the function of pasting pictures well, and the versatility will be better. Next, we will use Selection to insert emojis.

Insert emoji

Regardless of pasted text or images, our input box is always in focus state. When we select an emoji from the emoticon panel, the input box will blur and then refocus. Because the Document. execCommand method must be triggered when the input box is in focus, it is not available for handling emoji inserts.

As mentioned in the previous section, Selection allows us to get the starting position startOffset and the ending position endOffset of the selected text in the focused state. If the text is not selected but only in the focused state, then the value of the two positions is equal (equivalent to selecting the text is empty), which is also the position of the cursor. As long as we can record the location before we lose focus, we can use range to insert the emoji in the right place.

Start by writing two tool methods. Create a new cursorPosition.js file:


/** * get cursor position * @param {DOMElement} Element input box dom node * @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
}

@param {DOMElement} element input box dom node @param {Number} cursorPosition cursorPosition value */
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)
}

Copy the code

Once you have these two methods, you can use them in the Editor node. First record the cursor position in the node’s keyUp and click events:

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

Once the cursor position is recorded, the emoji character can be inserted by calling the insertEmoji() method.

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 the inserted emoji
  setCursorPosition(editor, this.cursorPosition + 1)
  // Update the cursor position variable saved locally (note that emojis are two bytes in size, so add 1)
  cursorPosition = getCursorPosition(editor) + 1 // There are two emoji
}
Copy the code

The end of the

The code involved in this article has been uploaded to the repository. For simplicity, IT is processed by VueJS without affecting reading. Finally, I want to say that this Demo has only completed the most basic part of the input box, there are still many details to deal with the copy and paste (such as copying in the line style elsewhere, etc.), here is not a spread out, interested readers can study by themselves, more welcome to leave a message with me ~