Desbloqueando el acceso al portapapeles

Acceso más seguro y desbloqueado al portapapeles para imágenes y texto

La forma tradicional de obtener acceso al portapapeles del sistema era a través de document.execCommand() para las interacciones del portapapeles. Si bien es ampliamente compatible, este método de cortar y pegar tenía un costo: el acceso al portapapeles era síncrono y solo podía leer y escribir en el DOM.

Eso está bien para pequeños fragmentos 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 necesite una limpieza o decodificación de imágenes que demanda mucho tiempo antes de que el contenido se pueda pegar de forma segura. Es posible que el navegador deba cargar recursos vinculados intercalados desde un documento pegado. Eso bloquearía la página mientras se espera en el disco o la red. Imagina que agregas permisos a la combinación, lo que requiere que el navegador bloquee la página mientras solicita acceso al portapapeles. Al mismo tiempo, los permisos implementados en torno a document.execCommand() para la interacción con el portapapeles se definen de manera flexible y varían según el navegador.

La API de Async Clipboard soluciona 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 el texto y las imágenes en la mayoría de los navegadores, pero la compatibilidad varía. Asegúrate de estudiar detenidamente la descripción general de compatibilidad del navegador para cada una de las siguientes secciones.

Copiar: escribir 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 pasó 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);
  }
}

Navegadores compatibles

  • 66
  • 79
  • 63
  • 13.1

Origen

escribir()

En realidad, writeText() es solo un método útil 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 la imagen como un blob. Una forma de hacerlo es solicitar la imagen a un servidor mediante fetch() y, luego, llamar a blob() en la respuesta.

Es posible que solicitar una imagen del servidor no sea conveniente ni posible por diversos 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 de MIME correcto de 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 de antemano el tipo de MIME de los datos.

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

Navegadores compatibles

  • 66
  • 79
  • 13.1

Origen

El evento de copia

En el caso de que un usuario inicie una copia del portapapeles y no llame a preventDefault(), el evento copy incluirá una propiedad clipboardData con los elementos que ya tienen el formato correcto. Si quieres implementar tu propia lógica, debes llamar a preventDefault() para evitar el comportamiento predeterminado y priorizar tu propia implementación. En este caso, clipboardData estará vacío. Considera una página con texto y una imagen y, cuando el usuario seleccione todo y, luego, inicie una copia del portapapeles, tu solución personalizada debería descartar el texto y solo copiar la imagen. Puedes lograrlo como se indica en la siguiente muestra de código. Lo que no se aborda en este ejemplo es cómo recurrir a APIs anteriores cuando la API de Portapapeles 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:

Navegadores compatibles

  • 1
  • 12
  • 22
  • 3

Origen

Para ClipboardItem:

Navegadores compatibles

  • 76
  • 79
  • 13.1

Origen

Pegar: lectura de datos del portapapeles

readText()

Para leer el 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);
  }
}

Navegadores compatibles

  • 66
  • 79
  • 13.1

Origen

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 retener su contenido de diferentes tipos, por lo que deberás iterar sobre 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);
  }
}

Navegadores compatibles

  • 66
  • 79
  • 13.1

Origen

Trabaja con los archivos pegados

Resulta útil que los usuarios puedan usar las 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 la combinación de teclas de pegado predeterminada del sistema operativo o cuando hace clic en Editar y, luego, en Pegar en la barra de menú del navegador. No se necesita más código de fontanería.

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

Navegadores compatibles

  • 3
  • 12
  • 3.6
  • 4

Origen

El evento de pegado

Como se indicó antes, hay planes para agregar eventos que funcionen 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);
});

Navegadores compatibles

  • 1
  • 12
  • 22
  • 3

Origen

Cómo manejar 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. Esto se debe a dos motivos: como desarrollador de apps, no tienes forma de conocer las capacidades de la app en las que el 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 que tiene un nombre, como Pegar y hacer coincidir 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 Acceso al sistema de archivos.

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 una preocupación de seguridad para los navegadores. Sin los permisos adecuados, una página podría copiar silenciosamente todo tipo de contenido malicioso en el portapapeles del usuario que produciría resultados catastróficos cuando se pegaba. Imagina una página web que copia rm -rf / de manera silenciosa o una imagen de bomba de descompresión en tu portapapeles.

Mensaje del navegador que le solicita al usuario el permiso del portapapeles.
La solicitud de permiso para la API de Clipboard.

Dar a las páginas web acceso de lectura ilimitado al portapapeles es aún más problemático. Los usuarios suelen copiar información sensible, como contraseñas y detalles personales, en el portapapeles, que luego puede leer cualquier página sin que el usuario lo sepa.

Al igual que con muchas APIs nuevas, la API de Clipboard solo es compatible con 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 de las pestañas activas pueden escribir en el portapapeles sin solicitar permiso, pero la lectura desde el portapapeles siempre requiere permiso.

Se agregaron los permisos para copiar y pegar 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 muestran estas últimas:

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 el corte o el pegado 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 Portapapeles resulta realmente útil: si intentas leer o escribir datos del portapapeles, se le solicita permiso al usuario automáticamente si aún no se otorgó el permiso. Como la API se basa en promesas, es completamente transparente, y si un usuario rechaza el permiso del portapapeles, se rechaza la promesa para que la página pueda responder de forma adecuada.

Dado que los navegadores solo permiten el acceso al portapapeles cuando una página es la pestaña activa, notarás que algunos de los ejemplos que se muestran aquí no se ejecutan si se pegan directamente en la consola del navegador, ya que las herramientas para desarrolladores son las pestañas activas. Hay un truco: aplaza el acceso al portapapeles con setTimeout() y, luego, haz clic rápidamente dentro de la página para enfocarlo antes de que se llame 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 o inhabilitar de forma selectiva varias APIs y funciones del navegador. En concreto, 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 funciones

Para usar la API de Async Clipboard y admitir todos los navegadores, prueba navigator.clipboard y recurre a los métodos anteriores. Por ejemplo, a continuación se muestra cómo puedes 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);
});

Esa no es la historia completa. Antes de la API de Async Clipboard, existía una mezcla de diferentes implementaciones de copiar y pegar en navegadores web. En la mayoría de los navegadores, este proceso puede activarse mediante document.execCommand('copy') y document.execCommand('paste'). Si el texto que se copiará es una cadena que no está presente en el DOM, debe insertarse en el DOM y seleccionarse:

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 jugar con la API de Async Clipboard en las demostraciones que aparecen a continuación. En Glitch, puedes combinar la demostración de texto o la demostración de imágenes para experimentar con ellas.

El primer ejemplo 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 PNG y que solo se admiten en algunos navegadores.

Agradecimientos

Darwin Huang y GaryKačmarčík implementaron la API de portapapeles asíncrono. Darwin también proporcionó la demostración. Gracias a Kyarik y, nuevamente, a Gary Kačmarčík por revisar partes de este artículo.

Hero image de Markus Winkler en Unsplash.