Desbloqueando el acceso al portapapeles

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 con el portapapeles. Si bien este método de cortar y pegar es ampliamente compatible, tiene un costo: el acceso al portapapeles es síncrono y solo puede leer y escribir en el DOM.

Esto funciona bien para fragmentos de texto pequeños, pero hay muchos casos en los que bloquear la página para la transferencia al portapapeles genera una mala experiencia. Es posible que se necesite una limpieza o decodificación de imágenes que consuma mucho tiempo antes de que se pueda pegar el contenido de forma segura. Es posible que el navegador deba cargar o incorporar recursos vinculados de un documento pegado. Esto bloquearía la página mientras se espera 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 torno a 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 texto e imágenes en la mayoría de los navegadores, pero la compatibilidad varía. Asegúrate de estudiar con atención la descripción general de la compatibilidad del navegador para cada una de las siguientes secciones.

Copia: Escribir datos en el portapapeles

writeText()

Para copiar texto en el portapapeles, llama a writeText(). Como esta API es asíncrona, la función writeText() devuelve una promesa que se resuelve o rechaza según si el texto pasado se copió 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);
  }
}

Browser Support

  • Chrome: 66.
  • Edge: 79.
  • Firefox: 63.
  • Safari: 13.1.

Source

write()

En realidad, writeText() es solo un método de conveniencia para el método genérico write(), que también te permite copiar imágenes en el portapapeles. Al igual que writeText(), es asíncrona y devuelve 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 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 de 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 para 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);
}

Browser Support

  • Chrome: 76.
  • Edge: 79.
  • Firefox: 127.
  • Safari: 13.1.

Source

El evento de copia

En el caso en que un usuario inicie una copia al portapapeles y no llame a preventDefault(), el evento copy incluye una propiedad clipboardData con los elementos ya en 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 selecciona todo y comienza una copia al portapapeles, tu solución personalizada debe descartar el texto y solo copiar la imagen. Puedes lograrlo como se muestra en el siguiente ejemplo de código. En este ejemplo, no se explica cómo recurrir a APIs anteriores cuando no se admite la API de Clipboard.

<!-- 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, haz lo siguiente:

Browser Support

  • Chrome: 1.
  • Edge: 12.
  • Firefox: 22.
  • Safari: 3.

Source

Para ClipboardItem:

Browser Support

  • Chrome: 76.
  • Edge: 79.
  • Firefox: 127.
  • Safari: 13.1.

Source

Pegar: Lectura de datos del portapapeles

readText()

Para leer texto del portapapeles, llama a navigator.clipboard.readText() y espera a que se resuelva la promesa devuelta:

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

Browser Support

  • Chrome: 66.
  • Edge: 79.
  • Firefox: 125.
  • Safari: 13.1.

Source

read()

El método navigator.clipboard.read() también es asíncrono y devuelve una promesa. Para leer una imagen del 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 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. Al igual que 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);
  }
}

Browser Support

  • Chrome: 76.
  • Edge: 79.
  • Firefox: 127.
  • Safari: 13.1.

Source

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 los archivos de solo lectura en el portapapeles como se describe a continuación. Este evento se activa cuando el usuario presiona el atajo de teclado predeterminado del sistema operativo para pegar o cuando hace clic en Editar y, luego, en Pegar en la barra de menú del navegador. No se necesita ningún otro código de plomerí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());
});

Browser Support

  • Chrome: 3.
  • Edge: 12.
  • Firefox: 3.6.
  • Safari: 4.

Source

El evento de pegado

Como se mencionó antes, hay planes para introducir 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 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);
});

Browser Support

  • Chrome: 1.
  • Edge: 12.
  • Firefox: 22.
  • Safari: 3.

Source

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 cortar o copiar. Esto se debe a dos motivos: 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 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 imágenes, pero también podrían 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 produciría resultados catastróficos cuando se pegara. Imagina una página web que copia de forma silenciosa rm -rf / o una imagen de bomba de descompresión en tu portapapeles.

Mensaje del navegador que le solicita al usuario permiso para acceder al portapapeles.
Es el mensaje de permiso para la API de Clipboard.

Darles a las páginas web acceso de lectura sin restricciones 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 cualquier página podría leer sin que el usuario lo sepa.

Al igual que con muchas APIs nuevas, la API de Clipboard solo se admite en las páginas que se publican a través de HTTPS. Para ayudar a prevenir el abuso, 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 leer desde el portapapeles siempre requiere permiso.

Se agregaron 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, lo que puedes hacer intentando leer datos del portapapeles. El siguiente código muestra este último caso:

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 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 Clipboard resulta muy útil: intentar leer o escribir datos del portapapeles solicita automáticamente permiso al usuario si aún no se otorgó. Dado que la API se basa en promesas, esto es completamente transparente, y si un usuario deniega el permiso del portapapeles, la promesa se rechaza para que la página pueda responder de manera adecuada.

Dado 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 que se muestran aquí no se ejecutan si se pegan directamente en la consola del navegador, ya que las herramientas para desarrolladores 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 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, luego, inhabilitar de forma selectiva diversas funciones y APIs 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 características

Para usar la API de Async Clipboard y, al mismo tiempo, admitir todos los navegadores, prueba navigator.clipboard y vuelve a los métodos anteriores. Por ejemplo, aquí se muestra cómo podrías implementar la acción de pegar 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 esa no es toda la historia. 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 copiará 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 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.

Agradecimientos

Darwin Huang y Gary Kačmarčík implementaron la API de Async Clipboard. Darwin también proporcionó la demostración. Gracias a Kyarik y, de nuevo, a Gary Kačmarčík por revisar partes de este artículo.

Imagen hero de Markus Winkler en Unsplash.