Excalidraw y Fugu: cómo mejorar los recorridos principales de los usuarios

La tecnología que es muy avanzada no se puede distinguir de la magia. A menos que lo entiendas. Me llamo Thomas Steiner, trabajo en Relaciones con Desarrolladores en Google y, en este artículo de mi charla de Google I/O, analizaré algunas de las nuevas APIs de Fugu y cómo mejoran los recorridos principales de los usuarios en la AWP de Excalidraw, para que puedas inspirarte en estas ideas y aplicarlas a tus propias apps.

Cómo llegué a Excalidraw

Quiero comenzar con una historia. El 1 de enero de 2020, Christopher Chedeau, ingeniero de software de Facebook, tuiteó sobre una pequeña app de dibujo en la que había comenzado a trabajar. Con esta herramienta, puedes dibujar cuadros y flechas que se vean como dibujos animados y dibujados a mano. Al día siguiente, también podrías dibujar elipses y texto, así como seleccionar objetos y moverlos. El 3 de enero, la app recibió su nombre, Excalidraw, y, como con todos los buenos proyectos paralelos, comprar el nombre de dominio fue una de las primeras acciones de Christopher. Ahora, puedes usar colores y exportar todo el dibujo como PNG.

Captura de pantalla de la aplicación del prototipo de Excalidraw que muestra que admitía rectángulos, flechas, elipses y texto.

El 15 de enero, Christopher publicó una entrada de blog que llamó mucho la atención en Twitter, incluida la mía. La publicación comenzó con algunas estadísticas impresionantes:

  • 12,000 usuarios activos únicos
  • 1,500 estrellas en GitHub
  • 26 colaboradores

Para un proyecto que comenzó hace solo dos semanas, no está nada mal. Pero lo que realmente incrementó mi interés estaba más abajo en la publicación. Christopher escribió que esta vez probó algo nuevo: les dio a todos los que enviaron una solicitud de extracción acceso de confirmación incondicional. El mismo día que leí la entrada de blog, tenía una solicitud de extracción que agregaba compatibilidad con la API de File System Access a Excalidraw y solucionaba una solicitud de función que alguien había presentado.

Captura de pantalla del tweet en el que anuncio mi RP.

Mi solicitud de extracción se fusionó un día después y, desde entonces, tuve acceso de confirmación total. No hace falta decir que no abusé de mi poder. Y tampoco lo hizo ningún otro de los 149 colaboradores hasta el momento.

Actualmente, Excalidraw es una app web progresiva instalable y completa con compatibilidad sin conexión, un impresionante modo oscuro y, sí, la capacidad de abrir y guardar archivos gracias a la API de File System Access.

Captura de pantalla de la AWP de Excalidraw en el estado actual.

Lipis explica por qué dedica tanto tiempo a Excalidraw

Así que este es el final de mi historia de “cómo llegué a Excalidraw”, pero antes de profundizar en algunas de las funciones increíbles de Excalidraw, tengo el placer de presentarte a Panayiotis. Panayiotis Lipiridis, en Internet conocido simplemente como lipis, es el colaborador más prolífico de Excalidraw. Le pregunté a lipis qué lo motiva a dedicar tanto tiempo a Excalidraw:

Como todos, me enteré de este proyecto por el tweet de Christopher. Mi primera contribución fue agregar la biblioteca Open Color, los colores que aún son parte de Excalidraw en la actualidad. A medida que el proyecto crecía y teníamos muchas solicitudes, mi siguiente gran contribución fue crear un backend para almacenar dibujos para que los usuarios pudieran compartirlos. Pero lo que realmente me motiva a contribuir es que quien probó Excalidraw busca excusas para volver a usarlo.

Estoy totalmente de acuerdo con lipis. Quienes probaron Excalidraw buscan excusas para volver a usarlo.

Excalidraw en acción

Ahora quiero mostrarte cómo puedes usar Excalidraw en la práctica. No soy un gran artista, pero el logotipo de Google I/O es bastante simple, así que déjame intentarlo. Un cuadro es la “i”, una línea puede ser la barra diagonal y la “o” es un círculo. Mantengo presionada la tecla Mayúsculas para obtener un círculo perfecto. Voy a mover un poco la barra para que se vea mejor. Ahora, agrega color a la “i” y la “o”. El azul es bueno. ¿Quizás un estilo de relleno diferente? ¿Son todos sólidos o están cruzados? No, las líneas punteadas se ven geniales. No es perfecto, pero esa es la idea de Excalidraw, así que permíteme guardarlo.

Hago clic en el ícono de guardar y, luego, ingreso un nombre de archivo en el cuadro de diálogo Guardar archivo. En Chrome, un navegador que admite la API de acceso al sistema de archivos, esto no es una descarga, sino una verdadera operación de guardado, en la que puedo elegir la ubicación y el nombre del archivo y, si hago ediciones, puedo guardarlas en el mismo archivo.

Voy a cambiar el logotipo y a poner la "i" de color rojo. Si ahora vuelvo a hacer clic en Guardar, mi modificación se guardará en el mismo archivo que antes. Como prueba, borraré el lienzo y volveré a abrir el archivo. Como puedes ver, el logotipo modificado en rojo y azul volvió a aparecer.

Trabaja con archivos

En los navegadores que actualmente no admiten la API de acceso al sistema de archivos, cada operación de guardado es una descarga, por lo que, cuando realizo cambios, termino con varios archivos con un número incremental en el nombre del archivo que llenan mi carpeta de Descargas. Sin embargo, a pesar de esta desventaja, puedo guardar el archivo.

Cómo abrir archivos

Entonces, ¿cuál es el secreto? ¿Cómo pueden abrirse y guardarse archivos en diferentes navegadores que pueden admitir o no la API de File System Access? La apertura de un archivo en Excalidraw se realiza en una función llamada loadFromJSON)(, que a su vez llama a una función llamada fileOpen().

export const loadFromJSON = async (localAppState: AppState) => {
  const blob = await fileOpen({
    description: 'Excalidraw files',
    extensions: ['.json', '.excalidraw', '.png', '.svg'],
    mimeTypes: ['application/json', 'image/png', 'image/svg+xml'],
  });
  return loadFromBlob(blob, localAppState);
};

La función fileOpen() proviene de una pequeña biblioteca que escribí llamada browser-fs-access, que usamos en Excalibur. Esta biblioteca proporciona acceso al sistema de archivos a través de la API de File System Access con un resguardo heredado, por lo que se puede usar en cualquier navegador.

Primero, te mostraré la implementación para cuando la API sea compatible. Después de negociar los tipos de MIME y las extensiones de archivo aceptados, la pieza central llama a la función showOpenFilePicker() de la API de File System Access. Esta función muestra un array de archivos o un solo archivo, según si se seleccionan varios archivos. Lo único que queda es colocar el identificador de archivo en el objeto de archivo para que se pueda recuperar nuevamente.

export default async (options = {}) => {
  const accept = {};
  // Not shown: deal with extensions and MIME types.
  const handleOrHandles = await window.showOpenFilePicker({
    types: [
      {
        description: options.description || '',
        accept: accept,
      },
    ],
    multiple: options.multiple || false,
  });
  const files = await Promise.all(handleOrHandles.map(getFileWithHandle));
  if (options.multiple) return files;
  return files[0];
  const getFileWithHandle = async (handle) => {
    const file = await handle.getFile();
    file.handle = handle;
    return file;
  };
};

La implementación de resguardo se basa en un elemento input de tipo "file". Después de la negociación de los tipos y las extensiones de MIME que se aceptarán, el siguiente paso es hacer clic de forma programática en el elemento de entrada para que se muestre el diálogo de apertura de archivos. Cuando se produce un cambio, es decir, cuando el usuario selecciona uno o varios archivos, se resuelve la promesa.

export default async (options = {}) => {
  return new Promise((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    const accept = [
      ...(options.mimeTypes ? options.mimeTypes : []),
      options.extensions ? options.extensions : [],
    ].join();
    input.multiple = options.multiple || false;
    input.accept = accept || '*/*';
    input.addEventListener('change', () => {
      resolve(input.multiple ? Array.from(input.files) : input.files[0]);
    });
    input.click();
  });
};

Cómo guardar archivos

Ahora, vamos a guardar. En Excalidraw, el guardado se realiza en una función llamada saveAsJSON(). Primero, serializa el array de elementos de Excalidraw a JSON, lo convierte en un BLOB y, luego, llama a una función llamada fileSave(). La biblioteca browser-fs-access también proporciona esta función.

export const saveAsJSON = async (
  elements: readonly ExcalidrawElement[],
  appState: AppState,
) => {
  const serialized = serializeAsJSON(elements, appState);
  const blob = new Blob([serialized], {
    type: 'application/vnd.excalidraw+json',
  });
  const fileHandle = await fileSave(
    blob,
    {
      fileName: appState.name,
      description: 'Excalidraw file',
      extensions: ['.excalidraw'],
    },
    appState.fileHandle,
  );
  return { fileHandle };
};

Una vez más, primero analizaré la implementación para navegadores compatibles con la API de File System Access. Las primeras dos líneas parecen un poco complicadas, pero todo lo que hacen es negociar los tipos de MIME y las extensiones de archivo. Cuando guardé antes y ya tengo un identificador de archivo, no se debe mostrar un diálogo de guardado. Sin embargo, si es la primera vez que se guarda, se muestra un diálogo de archivo y la app recupera un identificador de archivo para usarlo en el futuro. Luego, solo se escribe en el archivo, lo que se realiza a través de un flujo de escritura.

export default async (blob, options = {}, handle = null) => {
  options.fileName = options.fileName || 'Untitled';
  const accept = {};
  // Not shown: deal with extensions and MIME types.
  handle =
    handle ||
    (await window.showSaveFilePicker({
      suggestedName: options.fileName,
      types: [
        {
          description: options.description || '',
          accept: accept,
        },
      ],
    }));
  const writable = await handle.createWritable();
  await writable.write(blob);
  await writable.close();
  return handle;
};

La función “Guardar como”

Si decido ignorar un identificador de archivo existente, puedo implementar una función "Guardar como" para crear un archivo nuevo basado en uno existente. Para mostrarte esto, abriré un archivo existente, haré algunas modificaciones y, luego, no reemplazaré el archivo existente, sino que crearé uno nuevo con la función Guardar como. De esta manera, el archivo original permanecerá intacto.

La implementación para los navegadores que no admiten la API de acceso al sistema de archivos es breve, ya que todo lo que hace es crear un elemento de anclaje con un atributo download cuyo valor es el nombre de archivo deseado y una URL de blob como su valor de atributo href.

export default async (blob, options = {}) => {
  const a = document.createElement('a');
  a.download = options.fileName || 'Untitled';
  a.href = URL.createObjectURL(blob);
  a.addEventListener('click', () => {
    setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
  });
  a.click();
};

Luego, se hace clic en el elemento de anclaje de forma programática. Para evitar fugas de memoria, la URL del blob se debe revocar después de su uso. Como solo se trata de una descarga, nunca se muestra un diálogo de guardado de archivos y todos los archivos se almacenan en la carpeta predeterminada Downloads.

Arrastrar y soltar

Una de mis integraciones de sistemas favoritas en computadoras de escritorio es arrastrar y soltar. En Excalidraw, cuando coloco un archivo .excalidraw en la aplicación, se abre de inmediato y puedo comenzar a editarlo. En los navegadores que admiten la API de acceso al sistema de archivos, incluso puedo guardar mis cambios de inmediato. No es necesario pasar por un diálogo de guardado de archivos, ya que el identificador de archivo requerido se obtuvo de la operación de arrastrar y soltar.

El secreto para que esto suceda es llamar a getAsFileSystemHandle() en el elemento transferencia de datos cuando la API de acceso al sistema de archivos es compatible. Luego, paso este identificador de archivo a loadFromBlob(), que quizás recuerdes de hace un par de párrafos. Puedes hacer muchas cosas con los archivos: abrirlos, guardarlos, sobrescribirlos, arrastrarlos y soltarlos. Mi colega Pete y yo documentamos todos estos trucos y mucho más en nuestro artículo para que puedas ponerte al día en caso de que todo esto haya sido demasiado rápido.

const file = event.dataTransfer?.files[0];
if (file?.type === 'application/json' || file?.name.endsWith('.excalidraw')) {
  this.setState({ isLoading: true });
  // Provided by browser-fs-access.
  if (supported) {
    try {
      const item = event.dataTransfer.items[0];
      file as any.handle = await item as any
        .getAsFileSystemHandle();
    } catch (error) {
      console.warn(error.name, error.message);
    }
  }
  loadFromBlob(file, this.state).then(({ elements, appState }) =>
    // Load from blob
  ).catch((error) => {
    this.setState({ isLoading: false, errorMessage: error.message });
  });
}

Cómo compartir archivos

Otra integración del sistema que se encuentra actualmente en Android, ChromeOS y Windows es a través de la API de destino de Compartir con la Web. Aquí estoy en la app de Files, en mi carpeta Downloads. Veo dos archivos, uno de ellos con el nombre no descriptivo untitled y una marca de tiempo. Para verificar su contenido, hago clic en los tres puntos, luego en Compartir, y una de las opciones que aparece es Excalidraw. Cuando presiono el ícono, veo que el archivo solo contiene el logotipo de E/S.

Lipis en la versión obsoleta de Electron

Una de las acciones que puedes hacer con los archivos y de la que aún no te he hablado es hacer doble clic en ellos. Lo que suele suceder cuando haces doble clic en un archivo es que se abre la app asociada con el tipo de MIME del archivo. Por ejemplo, para .docx, sería Microsoft Word.

Excalidraw tenía una versión de Electron de la app que admitía esas asociaciones de tipos de archivos, por lo que, cuando hacías doble clic en un archivo .excalidraw, se abría la app de Excalidraw Electron. Lipis, a quien ya conoces, fue el creador y el encargado de dar de baja Excalidraw Electron. Le pregunté por qué creía que era posible dar de baja la versión de Electron:

Las personas han estado pidiendo una app de Electron desde el principio, principalmente porque querían abrir archivos haciendo doble clic. También teníamos la intención de publicar la app en tiendas de aplicaciones. Al mismo tiempo, alguien sugirió crear una PWA, así que hicimos ambas. Por suerte, conocimos las APIs de Project Fugu, como el acceso al sistema de archivos, al portapapeles, el manejo de archivos y mucho más. Con un solo clic, puedes instalar la app en tu computadora de escritorio o dispositivo móvil, sin el peso adicional de Electron. Fue una decisión fácil dar de baja la versión de Electron, enfocarse solo en la app web y hacer que sea la AWP lo mejor posible. Además, ahora podemos publicar AWP en Play Store y Microsoft Store. ¡Eso es enorme!

Se podría decir que Excalidraw para Electron no dejó de estar disponible porque Electron es malo, en absoluto, sino porque la Web se volvió lo suficientemente buena. Me gusta.

Manejo de archivos

Cuando digo que “la Web se volvió lo suficientemente buena”, me refiero a funciones como el próximo Manejo de archivos.

Esta es una instalación normal de macOS Big Sur. Ahora, comprueba lo que sucede cuando hago clic con el botón derecho en un archivo de Excalibur. Puedo elegir abrirlo con Excalidraw, la AWP instalada. Por supuesto, hacer doble clic también funcionaría, solo que es menos espectacular demostrarlo en una presentación en pantalla.

¿Cómo funciona? El primer paso es hacer que el sistema operativo conozca los tipos de archivos que puede controlar mi aplicación. Lo hago en un campo nuevo llamado file_handlers en el manifiesto de la app web. Su valor es un array de objetos con una acción y una propiedad accept. La acción determina la ruta de URL en la que el sistema operativo inicia tu app, y el objeto de aceptación son pares clave-valor de tipos MIME y las extensiones de archivo asociadas.

{
  "name": "Excalidraw",
  "description": "Excalidraw is a whiteboard tool...",
  "start_url": "/",
  "display": "standalone",
  "theme_color": "#000000",
  "background_color": "#ffffff",
  "file_handlers": [
    {
      "action": "/",
      "accept": {
        "application/vnd.excalidraw+json": [".excalidraw"]
      }
    }
  ]
}

El siguiente paso es controlar el archivo cuando se inicia la aplicación. Esto sucede en la interfaz launchQueue, en la que debo configurar un consumidor llamando a setConsumer(). El parámetro de esta función es una función asíncrona que recibe el launchParams. Este objeto launchParams tiene un campo llamado files que me proporciona un array de controladores de archivos para trabajar. Solo me importa el primero y, a partir de este identificador de archivo, obtengo un blob que luego paso a nuestro viejo amigo loadFromBlob().

if ('launchQueue' in window && 'LaunchParams' in window) {
  window as any.launchQueue
    .setConsumer(async (launchParams: { files: any[] }) => {
      if (!launchParams.files.length) return;
      const fileHandle = launchParams.files[0];
      const blob: Blob = await fileHandle.getFile();
      blob.handle = fileHandle;
      loadFromBlob(blob, this.state).then(({ elements, appState }) =>
        // Initialize app state.
      ).catch((error) => {
        this.setState({ isLoading: false, errorMessage: error.message });
      });
    });
}

Una vez más, si esto fue demasiado rápido, puedes obtener más información sobre la API de File Handling en mi artículo. Para habilitar el manejo de archivos, configura la marca de funciones experimentales de la plataforma web. Se lanzará en Chrome más adelante este año.

Integración del portapapeles

Otra función interesante de Excalidraw es la integración del portapapeles. Puedo copiar todo mi dibujo o solo partes de él en el portapapeles, tal vez agregar una marca de agua si quiero, y luego pegarlo en otra app. Por cierto, esta es una versión web de la app de Paint de Windows 95.

El funcionamiento es sorprendentemente sencillo. Todo lo que necesito es el lienzo como un blob, que luego escribo en el portapapeles pasando un array de un elemento con un ClipboardItem con el blob a la función navigator.clipboard.write(). Para obtener más información sobre lo que puedes hacer con la API del portapapeles, consulta mi artículo y el de Jason.

export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
  const blob = await canvasToBlob(canvas);
  await navigator.clipboard.write([
    new window.ClipboardItem({
      'image/png': blob,
    }),
  ]);
};

export const canvasToBlob = async (canvas: HTMLCanvasElement): Promise<Blob> => {
  return new Promise((resolve, reject) => {
    try {
      canvas.toBlob((blob) => {
        if (!blob) {
          return reject(new CanvasError(t('canvasError.canvasTooBig'), 'CANVAS_POSSIBLY_TOO_BIG'));
        }
        resolve(blob);
      });
    } catch (error) {
      reject(error);
    }
  });
};

Colaborar con otras personas

Cómo compartir una URL de sesión

¿Sabías que Excalidraw también tiene un modo colaborativo? Varias personas pueden trabajar juntas en el mismo documento. Para iniciar una sesión nueva, hago clic en el botón de colaboración en vivo y, luego, inicio una sesión. Puedo compartir la URL de la sesión con mis colaboradores fácilmente gracias a la API de Web Share que integró Excalidraw.

Colaboración en vivo

Simulé una sesión de colaboración de forma local trabajando en el logotipo de Google I/O en mi Pixelbook, mi teléfono Pixel 3a y mi iPad Pro. Puedes ver que los cambios que hago en un dispositivo se reflejan en todos los demás.

Incluso puedo ver todos los cursores moverse. El cursor de la Pixelbook se mueve de forma constante, ya que está controlado por un panel táctil, pero el cursor del teléfono Pixel 3a y el de la tablet iPad Pro saltan, ya que controlo estos dispositivos con el dedo.

Cómo ver los estados de los colaboradores

Para mejorar la experiencia de colaboración en tiempo real, incluso se ejecuta un sistema de detección de inactividad. El cursor del iPad Pro muestra un punto verde cuando lo uso. El punto se vuelve negro cuando cambio a una pestaña o app del navegador diferente. Y cuando estoy en la app de Excalidraw, pero no hago nada, el cursor me muestra como inactivo, simbolizado por las tres zZZ.

Los lectores ávidos de nuestras publicaciones podrían pensar que la detección de inactividad se realiza a través de la API de detección de inactividad, una propuesta en etapa inicial en la que se trabajó en el contexto del proyecto Fugu. Spoiler alert: no es así. Si bien teníamos una implementación basada en esta API en Excalidraw, al final, decidimos optar por un enfoque más tradicional basado en la medición del movimiento del puntero y la visibilidad de la página.

Captura de pantalla de los comentarios sobre la detección de inactividad enviados al repositorio de detección de inactividad de WICG.

Enviamos comentarios sobre por qué la API de detección de inactividad no resolvía el caso de uso que teníamos. Todas las APIs de Project Fugu se desarrollan de forma abierta, por lo que todos pueden participar y hacer escuchar su voz.

Lipis sobre lo que frena a Excalidraw

A propósito, le hice a lipis una última pregunta sobre lo que cree que le falta a la plataforma web que frena a Excalidraw:

La API de File System Access es excelente, pero ¿sabes qué? La mayoría de los archivos que me interesan en la actualidad están en Dropbox o Google Drive, no en mi disco duro. Ojalá la API de File System Access incluyera una capa de abstracción para que los proveedores de sistemas de archivos remotos, como Dropbox o Google, se integren y que los desarrolladores puedan codificar. De esta manera, los usuarios pueden relajarse y saber que sus archivos están seguros con el proveedor de servicios en la nube en el que confían.

Estoy totalmente de acuerdo con lipis, yo también vivo en la nube. Esperamos que esto se implemente pronto.

Modo de aplicación con pestañas

¡Vaya! Vimos muchas integraciones de API realmente geniales en Excalidraw. Sistema de archivos, manejo de archivos, clipboard, uso compartido en la Web y destino de uso compartido en la Web. Pero hay algo más. Hasta ahora, solo podía editar un documento a la vez. Ya no. Disfruta por primera vez de una versión preliminar del modo de aplicación con pestañas en Excalidraw. Así es como se ve.

Tengo un archivo existente abierto en la AWP de Excalidraw instalada que se ejecuta en modo independiente. Ahora, abro una pestaña nueva en la ventana independiente. Esta no es una pestaña de navegador normal, sino una pestaña de la AWP. En esta pestaña nueva, puedo abrir un archivo secundario y trabajar en él de forma independiente desde la misma ventana de la app.

El modo de aplicación con pestañas está en sus primeras etapas y no todo está definido. Si te interesa, asegúrate de leer el estado actual de esta función en mi artículo.

Closing

Para mantenerte al tanto de esta y otras funciones, asegúrate de consultar nuestro seguimiento de la API de Fugu. Nos entusiasma impulsar la Web y permitirte hacer más en la plataforma. ¡Salud por un Excalidraw que mejora cada día y por todas las aplicaciones increíbles que compilarás! Comienza a crear en excalidraw.com.

No puedo esperar a ver algunas de las APIs que mostré hoy en tus apps. Me llamo Tom. Puedes encontrarme como @tomayac en Twitter y en Internet en general. Muchas gracias por mirar el video. Disfruta del resto de Google I/O.