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. Mi nombre es Thomas Steiner, trabajo en Relaciones con Desarrolladores en Google y, en este resumen de mi charla en Google I/O, analizaré algunas de las nuevas APIs de Fugu y cómo mejoran los recorridos del usuario principales en la PWA 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, podías dibujar flechas y cuadros que parecían de dibujos animados y hechos a mano. Al día siguiente, también pudiste dibujar elipses y texto, así como seleccionar objetos y moverlos. El 3 de enero, la app recibió su nombre, Excalidraw, y, como con todo buen proyecto secundario, comprar el nombre de dominio fue uno de los primeros actos de Christopher. A estas alturas, ya podrías usar colores y exportar todo el dibujo como un PNG.

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

El 15 de enero, Christopher publicó una entrada de blog que atrajo mucha 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 apenas dos semanas, no está nada mal. Pero lo que realmente despertó mi interés estaba más abajo en la publicación. Christopher escribió que esta vez probó algo nuevo: darles a todos los que enviaron una solicitud de extracción acceso incondicional para confirmar cambios. 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, lo que solucionaba una solicitud de función que había presentado alguien.

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

Mi solicitud de extracción se combinó un día después y, a partir de entonces, tuve acceso completo para confirmar cambios. No hace falta decir que no abusé de mi poder. Y tampoco lo hizo ninguno de los otros 149 colaboradores hasta el momento.

Actualmente, Excalidraw es una app web progresiva instalable y completa con compatibilidad sin conexión, un modo oscuro impresionante 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 su estado actual.

Lipis explica por qué dedica tanto tiempo a Excalidraw

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

Al igual que todos los demás, me enteré de este proyecto por el tweet de Christopher. Mi primera contribución fue agregar la biblioteca de Open Color, los colores que aún forman parte de Excalidraw hoy en día. A medida que el proyecto crecía y recibíamos muchas solicitudes, mi siguiente gran contribución fue crear un backend para almacenar dibujos, de modo que los usuarios pudieran compartirlos. Pero lo que realmente me impulsa a contribuir es que quien probó Excalidraw busca excusas para volver a usarlo.

Estoy totalmente de acuerdo con lipis. Quien probó Excalidraw busca 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 lo suficientemente simple, así que lo intentaré. Un cuadro es la "i", una línea puede ser la barra y la "o" es un círculo. Mantengo presionada la tecla Mayús para obtener un círculo perfecto. Déjame mover la barra un poco para que se vea mejor. Ahora, un poco de color para la "i" y la "o". El azul es bueno. ¿Quizás un estilo de relleno diferente? ¿Todo sólido o con sombreado? No, el rayado se ve genial. No es perfecto, pero esa es la idea de Excalidraw, así que lo guardaré.

Hago clic en el ícono de guardar y, luego, ingreso un nombre de archivo en el diálogo de guardar archivo. En Chrome, un navegador que admite la API de File System Access, no se trata de una descarga, sino de una verdadera operación de guardado, en la que puedo elegir la ubicación y el nombre del archivo, y en la que, si realizo ediciones, puedo guardarlas en el mismo archivo.

Cambiaré el logotipo y haré que la "i" sea roja. 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 rojo y azul modificado vuelve a aparecer.

Trabaja con archivos

En los navegadores que actualmente no admiten la API de File System Access, cada operación de guardado es una descarga, por lo que, cuando realizo cambios, termino con varios archivos con un número creciente en el nombre de archivo que llenan mi carpeta de Descargas. Pero, a pesar de este inconveniente, puedo guardar el archivo.

Cómo abrir archivos

Entonces, ¿cuál es el secreto? ¿Cómo pueden funcionar la apertura y el guardado 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 y que usamos en Excalidraw. Esta biblioteca proporciona acceso al sistema de archivos a través de la API de File System Access con una alternativa heredada, por lo que se puede usar en cualquier navegador.

Primero, te mostraré la implementación cuando se admite la API. Después de negociar los tipos de MIME y las extensiones de archivo aceptados, la parte central es llamar a la función showOpenFilePicker() de la API de File System Access. Esta función devuelve un array de archivos o un solo archivo, según si se seleccionaron varios archivos. Todo lo que queda por hacer es colocar el identificador de archivo en el objeto de archivo para que se pueda recuperar de nuevo.

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 de MIME y las extensiones 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 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, veamos cómo guardar. En Excalidraw, el guardado se realiza en una función llamada saveAsJSON(). Primero, serializa el array de elementos de Excalidraw en JSON, convierte el JSON en un BLOB y, luego, llama a una función llamada fileSave(). Esta función también la proporciona la biblioteca browser-fs-access.

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 veamos la implementación para navegadores con compatibilidad con la API de File System Access. Las primeras líneas parecen un poco complejas, pero lo único que hacen es negociar los tipos de MIME y las extensiones de archivo. Cuando ya guardé el archivo y tengo un identificador de archivo, no es necesario que se muestre un diálogo de guardar. Sin embargo, si es la primera vez que se guarda, se muestra un diálogo de archivo y la app recibe un identificador de archivo para usarlo en el futuro. El resto es solo escribir en el archivo, lo que sucede 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 mostrar esto, abriré un archivo existente, haré algunas modificaciones y, luego, no sobrescribiré el archivo existente, sino que crearé uno nuevo con la función Guardar como. Esto deja el archivo original intacto.

La implementación para los navegadores que no admiten la API de File System Access es breve, ya que lo único 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 valor de su 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 debe revocarse después de su uso. Como se trata solo de una descarga, nunca se muestra un diálogo para guardar el archivo, y todos los archivos se guardan en la carpeta Downloads predeterminada.

Arrastrar y soltar

Una de mis integraciones de sistema favoritas en computadoras es la de arrastrar y soltar. En Excalidraw, cuando suelto un archivo .excalidraw en la aplicación, se abre de inmediato y puedo comenzar a editarlo. En los navegadores que admiten la API de File System Access, incluso puedo guardar mis cambios de inmediato. No es necesario pasar por un diálogo de guardar 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 de transferencia de datos cuando se admite la API de File System Access. Luego, paso este identificador de archivo a loadFromBlob(), que tal vez recuerdes de un par de párrafos anteriores. Puedes hacer muchas cosas con los archivos: abrirlos, guardarlos, sobrescribirlos, arrastrarlos, soltarlos, etcétera. Mi compañero Pete y yo documentamos todos estos trucos y más en nuestro artículo para que puedas ponerte al día en caso de que todo esto haya ido 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 actualmente se encuentra disponible en Android, ChromeOS y Windows es a través de la API de Web Share Target. Aquí estoy en la app de Archivos, en mi carpeta Downloads. Puedo ver 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, entre las opciones que aparecen, se encuentra Excalidraw. Cuando presiono el ícono, puedo ver que el archivo solo contiene el logotipo de I/O.

Lipis en la versión obsoleta de Electron

Una cosa que puedes hacer con los archivos y de la que aún no hablé es hacer doble clic en ellos. Por lo general, cuando haces doble clic en un archivo, se abre la app asociada a su tipo de MIME. Por ejemplo, para .docx, sería Microsoft Word.

Excalidraw solía tener 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 Electron de Excalidraw. Lipis, a quien ya conociste, fue el creador y el responsable de la baja de Excalidraw Electron. Le pregunté por qué creía que era posible desaprobar la versión de Electron:

Desde el principio, los usuarios han solicitado una app de Electron, principalmente porque querían abrir archivos con un doble clic. También teníamos la intención de publicar la app en las tiendas de aplicaciones. Paralelamente, alguien sugirió crear una PWA, así que hicimos ambas cosas. Por suerte, nos presentaron las APIs de Project Fugu, como el acceso al sistema de archivos, el acceso al portapapeles, el control de archivos y mucho más. Con un solo clic, puedes instalar la app en tu computadora o dispositivo móvil, sin el peso adicional de Electron. Fue una decisión fácil desaprobar la versión de Electron, concentrarse solo en la app web y convertirla en la mejor AWP posible. Además, ahora podemos publicar APW en Play Store y Microsoft Store. ¡Es increíble!

Se podría decir que Excalidraw para Electron no se dejó de usar porque Electron sea malo, sino porque la Web se volvió lo suficientemente buena. ¡Me gusta!

Manejo de archivos

Cuando digo que "la Web se ha vuelto lo suficientemente buena", me refiero a funciones como la próxima File Handling.

Esta es una instalación normal de macOS Big Sur. Ahora, observa lo que sucede cuando hago clic con el botón derecho en un archivo de Excalidraw. Puedo elegir abrirlo con Excalidraw, la AWP instalada. Por supuesto, hacer doble clic también funcionaría, pero es menos dramático para demostrarlo en una grabación de pantalla.

Entonces, ¿cómo funciona? El primer paso es hacer que el sistema operativo conozca los tipos de archivos que mi aplicación puede controlar. 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 inicie la aplicación. Esto sucede en la interfaz launchQueue, en la que necesito establecer 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 identificadores de archivos con los que puedo trabajar. Solo me interesa 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 });
      });
    });
}

Nuevamente, si esto fue demasiado rápido, puedes leer más sobre la API de File Handling en mi artículo. Puedes habilitar el control de archivos configurando la marca de funciones experimentales de la plataforma web. Está previsto que se lance 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 lo deseo 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 simple. Todo lo que necesito es el lienzo como un blob, que luego escribo en el portapapeles pasando un array de un solo 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 de Clipboard, 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 la URL de una sesión

¿Sabías que Excalidraw también tiene un modo colaborativo? Diferentes 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 realizo en un dispositivo se reflejan en todos los demás.

Incluso puedo ver cómo se mueven todos los cursores. El cursor del Pixelbook se mueve de forma constante, ya que se controla con un panel táctil, pero el cursor del teléfono Pixel 3a y el cursor de la tablet iPad Pro saltan, ya que controlo estos dispositivos tocando 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 otra pestaña o app del navegador. 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 Idle Detection, una propuesta en etapa inicial en la que se trabajó en el contexto del Proyecto Fugu. Alerta de spoiler: No lo es. 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 que se registraron en el repositorio de detección de inactividad de WICG.

Enviamos comentarios sobre por qué la API de Idle Detection 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 expresar sus opiniones.

Lipis sobre lo que frena a Excalidraw

Hablando de eso, le hice a lipis una última pregunta sobre lo que cree que falta en 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 se encuentran en mi Dropbox o Google Drive, no en mi disco duro. Me gustaría que la API de File System Access incluyera una capa de abstracción para los proveedores de sistemas de archivos remotos, como Dropbox o Google, con la que se pudieran integrar y que los desarrolladores pudieran codificar. Luego, los usuarios podrían 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 se implemente pronto.

Modo de aplicación con pestañas

¡Vaya! Vimos muchas integraciones de API realmente excelentes en Excalidraw. Sistema de archivos, control de archivos, portapapeles, uso compartido en la Web y destino de uso compartido en la Web Pero hay una cosa 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 normal del navegador, sino una pestaña de PWA. En esta nueva pestaña, 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 se encuentra en sus primeras etapas y no todo está definido. Si te interesa, asegúrate de leer sobre 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 mucho impulsar la Web y permitirte hacer más en la plataforma. Brindamos por un Excalidraw cada vez mejor y por todas las aplicaciones increíbles que crearás. Comienza a crear en excalidraw.com.

No puedo esperar a ver algunas de las APIs que mostré hoy en sus apps. Me llamo Tom y me puedes encontrar como @tomayac en Twitter y en Internet en general. Muchas gracias por mirar el video y que disfrutes el resto de Google I/O.