HTML no limpio en la API de Async Clipboard

A partir de Chrome 120, hay una nueva opción unsanitized disponible en la API del portapapeles asíncrono. Esta opción puede ayudar en situaciones especiales con HTML, en las que necesitas pegar el contenido del portapapeles de forma idéntica a como estaba cuando se copió. Es decir, sin ningún paso de limpieza intermedio que los navegadores suelen aplicar (y por buenas razones). Aprende a usarlo en esta guía.

Cuando trabajan con la API de Async Clipboard, en la mayoría de los casos, los desarrolladores no necesitan preocuparse por la integridad del contenido en el portapapeles y pueden suponer que lo que escriben en el portapapeles (copia) es lo mismo que obtendrán cuando lean los datos del portapapeles (pegan).

Sin duda, esto es cierto para el texto. Intenta pegar el siguiente código en la consola de Herramientas para desarrolladores y vuelve a enfocar la página de inmediato. (El elemento setTimeout() es necesario a fin de que tengas tiempo suficiente para enfocar la página, lo cual es un requisito de la API de Async Clipboard). Como puedes ver, la entrada es exactamente la misma que la salida.

setTimeout(async () => {
  const input = 'Hello';
  await navigator.clipboard.writeText(input);
  const output = await navigator.clipboard.readText();
  console.log(input, output, input === output);
  // Logs "Hello Hello true".
}, 3000);

Con las imágenes, es un poco diferente. Para evitar los llamados ataques de bombas de compresión, los navegadores vuelven a codificar imágenes como PNG, pero las imágenes de entrada y salida son visualmente exactamente iguales (píxeles por píxel).

setTimeout(async () => {
  const dataURL =
    'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=';
  const input = await fetch(dataURL).then((response) => response.blob());
  await navigator.clipboard.write([
    new ClipboardItem({
      [input.type]: input,
    }),
  ]);
  const [clipboardItem] = await navigator.clipboard.read();
  const output = await clipboardItem.getType(input.type);
  console.log(input.size, output.size, input.type === output.type);
  // Logs "68 161 true".
}, 3000);

¿Qué sucede con el texto HTML? Como habrás adivinado, con HTML, la situación es diferente. Aquí, el navegador depura el código HTML para evitar que sucedan situaciones perjudiciales. Por ejemplo, se quitan las etiquetas <script> del código HTML (y otras como <meta>, <head> y <style>) y se intercala CSS. Considera el siguiente ejemplo y pruébalo en la consola de Herramientas para desarrolladores. Notarás que el resultado difiere bastante de la entrada.

setTimeout(async () => {
  const input = `<html>  
  <head>  
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />  
    <meta name="ProgId" content="Excel.Sheet" />  
    <meta name="Generator" content="Microsoft Excel 15" />  
    <style>  
      body {  
        font-family: HK Grotesk;  
        background-color: var(--color-bg);  
      }  
    </style>  
  </head>  
  <body>  
    <div>hello</div>  
  </body>  
</html>`;
  const inputBlob = new Blob([input], { type: 'text/html' });
  await navigator.clipboard.write([
    new ClipboardItem({
      'text/html': inputBlob,
    }),
  ]);
  const [clipboardItem] = await navigator.clipboard.read();
  const outputBlob = await clipboardItem.getType('text/html');
  const output = await outputBlob.text();
  console.log(input, output);
}, 3000);

La limpieza de HTML suele ser algo bueno. En la mayoría de los casos, no es recomendable exponerte a problemas de seguridad y permitir HTML no seguro. Sin embargo, hay situaciones en las que el desarrollador sabe exactamente lo que está haciendo y en las que la integridad del HTML de entrada y de salida es crucial para el correcto funcionamiento de la app. En estas circunstancias, tienes dos opciones:

  1. Si controlas la parte de copiar y pegar, por ejemplo, si copias desde tu app para luego pegar contenido dentro de ella, debes usar formatos personalizados de la Web para la API de Async Clipboard. Deja de leer aquí y consulta el artículo vinculado.
  2. Si solo controlas la parte de pegado en tu app, pero no la parte de copiar, tal vez porque la operación de copia ocurre en una app nativa que no admite formatos personalizados para la Web, debes usar la opción unsanitized, que se explica en el resto de este artículo.

La limpieza incluye acciones como quitar las etiquetas script, intercalar estilos y asegurarse de que el HTML tenga el formato correcto. Esta lista no es exhaustiva y es posible que se agreguen más pasos en el futuro.

Copiar y pegar el código HTML no limpio

Cuando write() (copias) HTML en el portapapeles con la API de Async Clipboard, el navegador se asegura de que esté bien formado. Para ello, lo ejecuta a través de un analizador de DOM y serializa la string HTML resultante, pero en este paso no se realiza ninguna limpieza. No es necesario que hagas nada. Cuando otra aplicación coloca el código HTML read() en el portapapeles y tu app web acepta obtener el contenido de fidelidad completa y necesita realizar la limpieza en tu propio código, puedes pasar un objeto de opciones al método read() con una propiedad unsanitized y un valor de ['text/html']. Por separado, se ve de la siguiente manera: navigator.clipboard.read({ unsanitized: ['text/html'] }). La siguiente muestra de código es casi la misma que la que se mostró anteriormente, pero esta vez con la opción unsanitized. Cuando la pruebes en la consola de Herramientas para desarrolladores, verás que la entrada y el resultado son iguales.

setTimeout(async () => {
  const input = `<html>  
  <head>  
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />  
    <meta name="ProgId" content="Excel.Sheet" />  
    <meta name="Generator" content="Microsoft Excel 15" />  
    <style>  
      body {  
        font-family: HK Grotesk;  
        background-color: var(--color-bg);  
      }  
    </style>  
  </head>  
  <body>  
    <div>hello</div>  
  </body>  
</html>`;
  const inputBlob = new Blob([input], { type: 'text/html' });
  await navigator.clipboard.write([
    new ClipboardItem({
      'text/html': inputBlob,
    }),
  ]);
  const [clipboardItem] = await navigator.clipboard.read({
    unsanitized: ['text/html'],
  });
  const outputBlob = await clipboardItem.getType('text/html');
  const output = await outputBlob.text();
  console.log(input, output);
}, 3000);

Compatibilidad con navegadores y detección de funciones

No hay una forma directa de comprobar si la función es compatible, por lo que la detección de funciones se basa en observar el comportamiento. Por lo tanto, el siguiente ejemplo se basa en la detección de si una etiqueta <style> sobrevive, lo que indica compatibilidad, o está intercalada, lo que indica que no es compatible. Ten en cuenta que, para que esto funcione, la página ya debe haber obtenido el permiso del portapapeles.

const supportsUnsanitized = async () => {
  const input = `<style>p{color:red}</style><p>a`;
  const inputBlob = new Blob([input], { type: 'text/html' });
  await navigator.clipboard.write([
    new ClipboardItem({
      'text/html': inputBlob,
    }),
  ]);
  const [clipboardItem] = await navigator.clipboard.read({
    unsanitized: ['text/html],
  });
  const outputBlob = await clipboardItem.getType('text/html');
  const output = await outputBlob.text();
  return /<style>/.test(output);
};

Demostración

Para ver la opción unsanitized en acción, consulta la demostración en Glitch y su código fuente.

Conclusiones

Como se describe en la introducción, la mayoría de los desarrolladores nunca necesitarán preocuparse por la limpieza del portapapeles y solo podrán trabajar con las opciones de limpieza predeterminadas del navegador. En los casos poco frecuentes en los que los desarrolladores necesitan atención, existe la opción unsanitized.

Agradecimientos

Anupam Snigdha y Rachel Andrew revisaron este artículo. El equipo de Microsoft Edge especificó e implementó la API.