Acceso más seguro y sin bloqueos al portapapeles para imágenes y texto
La forma tradicional de acceder al portapapeles del sistema era a través de document.execCommand()
para las interacciones del portapapeles. Aunque este método de cortar y pegar es muy compatible, tiene un costo: el acceso a la tabla de recortes era síncrono y solo podía leer y escribir en el DOM.
Eso está bien para fragmentos pequeños de texto, pero hay muchos casos en los que bloquear la página para la transferencia del portapapeles es una experiencia deficiente. Es posible que se deba realizar una limpieza o decodificación de imágenes que requiera tiempo antes de que se pueda pegar el contenido de forma segura. Es posible que el navegador necesite cargar o intercalar recursos vinculados desde un documento pegado. Eso bloquearía la página mientras espera en el disco o la red. Imagina que agregas permisos a la mezcla, lo que requiere que el navegador bloquee la página mientras solicita acceso al portapapeles. Al mismo tiempo, los permisos establecidos en document.execCommand()
para la interacción con el portapapeles están definidos de forma imprecisa y varían entre los navegadores.
La API de Async Clipboard aborda estos problemas y proporciona un modelo de permisos bien definido que no bloquea la página. La API de Async Clipboard se limita a controlar imágenes y texto en la mayoría de los navegadores, pero la compatibilidad varía. Asegúrate de estudiar cuidadosamente la descripción general de la compatibilidad con navegadores de cada una de las siguientes secciones.
Copiar: Escribe datos en el portapapeles.
writeText()
Para copiar texto en el portapapeles, llama a writeText()
. Dado que esta API es
asíncrona, la función writeText()
muestra una promesa que se resuelve o
rechaza en función de si el texto que se pasa se copia correctamente:
async function copyPageUrl() {
try {
await navigator.clipboard.writeText(location.href);
console.log('Page URL copied to clipboard');
} catch (err) {
console.error('Failed to copy: ', err);
}
}
write()
En realidad, writeText()
es solo un método conveniente para el método genérico write()
, que también te permite copiar imágenes en el portapapeles. Al igual que writeText()
, es asíncrono y muestra una promesa.
Para escribir una imagen en el portapapeles, necesitas que sea un blob
. Una forma de hacerlo es solicitar la imagen a un servidor con fetch()
y, luego, llamar a blob()
en la respuesta.
Solicitar una imagen del servidor puede no ser deseable o posible por varios motivos. Afortunadamente, también puedes dibujar la imagen en un lienzo y llamar al método toBlob()
del lienzo.
A continuación, pasa un array de objetos ClipboardItem
como parámetro al método write()
. Actualmente, solo puedes pasar una imagen a la vez, pero esperamos agregar compatibilidad con varias imágenes en el futuro. ClipboardItem
toma un objeto con el tipo de MIME de la imagen como clave y el BLOB como valor. En el caso de los objetos BLOB obtenidos de fetch()
o canvas.toBlob()
, la propiedad blob.type
contiene automáticamente el tipo MIME correcto para una imagen.
try {
const imgURL = '/images/generic/file.png';
const data = await fetch(imgURL);
const blob = await data.blob();
await navigator.clipboard.write([
new ClipboardItem({
// The key is determined dynamically based on the blob's type.
[blob.type]: blob
})
]);
console.log('Image copied.');
} catch (err) {
console.error(err.name, err.message);
}
Como alternativa, puedes escribir una promesa en el objeto ClipboardItem
.
Para este patrón, debes conocer el tipo de MIME de los datos de antemano.
try {
const imgURL = '/images/generic/file.png';
await navigator.clipboard.write([
new ClipboardItem({
// Set the key beforehand and write a promise as the value.
'image/png': fetch(imgURL).then(response => response.blob()),
})
]);
console.log('Image copied.');
} catch (err) {
console.error(err.name, err.message);
}
El evento de copia
En el caso en que un usuario inicie una copia del portapapeles y no llame a preventDefault()
, el evento copy
incluye una propiedad clipboardData
con los elementos que ya tienen el formato correcto.
Si deseas implementar tu propia lógica, debes llamar a preventDefault()
para evitar el comportamiento predeterminado en favor de tu propia implementación.
En este caso, clipboardData
estará vacío.
Considera una página con texto y una imagen. Cuando el usuario seleccione todo e inicie una copia en el portapapeles, tu solución personalizada debe descartar el texto y solo copiar la imagen. Puedes lograrlo como se muestra en la siguiente muestra de código.
En este ejemplo, no se explica cómo recurrir a APIs anteriores cuando la API de Clipboard no es compatible.
<!-- The image we want on the clipboard. -->
<img src="kitten.webp" alt="Cute kitten.">
<!-- Some text we're not interested in. -->
<p>Lorem ipsum</p>
document.addEventListener("copy", async (e) => {
// Prevent the default behavior.
e.preventDefault();
try {
// Prepare an array for the clipboard items.
let clipboardItems = [];
// Assume `blob` is the blob representation of `kitten.webp`.
clipboardItems.push(
new ClipboardItem({
[blob.type]: blob,
})
);
await navigator.clipboard.write(clipboardItems);
console.log("Image copied, text ignored.");
} catch (err) {
console.error(err.name, err.message);
}
});
Para el evento copy
:
Para ClipboardItem
:
Pegar: Lee datos del portapapeles.
readText()
Para leer texto del portapapeles, llama a navigator.clipboard.readText()
y espera a que se resuelva la promesa que se muestra:
async function getClipboardContents() {
try {
const text = await navigator.clipboard.readText();
console.log('Pasted content: ', text);
} catch (err) {
console.error('Failed to read clipboard contents: ', err);
}
}
read()
El método navigator.clipboard.read()
también es asíncrono y muestra una promesa. Para leer una imagen desde el portapapeles, obtén una lista de objetos ClipboardItem
y, luego, itera sobre ellos.
Cada ClipboardItem
puede contener su contenido en diferentes tipos, por lo que deberás iterar por la lista de tipos, nuevamente con un bucle for...of
. Para cada tipo, llama al método getType()
con el tipo actual como argumento para obtener el blob correspondiente. Como antes, este código no está vinculado a imágenes y funcionará con otros tipos de archivos futuros.
async function getClipboardContents() {
try {
const clipboardItems = await navigator.clipboard.read();
for (const clipboardItem of clipboardItems) {
for (const type of clipboardItem.types) {
const blob = await clipboardItem.getType(type);
console.log(URL.createObjectURL(blob));
}
}
} catch (err) {
console.error(err.name, err.message);
}
}
Cómo trabajar con archivos pegados
Es útil que los usuarios puedan usar combinaciones de teclas del portapapeles, como Ctrl + C y Ctrl + V. Chromium expone archivos de solo lectura en el portapapeles, como se describe a continuación. Esto se activa cuando el usuario presiona el atajo de pegado predeterminado del sistema operativo o cuando hace clic en Editar y, luego, en Pegar en la barra de menú del navegador. No se necesita ningún código de plomería adicional.
document.addEventListener("paste", async e => {
e.preventDefault();
if (!e.clipboardData.files.length) {
return;
}
const file = e.clipboardData.files[0];
// Read the file's contents, assuming it's a text file.
// There is no way to write back to it.
console.log(await file.text());
});
El evento de pegado
Como se señaló antes, se planea implementar eventos para trabajar con la API de Clipboard,
pero por ahora puedes usar el evento paste
existente. Funciona bien con los nuevos métodos asíncronos para leer el texto del portapapeles. Al igual que con el evento copy
, no olvides llamar a preventDefault()
.
document.addEventListener('paste', async (e) => {
e.preventDefault();
const text = await navigator.clipboard.readText();
console.log('Pasted text: ', text);
});
Cómo controlar varios tipos de MIME
La mayoría de las implementaciones colocan varios formatos de datos en el portapapeles para una sola operación de corte o copia. Hay dos razones para esto: como desarrollador de apps, no tienes forma de conocer las capacidades de la app a la que un usuario quiere copiar texto o imágenes, y muchas aplicaciones admiten pegar datos estructurados como texto sin formato. Por lo general, se presenta a los usuarios con un elemento de menú Editar con un nombre como Pegar y coincidir con el estilo o Pegar sin formato.
En el siguiente ejemplo, se muestra cómo hacerlo. En este ejemplo, se usa fetch()
para obtener datos de imagen, pero también podría provenir de un <canvas>
o de la API de File System Access.
async function copy() {
const image = await fetch('kitten.png').then(response => response.blob());
const text = new Blob(['Cute sleeping kitten'], {type: 'text/plain'});
const item = new ClipboardItem({
'text/plain': text,
'image/png': image
});
await navigator.clipboard.write([item]);
}
Seguridad y permisos
El acceso al portapapeles siempre ha sido un problema de seguridad para los navegadores. Sin los permisos adecuados, una página podría copiar de forma silenciosa todo tipo de contenido malicioso en el portapapeles de un usuario, lo que generaría resultados catastróficos cuando se pegue.
Imagina una página web que copie de forma silenciosa rm -rf /
o una imagen de bomba de descompresión en tu portapapeles.
Permitir que las páginas web accedan sin restricciones al portapapeles es aún más problemático. Los usuarios suelen copiar información sensible, como contraseñas y datos personales, en el portapapeles, que cualquier página podría leer sin el conocimiento del usuario.
Al igual que con muchas APIs nuevas, la API de Clipboard solo es compatible con las páginas que se entregan a través de HTTPS. Para evitar abusos, el acceso al portapapeles solo se permite cuando una página es la pestaña activa. Las páginas en pestañas activas pueden escribir en el portapapeles sin solicitar permiso, pero leer desde el portapapeles siempre requiere permiso.
Se agregaron permisos de copiado y pegado a la API de Permissions.
El permiso clipboard-write
se otorga automáticamente a las páginas cuando son la pestaña activa. Se debe solicitar el permiso clipboard-read
. Para ello, intenta leer los datos del portapapeles. En el siguiente código, se muestra lo último:
const queryOpts = { name: 'clipboard-read', allowWithoutGesture: false };
const permissionStatus = await navigator.permissions.query(queryOpts);
// Will be 'granted', 'denied' or 'prompt':
console.log(permissionStatus.state);
// Listen for changes to the permission state
permissionStatus.onchange = () => {
console.log(permissionStatus.state);
};
También puedes controlar si se requiere un gesto del usuario para invocar cortar o pegar con la opción allowWithoutGesture
. El valor predeterminado de este valor varía según el navegador, por lo que siempre debes incluirlo.
Aquí es donde la naturaleza asíncrona de la API de Clipboard resulta realmente útil: intentar leer o escribir datos del portapapeles le solicita automáticamente permiso al usuario si aún no se le otorgó. Dado que la API se basa en promesas, esto es completamente transparente, y si un usuario rechaza el permiso del portapapeles, la promesa se rechaza para que la página pueda responder de manera adecuada.
Debido a que los navegadores solo permiten el acceso al portapapeles cuando una página es la pestaña activa,
verás que algunos de los ejemplos no se ejecutan si se pegan directamente en la consola del navegador, ya que las herramientas para desarrolladores en sí son la pestaña activa. Hay un truco: aplaza el acceso al portapapeles con setTimeout()
y, luego, haz clic rápidamente dentro de la página para enfocarla antes de que se llamen a las funciones:
setTimeout(async () => {
const text = await navigator.clipboard.readText();
console.log(text);
}, 2000);
Integración de la política de permisos
Para usar la API en iframes, debes habilitarla con la Política de Permisos, que define un mecanismo que permite habilitar y inhabilitar de forma selectiva varias funciones y APIs del navegador. Específicamente, debes pasar clipboard-read
o clipboard-write
, o ambos, según las necesidades de tu app.
<iframe
src="index.html"
allow="clipboard-read; clipboard-write"
>
</iframe>
Detección de atributos
Para usar la API de Async Clipboard y admitir todos los navegadores, prueba navigator.clipboard
y recurre a métodos anteriores. Por ejemplo, a continuación, se muestra cómo
podrías implementar el pegado para incluir otros navegadores.
document.addEventListener('paste', async (e) => {
e.preventDefault();
let text;
if (navigator.clipboard) {
text = await navigator.clipboard.readText();
}
else {
text = e.clipboardData.getData('text/plain');
}
console.log('Got pasted text: ', text);
});
Pero eso no es todo. Antes de la API de Async Clipboard, había una combinación de diferentes implementaciones de copiar y pegar en los navegadores web. En la mayoría de los navegadores, se puede activar la función de copiar y pegar del navegador con document.execCommand('copy')
y document.execCommand('paste')
. Si el texto que se copia es una cadena que no está presente en el DOM, se debe insertar en el DOM y seleccionar:
button.addEventListener('click', (e) => {
const input = document.createElement('input');
input.style.display = 'none';
document.body.appendChild(input);
input.value = text;
input.focus();
input.select();
const result = document.execCommand('copy');
if (result === 'unsuccessful') {
console.error('Failed to copy text.');
}
input.remove();
});
Demostraciones
Puedes probar la API de Async Clipboard en las siguientes demostraciones. En Glitch, puedes hacer un remix de la demo de texto o la demo de imágenes para experimentar con ellas.
En el primer ejemplo, se muestra cómo mover texto dentro y fuera del portapapeles.
Para probar la API con imágenes, usa esta demostración. Recuerda que solo se admiten archivos PNG y solo en algunos navegadores.
Vínculos relacionados
Agradecimientos
Darwin Huang y Gary Kačmarčík implementaron la API de Asynchronous Clipboard. Darwin también proporcionó la demostración. Gracias a Kyarik y, nuevamente, a Gary Kačmarčík por revisar partes de este artículo.
Imagen hero de Markus Winkler en Unsplash.