Writing to the Clipboard in JavaScript
Writing to the Clipboard in JavaScript êŽë š
In my last article, I showed you how to enable your website to read a visitor's clipboard. Now I'm going to follow up that guide with a look at writing to the clipboard. It goes without saying that in any use of this type of functionality, you should proceed with care and, most of all, respect for your visitors. I'll talk a bit about what that means later in the article, but for now, let's look at the API.
Article Series
Before we beginâŠ
As I said last time, clipboard functionality on the web requires a âsecure contextâ. So if you're running an http site (as opposed to an https site), these features will not work. I'd highly encourage you to get your site on https. That being said, these features, and others like them that require secure contexts, will still work on http://localhost
. There's no need to set up a temporary certificate when doing local testing.
The Clipboard API
I covered this last time, but in case you didn't read the previous article in this series, the Clipboard API is supported in JavaScript via navigator.clipboard
and has excellent cross-platform support:
This feature will also prompt the user for permission so remember to handle cases where they reject the request.
Writing to the Clipboard
When I last discussed the clipboard API, I mentioned how it had two APIs for reading from the clipboard, we had a readText
method tailored for, you guessed it, reading text, and a more generic read
method for handling complex data. Unsurprisingly, we've got the same on the write side:
write
writeText
And just like before, writeText
is specifically for writing text to the clipboard while write
gives you additional flexibility.
At the simplest, you can use it like so:
await navigator.clipboard.writeText("Hello World!");
That's literally it. Here's a Pen demonstrating an example of this, but you will most likely need to make use of the 'debug' link to see this in action due to the permissions complication of cross-domain iframes:
One pretty simple and actually practical use-case for something like this is quickly copying links to the user's clipboard. Let's consider some simple HTML:
<div class="example">
<p>
<a href="https://www.raymondcamden.com">My blog</a><br />
<a href="https://frontendmasters.com/blog/">Frontend Masters blog</a>
</p>
</div>
What I want to do is:
- Pick up all the links (filtered by something logical)
- Automatically add a UI item that will copy the URL to the clipboard
links = document.querySelectorAll("div.example p:first-child a");
links.forEach((a) => {
let copy = document.createElement("span");
copy.innerText = "[Copy]";
copy.addEventListener("click", async () => {
await navigator.clipboard.writeText(a.href);
});
a.after(copy);
});
I begin with a selector for the links I care about, and for each, I append a <button>
element with "Copy URL of Link"
as the text. Each new element has a click handler to support copying its related URL to the clipboard. As before, here's the CodePen but expect to need the debug link:
As a quick note, the UX of this demo could be improved, for example, notifying the user in some small way that the text was successfully copied.
Now let's kick it up a notch and look into how to support binary data with the write
method.
The basic interface for write
is to pass an array of ClipboardItem
objects. The MDN docs for write provide this example:
const type = "text/plain";
const blob = new Blob([text], { type });
const data = [new ClipboardItem({ [type]: blob })];
await navigator.clipboard.write(data);
That seems sensible. For my first attempt, I decided to add simple âclick to copyâ support to an image. So consider this HTML:
<img src="house.jpg" alt="a house">
I could support this like so:
document.addEventListener('DOMContentLoaded', init, false);
async function init() {
document.querySelector('img').addEventListener('click', copyImagetoCB);
}
async function copyImagetoCB(e) {
// should be dynamic
let type = 'image/jpeg';
let dataReq = await fetch(e.target.src);
let data = await dataReq.blob();
let blob = new Blob([data], { type });
let cbData = [new ClipboardItem({ [type]: blob })];
await navigator.clipboard.write(cbData);
console.log('Done');
}
This code picks up the one and only image, adds a click handler, and makes use of the MDN sample code, updated to use a hard-coded type (JPG) and to fetch the binary data. (This kind of bugs me. I know the image is loaded in the DOM so it feels like I should be able to get access to the bits without another network call, but from what I can see, you can only do this by using a Canvas object.)
But when run, you get something interesting:
Uncaught (in promise) DOMException: Failed to execute 'write' on 'Clipboard': Type image/jpeg not supported on write.
What??? Turns out, this is actually documented on MDN:
Browsers commonly support writing text, HTML, and PNG image data
This seems⊠crazy. I mean I definitely get having some restrictions, but it feels odd for only one type of image to be supported. However, changing my HTML:
<img src="house.png" alt="a house">
And the type in my JavaScript:
let type = 'image/png';
Confirms that it works. You can see this yourself below (and again, hit that âDebug' link):
So remember above when I said I didn't want to use a canvas? I did some Googling and turns out: I need to use a canvas. I found an excellent example of this on StackOverflow in this answer. In it, the author uses a temporary canvas, writes the image data to it, and uses toBlob
while specifying a PNG image type. Whew. So let's see if we can build a generic solution.
First, I updated my HTML to support two images:
<div class="photos">
<p>
JPG:<br/>
<img src="house.jpg" alt="a house">
</p>
<p>
PNG:<br/>
<img src="house.png" alt="a house">
</p>
</div>
I labeled them as I was using the same picture each and needed a simple way to know which was which.
Next, I updated my code to pick up all images in that particular div:
document.addEventListener('DOMContentLoaded', init, false);
async function init() {
let imgs = document.querySelectorAll('div.photos img');
imgs.forEach(i => {
i.addEventListener('click', copyImagetoCB);
});
}
Now for the fun part. I'm going to update my code to detect the type of the image. For PNG's, it will use what I showed before, and for JPGs, I'm using a modified version of the StackOverflow answer. Here's the updated code:
async function copyImagetoCB(e) {
let type = e.target.src.split(".").pop();
let blob;
console.log("type", type);
// support JPG/PNG only
if (type === "jpg" || type === "jpeg") {
blob = await setCanvasImage(e.target.src);
} else if (type.endsWith("png")) {
let dataReq = await fetch(e.target.src);
let data = await dataReq.blob();
blob = new Blob([data], { type: "image/png" });
}
let cbData = [new ClipboardItem({ "image/png": blob })];
await navigator.clipboard.write(cbData);
console.log("Done");
}
async function setCanvasImage(path) {
return new Promise((resolve, reject) => {
const img = new Image();
const c = document.createElement("canvas");
const ctx = c.getContext("2d");
img.onload = function () {
c.width = this.naturalWidth;
c.height = this.naturalHeight;
ctx.drawImage(this, 0, 0);
c.toBlob((blob) => {
resolve(blob);
}, "image/png");
};
img.src = path;
});
You'll note that the main difference is how we get the blob. For JPGs, we're using setCanvasImage
to handle the canvas shenanigans (that's what I'm calling it) and return a PNG blob.
You can see this in the CodePen below, if, again, you click out to the debug view. Note that I had to add one additional line:
img.crossOrigin = "anonymous";
As without it, you get a tainted canvas error. Sounds dirty.
With great powerâŠ
It goes without saying that this API could be abused, or, more likely, used in kind of a jerky manner. So for example, if you give the user the ability to click to copy a URL, don't do sneaky crap like:
"(url here) - This URL comes from the awesome site X and you should visit it for deals!"
If you are expressing to the user that you are copying something in particular, adding to it or modifying it is simply rude. I'd even suggest that copying a short URL link instead of the original would be bad form as well.
Article Series