Copying a Binary Image Blob to the Clipboard with JavaScript

Copying a Binary Image Blob to the Clipboard with JavaScript

One challenge I encountered when creating Spindle was copying the raw bytes of a PNG image to the clipboard. The current best practice for doing this is to use the ClipBoard API. This API isn't supported on all browsers, so I'll also show you how to use the ClipBoard API and a legacy implementation for browser compatibility. Then we'll create a complete implementation that defaults to the ClipBoard API and falls back to the legacy implementation.

Loading an Image as a Binary Blob

How we load and display an image from an API

The implementation of Spindle uses a python back-end that implements a RESTful API. The API takes some arguments about to generate and sends back the raw bytes of a PNG file. In the Spindle front-end code, I use the JavaScript Fetch API to interact with the back-end API.

To mimic this scenario for our example, we're going to load an image from our local machine using the Fetch API. The code to do this looks like the following.

blob = await (await fetch(
  "./static/test-image.png",
)).blob()

This code does a couple of things.

  1. Reads a local file using fetch
  2. Extracts the blob

The blob is just a block of binary data with some type metadata.

There are some oddities using fetch with local files. To do this without running into Cross-Origin Resource Sharing (CORS) issues, we need to setup a local nginx server and point it at our code. Here's a one-liner to do that using docker.

$ docker run -d --name nginx-test -v $(pwd):/usr/share/nginx/html -p 80:80 nginx

Then, whenever you make changes, you can use the following reload your app.

$ docker restart nginx-test

I wrote a separate post about running NGINX locally using Docker that includes an auto-reload script that you can checkout for more details.

The PNG blob that you get from fetch isn't in the right format to use it as an img.src . To view the image, I have a helper function which you can read more about in this post about converting a PNG blob to Base64. This code converts our raw blob into a base64 url that plays nicely with img.

function blobToBase64(blob) {
  return new Promise((resolve, _) => {
    const reader = new FileReader();
    reader.onloadend = function (event) {
      const result = event.target.result
      resolve(result.replace(/^data:.+;base64,/, ''))
    }
    reader.readAsDataURL(blob);
  });
}

Here's the full code to load and view our image. To read data from an API like I do for Spindle, you just need to change the fetch call.

JavaScript

function blobToBase64(blob) {
  return new Promise((resolve, _) => {
    const reader = new FileReader();
    reader.onloadend = function (event) {
      const result = event.target.result
      resolve(result.replace(/^data:.+;base64,/, ''))
    }
    reader.readAsDataURL(blob);
  });
}

window.onload = async function registerCallbacks() {
  // Grab the element where we want to display our image
  const image = document.getElementById("imageToCopy");

  // Load the PNG blog we want to show
  blob = await (await fetch(
    "./static/test-image.png",
  )).blob()
  
  // Set our image src using the image data encoded in base64
  image.src = "data:image/png;base64," + await blobToBase64(blob);
}

HTML

<img id="imageToCopy" />

The ClipBoard API

Implementation Details

The ClipBoard API has two main benefits: it provides object-oriented and asynchronous access to the clipboard. The Object-Oriented approach makes the clipboard easy to think about and visualize. The ClipboardItem object is, intuitively, an item that you want to place on the clipboard. Async support is helpful because it ensures that our API calls are non-blocking, which means that copying our large image blob won't lock up the browser.

Let's get started with the code. Since we already have our blob, this part is pretty simple. It's just matter of using the API. All the ClipBoard API needs is the blob and it's type which the blob already knows in blob.type!

async function clipboardAPICopy(blob) {
  await navigator.clipboard.write([
    new ClipboardItem({
      [blob.type]: blob
    })
  ]);
}

The Legacy execCommand API

Implementation Details

The ClipBoard API isn't supported on all browsers. Even in browsers that do support it, users can disable it anyway. The ClipBoard API is also not supported over HTTP. Since the API isn't guaranteed to be available, we need to implement a fallback.

The execCommand interface is a legacy interface that is supported on any reasonable browser your code might need to run on. We don't want to default to it though because it's a blocking interface. Blocking means that it will stop all interactivity in your user's browser while it executes. For small snippets of text, this is probably not noticeable. When copying an image, like we're trying to do, the larger data size can lead to a noticeable blip in your website's responsiveness.

Using the execCommand interface feels a little convoluted. It makes more sense if you think about it in two steps:

  1. Highlighting a set of elements in the DOM
  2. Performing an operation on those selected elements

First, we want to clear out any current selections

let selection = window.getSelection();
selection.removeAllRanges();

Next, we create a selection with the image element we want to copy.

range.selectNode(image);
selection.addRange(range);

Finally, we use execCommand to copy our selection and clean up.

document.execCommand("copy");
selection.removeAllRanges();

Here's one function to wrap that all up. We want both the clipboardAPICopy function and the execCommandCopy function to take a Blob as an argument. To accomplish this for execCommandCopy, we create a temporary, invisible image element, set the source, and delete it. We could use the image element we already set up to view the image, but I want an unified interface between our two different copy functions.

async function execCommandCopy(blob) {
  image = document.createElement("img");
  image.style = "display: none;"
  image.src = "data:image/png;base64," + await blobToBase64(blob);
  document.body.appendChild(image);

  let selection = window.getSelection();
  selection.removeAllRanges();
  let range = document.createRange();
  range.selectNode(image);
  selection.addRange(range);
  document.execCommand("copy");
  selection.removeAllRanges();

  document.body.removeChild(image);
}

Pulling It All Together

One function to copy to the clipboard using the ClipBoard API and falling back to the execCommand API

Now, we have all of the components that we need to implement our desired interface: one copyBlobToClipBoard function that will work on any browser. Here's the code with some console messages to report which API we're using to do the copy.

async function copyBlobToClipBoard(blob) {
  try {
    await clipboardAPICopy(blob);
    console.log("Image copied using the ClipBoard API!");
  } catch ({name, _}) {
    console.log("Recieved " + name + " when accessing the ClipBoard API. Falling back to execCommand");
    await execCommandCopy(blob);
    console.log("Image copied using the execCommand API!");
  }
}

Here's a minimal HTML page implementing everything we've talked about.

<!DOCTYPE html>
<html lang=en-US>
<head>
  <meta charset="UTF-8" />
  <script>
    function blobToBase64(blob) {
      return new Promise((resolve, _) => {
        const reader = new FileReader();
        reader.onloadend = function (event) {
          const result = event.target.result
          resolve(result.replace(/^data:.+;base64,/, ''))
        }
        reader.readAsDataURL(blob);
      });
    }

    async function clipboardAPICopy(blob) {
      await navigator.clipboard.write([
        new ClipboardItem({
          [blob.type]: blob
        })
      ]);
    }

    async function execCommandCopy(blob) {
      image = document.createElement("img");
      image.style = "display: none;"
      image.src = "data:image/png;base64," + await blobToBase64(blob);
      document.body.appendChild(image);

      let selection = window.getSelection();
      selection.removeAllRanges();
      let range = document.createRange();
      range.selectNode(image);
      selection.addRange(range);
      document.execCommand("copy");
      selection.removeAllRanges();

      document.body.removeChild(image);
    }

    async function copyBlobToClipBoard(blob) {
      try {
        await clipboardAPICopy(blob);
        console.log("Image copied using the ClipBoard API!");
      } catch ({name, _}) {
        console.log("Recieved " + name + " when accessing the ClipBoard API. Falling back to execCommand");
        await execCommandCopy(blob);
        console.log("Image copied using the execCommand API!");
      }
    }

    window.onload = async function registerCallbacks() {
      const image = document.getElementById("imageToCopy");

      blob = await (await fetch(
        "./static/test-image.png",
      )).blob()
      image.src = "data:image/png;base64," + await blobToBase64(blob);

      image.addEventListener("click", async () => await copyBlobToClipBoard(blob));
    };

  </script>
</head>
<body>
  <img
    id="imageToCopy"
  />
</body>
</html>

To show both methods working, we can use two different browsers: FireFox and Chrome. Chrome implements the ClipBoard API by default, but FireFox does not. Running in chrome, we will see the following in the console.

Then in FireFox, your console should look like this.

We did it! We've taken a PNG blob and copied it to the clipboard in a widely compatible way 🥳