Copying a Binary Image Blob to the Clipboard with JavaScript
Tags: webdev, javascript, tutorial, programming
Published: 2023-11-09 01:52:39

One challenge I encountered when creating [Spindle](https://spindle.licoriceworks.com/?ref=licoriceworks.com) 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](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API?ref=licoriceworks.com). 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

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.

```javascript
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.

```bash
$ 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.

```bash
$ docker restart nginx-test
```

I wrote a separate post about r[unning NGINX locally using Docker that includes an auto-reload script](https://licoriceworks.com/blog/run-nginx/) 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 actually view the image, I have a helper function. This code converts our raw blob into a base64 url that plays nicely with `img`.

```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);
  });
}
```

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**
```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**
```HTML
<img id="imageToCopy" />
```

## The ClipBoard API

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`!

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

## The Legacy execCommand API

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

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

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

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

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

```javascript
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.

```javascript
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

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.

```javascript
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.

```html
<!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.


![Chrome console showing a successful copy using the ClipBoard API](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/scsw9q0sq9rr5mqlyk01.png)

Then in FireFox, your console should look like this.


![FireFox console showing a successful copy using the execCommand API](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/g0zg7o5m51fup67rgnrh.png)

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

Checkout the original post [Copying a Binary Image Blob to the Clipboard with JavaScript](https://licoriceworks.com/blog/copying-a-binary-image-blob-to-the-clipboard/).