Project code: github.com/Haixiang612… Preview address: yanhaixiang.com/my-copy-to-… Reference the wheels: www.npmjs.com/package/cop…

Using javascript to copy text is quite common in web apps, such as github’s remote address copy function:

Today we will take you to write a JS copy text wheel

Starting from scratch

JS do copy function of the article is quite a lot, here cites a Ruan Yifeng “Clipboard operation Clipboard API tutorial” as an example.

Most articles do this by creating an input field (input or Textarea), assigning the copied text to the value of the element, selecting the text, and using Document.exec (‘copy’) to do the copying.

The problem here is that the text input field can behave in some strange ways under certain circumstances, such as:

  • If it is not a text input label, you need to actively create a text input label (input and textarea) and assign the text to be copied to the label, and then call the.select() method to select the label to continue executiondocument.execCommand('copy')They can be reproduced.
  • If it isa text input label, the label cannot be given to disable or readonly, which affectsselect()Methods.
  • Mobile iOS will automatically adjust the page zoom when the input box is selected. If this is not handled, callselect()The same problem occurs when the tag is in focus state.

Sounds like a lot of trouble. To eliminate these compatibility issues, use the element as a container for copying text.

const copy = (text: string) = > {
  const range = document.createRange()
  const selection = document.getSelection()

  const mark = document.createElement('span')
  mark.textContent = text

  // Insert into body
  document.body.appendChild(mark)

  / / selected
  range.selectNodeContents(mark)
  selection.addRange(range)

  const success = document.execCommand('copy')

  if (success) {
    alert('Copy successful')}else {
    alert('Replication failed')}if (mark) {
    document.body.removeChild(mark)
  }
}
Copy the code

I’m going to use Selection and Range. About Selection indicates the range of text or the current position of the caret selected by the user. It represents a text selection on a page that may span multiple elements; Range represents a document fragment that contains a node and a portion of a text node. A Selection can have multiple Range objects.

The logic above is simple: create a SPAN element and add copied text from textContent. Why not use innerText? What’s the difference? See Stackoverflow for the Difference: Difference between textContent vs. innerText.

Okay, I know you won’t, so here’s a quick list:

  1. First of all,innerTextNon-standard,textContentIs a standard
  2. innerTextVery susceptible to CSS,textContentDo not:innerTextReturns only visible text, andtextContentReturns the full text. For example, “Hello Wold” text, use display: None to make “Hello” invisible, soinnerTextWill return “World”, andtextContentReturn “Hello World”.
  3. innerTextPerformance is poor because you need to wait until the rendering is finished to get the text from the page layout information
  4. innerTextGet it through HTMLElement, andtextContentYou can get it from all nodes, so it’s a bit more accessible

Go back to the code, place the span in document.body, select the element, add range to Selection, and document.exec performs the copy operation, removing the mark element and calling it a day.

Copying is up and down

If you make a button and bind copy(‘Hello’), when you click it, it says: Huh? How does it go up and down? One moment you can copy and the next you can’t.

Selection could be the current position of the caret. Think about the mouse click calculation is not the selection? It just has length 0 and you can’t see it.

At this point it is marked as Collapsed, which means that the selection is compressed to a point, the cursor position. – Selection

If the length is 0, the code is addRange. However, addRange does not add new ranges to Selection!

Currently only Firefox supports multiple selection ranges, Other browsers will not add new ranges to the selection if it already contains one. — Selection. AddRange ().

To summarize the problem of unsuccessful replication:

  1. Selection adds an invisible Range when the mouse is accidentally clicked on a page (e.g. a button).
  2. In our codeselection.addRangeSelection of selected text within a span will not be added as a new Range
  3. performdocument.exec('copy')“, because the selection is a cursor position, copied a lonely, paste board or the original copy content, will not change, if the original is empty, the paste is empty
  4. Since the implementation of a lonely, why success is notfalse? Because MDN says that success or failure has nothing to do with the return value, onlydocument.execNot supported or enabled by the browserfalse.

Note: document.execCommand() only returns true if it is invoked as part of a user interaction. You can’t use it to verify browser support before calling a command. From Firefox 82, Nested document.execcommand () calls will always return false. — document.execcommand ()

The solution: useselection.removeAllRangesIn theselection.addRangeJust clean up the original Range before.

const copy = (text: string) = > {
  const range = document.createRange()
  const selection = document.getSelection()

  const mark = document.createElement('span')
  mark.textContent = text

  document.body.appendChild(mark)

  range.selectNodeContents(mark)
  selection.removeAllRanges() // Remove the Range that existed before the call
  selection.addRange(range)

  const success = document.execCommand('copy')

  if (success) {
    console.log('Copy successful')}else {
    console.log('Replication failed')}if (mark) {
    document.body.removeChild(mark)
  }
}
Copy the code

Used above selection. RemoveAllRanges removes the current Range, so that you can copy to the Range of join the selection.

toggle-selection

This solves the problem of not being able to copy, but will remove the selected region. For example, if the user selects a text and executes copy, the original text is not selected. Copy is side-effect, not application-friendly.

The solution is simple: executecopyRemoves the current selection, and restores the original selection after execution.

export const deselectCurrent = () = > {
  const selection = document.getSelection()

  // Currently not selected
  if (selection.rangeCount === 0) {
    return () = >{}}let $active = document.activeElement

  // Get the currently selected ranges
  const ranges: Range[] = []
  for (let i = 0; i < selection.rangeCount; i++) {
    ranges.push(selection.getRangeAt(i))
  }

  // deselect
  selection.removeAllRanges();

  return () = > {
    // If it is a caret, remove ranges
    if (selection.type === 'Caret') {
      selection.removeAllRanges()
    }

    // If no, add the previous ranges back
    if (selection.rangeCount === 0) {
      ranges.forEach(range= > {
        selection.addRange(range)
      })
    }
  }
}
Copy the code

The deselectCurrent function stores the current selection in the ranges and returns a function that can be used to restore the current selection.

Also consider that if the activeElement is input or Textarea, deselect is blur and reselect is Focus back.

export const deselectCurrent = () = > {
  const selection = document.getSelection()

  if (selection.rangeCount === 0) {
    return () = >{}}let $active = document.activeElement

  const ranges: Range[] = []
  for (let i = 0; i < selection.rangeCount; i++) {
    ranges.push(selection.getRangeAt(i))
  }

  Blur then focus for input elements
  switch ($active.tagName.toUpperCase()) {
    case 'INPUT':
    case 'TEXTAREA':
      ($active as HTMLInputElement | HTMLTextAreaElement).blur()
      break
    default:
      $active = null
  }

  selection.removeAllRanges();

  return () = > {
    if (selection.type === 'Caret') {
      selection.removeAllRanges()
    }
    if (selection.rangeCount === 0) {
      ranges.forEach(range= > {
        selection.addRange(range)
      })
    }

    // Focus back to input or textarea
    if ($active) {
      ($active as HTMLInputElement | HTMLTextAreaElement).focus()
    }
  }
}
Copy the code

In copy you can have happy deselect and reselect:

const copy = (text: string) = > {
  const reselectPrevious = deselectCurrent() // Remove the current selection.const success = document.execCommand('copy')

  if (mark) {
    document.body.removeChild(mark)
  }

  reselectPrevious() // Restore the previous selection

  return success
}
Copy the code

onCopy

Copy events are triggered when copying, so callers can also be provided with onCopy callbacks and custom listeners.

interfaceOptions { onCopy? :(copiedText: DataTransfer | null) = > unknown
}

const copy = (text: string, options: Options = {}) = > {
  const {onCopy} = options

  const reselectPrevious = deselectCurrent()

  const range = document.createRange()
  const selection = document.getSelection()

  const mark = document.createElement('span')
  mark.textContent = text

  // Customize onCopy
  mark.addEventListener('copy'.(e) = > {
    if (onCopy) {
      e.stopPropagation()
      e.preventDefault()
      onCopy(e.clipboardData)
    }
  })

  document.body.appendChild(mark)

  range.selectNodeContents(mark)
  selection.addRange(range)

  const success = document.execCommand('copy')

  if (mark) {
    document.body.removeChild(mark)
  }

  reselectPrevious()

  return success
}
Copy the code

Add “copy” event listener here. E. topPropagation prevents copy events from bubbling. e. revenDefault disables the default response and takes over the response of the copy event with the onCopy function. At the same time, onCopy passes e.clipbaordData, which allows the caller to manipulate the copied data at will.

Such as:

$myCopy.onclick = () = > {
  const myText = 'my text'

  copy('xxx', {
    onCopy: (clipboardData) = > clipboardData.setData('text/plain', myText), / / copy 'my - text'})}Copy the code

If you set setData to copy text, you can use text/plain.

The format of the DataTransfer

Have you ever looked at clipboardData? It’s a DataTransfer type. What does a DataTransfer do? Generally, it is used to store dragged content when dragging. Replication is also a kind of DataTransfer, so clipboardData is also DataTransfer type.

Copying is essentially copying content, not just text, but also formatting. We might normally just copy a few words, but in some cases, such as a link, an element of an < H1 > tag, or even an image, when pasting into a docs file, the elements’ styles and images come with them all.

Why did this happen? Because the system sets the format for copying, and the World recognizes the format, it can directly display the copied content with style.

Currently our function only supports plain text copy, should add a format, let the caller customize the copy format.

interfaceOptions { onCopy? :(copiedText: DataTransfer | null) = >unknown format? : Format }const copy = (text: string, options: Options = {}) = > {
  const {onCopy} = options

  const reselectPrevious = deselectCurrent()

  const range = document.createRange()
  const selection = document.getSelection()

  const mark = document.createElement('span')
  mark.textContent = text

  mark.addEventListener('copy'.(e) = > {
    e.stopPropagation();

    // Format to copy content
    if (format) {
      e.preventDefault()
      e.clipboardData.clearData()
      e.clipboardData.setData(format, text)
    }

    if (onCopy) {
      e.preventDefault()
      onCopy(e.clipboardData)
    }
  })

  document.body.appendChild(mark)

  range.selectNodeContents(mark)
  selection.addRange(range)

  const success = document.execCommand('copy')

  if (mark) {
    document.body.removeChild(mark)
  }

  reselectPrevious()

  return success
}
Copy the code

If there is a format in the copy event, the copy listener is taken over, clearData clears the copied content, and setData(format, text) copies the content.

Compatible with IE

Front-end engineers have a lifelong enemy in common – IE. Currently, I have checked the documents and found the following compatibility problems:

  • Under IE 11, format is only available hereTextUrlTwo kinds of
  • In IE, in the copy evente.clipboardDataundefinedBut there will bewindow.clipboardData
  • Below IE 9,document.execCommandMay not be supported (some posts say yes, some posts say there are problems)

Format, E. lipboardData, and Document. execCommand.

The first is format, which provides a format conversion Mapper:

type Format = 'text/plain' | 'text/html' | 'default'
type IE11Format = 'Text' | 'Url'

const clipboardToIE11Formatting: Record<Format, IE11Format> = {
  "text/plain": "Text"."text/html": "Url"."default": "Text"
}
Copy the code

In Internet Explorer, Windows will have a clipboardData, we can copy the content to the window. ClipboardData. Note: this global variable is available only in IE, and is normally e.clipboardData.

const copy = (text: string, options: Options = {}) = >{... mark.addEventListener('copy'.(e) = > {
    e.stopPropagation();
    if (format) {
      e.preventDefault()
      if(! e.clipboardData) {// E.clipboardData is undefined only in IE 11
        // Change the format to the format specified in IE 11
        const IE11Format = clipboardToIE11Formatting[format || 'default']
        // @ts-ignore clearData is only available on IE
        window.clipboardData.clearData()
        // @ts-ignore setData is only available on IE
        window.clipboardData.setData(IE11Format, text);
      } else {
        e.clipboardData.clearData()
        e.clipboardData.setData(format, text)
      }
    }

    if (onCopy) {
      e.preventDefault()
      onCopy(e.clipboardData)
    }
  })

  ...
}
Copy the code

The final step is to make compatibility with Document. execCommand. So far, I have found problems that will not take effect and execCommand does not support. In order to deal with the vast majority of problems in IE, we can offer a try-catch method. As long as there is error, we will copy in the old way of IE.

const copy = (text: string, options: Options = {}) = >{... ...try {
    / / execCommand some browsers may not support, here to try again
    success = document.execCommand('copy')

    if(! success) {throw new Error("Can't not copy")}}catch (e) {
    try {
      // @ts-ignore window.clipboardData is only available on IE
      window.clipboardData.setData(format || 'text', text)
      // @ts-ignore window.clipboardData is only available on IE
      onCopy && onCopy(window.clipboardData)
    } catch (e) {
      // Last pocket scheme to allow the user to type in window.prompt
      window.prompt('Enter what you want to copy', text)
    }
  } finally {
    if (selection.removeRange) {
      selection.removeRange(range)
    } else {
      selection.removeAllRanges()
    }

    if (mark) {
      document.body.removeChild(mark)
    }
    reselectPrevious()
  }

  return success
}
Copy the code

Added several try-catch above, the first is compatible with the document. The execCommand, there is a problem go window. The clipboardData. SetData way to copy. The second is the bottom-pocket scheme, using window.Prompt as the bottom-pocket.

Finally, compatibility is made for selection. RemoveRange. If removeRange fails, removeAllRanges are used to removeAllRanges.

Compatible with the style

When creating and adding marks, you also need to style them to prevent side-effects from appearing on the page, such as:

  • Adding and removing marks does not cause scrolling
  • The space and line-break of the span element should bepre, you can copy with special symbols such as line breaks
  • External may be set to “none”, so user-select must be “text”, otherwise the selection will fail
const updateMarkStyles = (mark: HTMLSpanElement) = > {
  // Resets the user style
  mark.style.all = "unset";
  // place it in fixed to prevent scrolling after adding elements
  mark.style.position = "fixed";
  mark.style.top = '0';
  mark.style.clip = "rect(0, 0, 0, 0)";
  // Keep the space and line-break features
  mark.style.whiteSpace = "pre";
  // External user-select may be 'none', so set this to text
  mark.style.userSelect = "text";
}

const copy = (text: string, options: Options = {}) = >{...const mark = document.createElement('span')
  mark.textContent = text

  updateMarkStyles(mark)

  mark.addEventListener('copy'.(e) = >{... })... }Copy the code

You should update the style as soon as you create the SPAN element to ensure that there are no page changes (side effects).

conclusion

Copy-to-clipboard has completed all functions of the library. It mainly does the following things:

  1. Complete the replication function
  2. The original selection will be restored after copying
  3. OnCopy is provided so that callers can define their own copy Listeners
  4. Provide format, can copy multiple formats
  5. Compatible with the IE
  6. Styles are compatible, and copying is done without side effects to the page

The last

JS copy this demand should be encountered by many people. However, when it comes to real research, there are still many things to consider.

If you just glance at the source code, you might just make a “from scratch” version, and the compatibility, format, callback, and other functions are really hard to think of.

Finally, the Clipboard API. The Clipboard API is the next generation of Clipboard manipulation methods that are more powerful and reasonable than the traditional document.execcommand () method. All of its operations are asynchronous, return Promise objects, and do not cause the page to stall. Moreover, it can put any content (such as pictures) into the clipboard.

However, Document. ExecCommand is still widely used today. Although it was stated above that IE is not good for Document. execCommand, The Clipboard API is even less compatible, and both FireFox and Chome may have problems in some versions. Another problem is that using the ClipBoard API requires Permissions from the Permissions API to access clipboard content, which can seriously impact the user experience. User: you let me open permission, is it to steal my password??