HTML no limpio en la API de Async Clipboard

A partir de Chrome 120, hay una nueva opción unsanitized disponible en la API de Async Clipboard. Esta opción puede ser útil en situaciones especiales con HTML, en las que necesitas pegar el contenido del portapapeles de la misma manera que estaba cuando se copió. Es decir, sin ningún paso de limpieza intermedio que los navegadores suelen aplicar, y con razón. Obtén información para usarla en esta guía.

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

Esto es definitivamente cierto para el texto. Intenta pegar el siguiente código en la Consola de DevTools y, luego, vuelve a enfocar la página de inmediato. (El setTimeout() es necesario para que tengas suficiente tiempo para enfocar la página, que es un requisito de la API de Async Clipboard). Como ves, la entrada es exactamente igual 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 bomba de compresión, los navegadores vuelven a codificar imágenes como las PNG, pero las imágenes de entrada y salida son visualmente exactamente iguales, píxel 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);

Sin embargo, ¿qué sucede con el texto HTML? Como habrás adivinado, con HTML, la situación es diferente. Aquí, el navegador limpia el código HTML para evitar que sucedan eventos no deseados, por ejemplo, quitando las etiquetas <script> del código HTML (y otras como <meta>, <head> y <style>) y aplicando CSS intercalado. Considera el siguiente ejemplo y pruébalo en la consola de DevTools. 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);

Por lo general, la limpieza de HTML es una buena práctica. En la mayoría de los casos, no quieres exponerte a problemas de seguridad, ya que permites HTML sin depurar. Sin embargo, hay situaciones en las que el desarrollador sabe exactamente lo que está haciendo y en las que la integridad del código HTML de entrada y salida es crucial para el correcto funcionamiento de la app. En estas circunstancias, tienes dos opciones:

  1. Si controlas el final de la copia y el pegado, por ejemplo, si copias desde tu app para pegarlo de la misma manera en tu app, debes usar formatos personalizados web para la API de Async Clipboard. Deja de leer aquí y consulta el artículo vinculado.
  2. Si solo controlas el extremo de pegado en tu app, pero no el extremo de copia, quizás porque la operación de copia se realiza en una app nativa que no admite formatos personalizados web, debes usar la opción unsanitized, que se explica en el resto de este artículo.

La limpieza incluye acciones como quitar etiquetas script, intercalar estilos y garantizar 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 código HTML no depurado

Cuando write() (copias) HTML al portapapeles con la API de Async Clipboard, el navegador se asegura de que tenga el formato correcto ejecutándolo a través de un analizador de DOM y serializando la cadena HTML resultante, pero no se realiza ninguna limpieza en este paso. No necesitas hacer nada. Cuando otra aplicación coloca read() HTML en el portapapeles y tu app web habilita la obtención del contenido de alta fidelidad y necesita realizar una 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']. De forma aislada, 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 lo pruebes en la consola de DevTools, verás que la entrada y la salida son las mismas.

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 la observación del comportamiento. Por lo tanto, el siguiente ejemplo se basa en la detección del hecho de si una etiqueta <style> sobrevive, lo que indica compatibilidad, o se está intercalada, lo que indica no compatibilidad. 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 consulta 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 de la plataforma de intercambio y pueden trabajar solo con las opciones de limpieza predeterminadas que realiza el navegador. En los casos excepcionales en los que los desarrolladores deban prestar atención, existe la opción unsanitized.

Agradecimientos

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