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

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

Cómo llegué a Excalidraw

Quiero empezar con una historia. El 1 de enero de 2020, Christopher Chedeau, ingeniero de software de Facebook, twitteó sobre una pequeña app de dibujo en la que había comenzado a trabajar. Con esta herramienta, puedes dibujar cajas y flechas con dibujos animados y dibujados a mano. Al día siguiente, también podrás dibujar puntos suspensivos y texto, además de seleccionar objetos y moverlos. El 3 de enero, la app recibió su nombre, Excalidraw, y, al igual que con todos los buenos proyectos secundarios, la compra del nombre de dominio fue una de las primeras acciones de Christopher. Por ahora, puedes usar colores y exportar todo el dibujo como un archivo PNG.

Captura de pantalla de la aplicación del prototipo de Excalidraw en la que se 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 estadísticas asombrosas:

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

Para un proyecto que comenzó hace solo dos semanas, eso no está para nada malo. Pero lo que realmente aumentó mi interés fue en la publicación. Christopher escribió que probó algo nuevo esta vez: darle acceso de confirmación incondicional a todos los que obtuvieron una solicitud de extracción. El mismo día después de leer la entrada de blog, recibí una solicitud de extracción que agregaba compatibilidad con la API de File System Access a Excalidraw, lo que corrige una solicitud de función que alguien había presentado.

Captura de pantalla del tuit en el que anuncio mi mensaje de Relaciones Públicas.

Mi solicitud de extracción se combinó un día después y, a partir de allí, tuve acceso de confirmación total. No hace falta decir que no abusé de mi poder. Tampoco lo hizo ninguno de los 149 colaboradores hasta ahora.

En la actualidad, Excalidraw es una app web progresiva instalable completa con soporte 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 con el estado actual.

Lípis explica por qué dedica mucho tiempo al excalidraw.

Esto marca el final de mi historia de "Cómo llegué a Excalidraw", pero antes de sumergirme en algunas de las increíbles características de Excalidraw, tengo el placer de presentar 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 gran parte de su tiempo a la Excalidraw:

Como todas las demás personas, me enteré de este proyecto por el tweet de Christopher. Mi primera contribución fue agregar la biblioteca Open Color, los colores que siguen siendo 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 de manera que los usuarios pudieran compartirlos. Pero lo que realmente me impulsa a contribuir es que quien haya probado Excalidraw busca excusas para volver a usarla.

Estoy totalmente de acuerdo con lipis. Quien probó Excalidraw busca excusas para volver a usarla.

Excalidraw en acción

Quiero mostrarte ahora 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 probarlo. Un cuadro es la “i”, una línea puede ser la barra y la “o” es un círculo. Mantengo presionada la tecla Mayúsculas para obtener un círculo perfecto. Déjame mover un poco la barra para que se vea mejor. Ahora algo de color para la "i" y la "o". El azul es bueno. ¿Quizás un estilo de relleno diferente? ¿Todos son sólidos o tienen tramas cruzadas? No, el hachure se ve genial. No es perfecto, pero esa es la idea de Excalidraw, así que déjame guardarla.

Hago clic en el ícono de guardar e ingreso un nombre de archivo en el diálogo de guardado de archivos. En Chrome, un navegador compatible con la API de File System Access, esto no es una descarga, sino una operación de guardado real, en la que puedo elegir la ubicación y el nombre del archivo, y dónde, si lo edito, puedo guardarlos en el mismo archivo.

Voy a cambiar el logotipo y hacer que la "i" sea roja. Si hago clic de nuevo en Guardar, la modificación se guardará en el mismo archivo que antes. Como prueba, voy a borrar el lienzo y volver a abrir el archivo. Como puedes ver, el logotipo rojo-azul modificado está ahí otra vez.

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 hago cambios, termino varios archivos con un número creciente en el nombre del archivo que llena mi carpeta Descargas. A pesar de este inconveniente, puedo guardar el archivo de todos modos.

Cómo abrir archivos

Entonces, ¿cuál es el secreto? ¿Cómo funciona abrir y guardar en diferentes navegadores que pueden o no ser compatibles con 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() que 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 un resguardo heredado, por lo que se puede usar en cualquier navegador.

Primero, te mostraré la implementación para cuando la API es 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, que depende de si se seleccionaron varios archivos. Todo lo que queda entonces es colocar el controlador del 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 negociar los tipos y las extensiones de MIME que se aceptarán, el siguiente paso es hacer clic de manera programática en el elemento de entrada para que se muestre el diálogo de apertura del archivo. Ante un cambio, es decir, cuando el usuario selecciona uno o varios archivos, la promesa se resuelve.

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 ahorrar. En Excalidraw, el guardado se realiza en una función llamada saveAsJSON(). Primero, serializa el array de elementos Excalidraw en JSON, convierte el JSON 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, permítanme ver en primer lugar la implementación para navegadores compatibles con la API de File System Access. Las primeras dos líneas parecen un poco complicadas, pero lo único que hacen es negociar los tipos de MIME y las extensiones de archivo. Cuando guardé antes y ya tengo un controlador de archivo, no es necesario mostrar ningún diálogo de guardado. Sin embargo, si esta es la primera vez que se guarda, se muestra un diálogo de archivo y la app obtiene un controlador de archivo para usarlo en el futuro. El resto solo escribe en el archivo, lo que sucede a través de una transmisión que admite 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 controlador de archivo existente, puedo implementar la función “Guardar como” para crear un archivo nuevo basado en uno existente. Para mostrar esto, abriré un archivo existente, realizaré algunas modificaciones y, luego, no reemplazaré el archivo existente, sino que crearé un archivo nuevo con la función Guardar como. Esto deja intacto el archivo original.

La implementación para navegadores que no admiten la API de File System Access es corta, 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 el valor del 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 manera programática. Para evitar fugas de memoria, la URL del BLOB debe revocarse después de su uso. Como se trata de una descarga, no se muestra ningún diálogo para guardar archivos y todos los archivos se ubican en la carpeta Downloads predeterminada.

Arrastrar y soltar

Una de mis integraciones de sistemas favoritas en computadoras de escritorio 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 editar. En navegadores compatibles con la API de File System Access, puedo guardar mis cambios de inmediato. No es necesario pasar por un diálogo para guardar el archivo, ya que el controlador 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 admita la API de File System Access. Luego, paso el controlador del archivo a loadFromBlob(), que quizás recuerdes de los párrafos anteriores. Muchas acciones que puedes realizar con los archivos: abrirlos, guardar, guardar en exceso, arrastrar y soltar. Mi colega 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 haya salido 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 está en Android, ChromeOS y Windows es mediante la API de Web Share Target. Aquí estoy en la app de Files en la carpeta Downloads. Puedo ver dos archivos, uno de ellos con el nombre indescriptivo untitled y una marca de tiempo. Para ver qué contiene, hago clic en los tres puntos y, luego, comparto una de las opciones que aparece: Excalidraw. Cuando presiono el ícono, puedo ver que el archivo solo contiene el logotipo de E/S de nuevo.

Lipis en la versión obsoleta de Electron

Una cosa que se puede hacer con los archivos de los que todavía no hablamos 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 antes tenía una versión de Electron de la app que admitía esas asociaciones de tipo de archivo, por lo que, cuando hacías doble clic en un archivo .excalidraw, se abría la app de Excalidraw Electron. Lipis, a quien ya habías visto antes, fue tanto el creador como el dado de baja Excalidraw Electron. Le pregunté por qué sentía que era posible dar de baja la versión Electron:

Desde el comienzo, las personas solicitan una app de Electron, principalmente porque querían abrir archivos haciendo doble clic. También teníamos la intención de publicarla en las tiendas de aplicaciones. Al mismo tiempo, alguien sugirió crear una AWP, por lo que hicimos ambas. Afortunadamente, conocimos las APIs de Project Fugu, como el acceso al sistema de archivos, el acceso al portapapeles, el manejo de archivos y 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 dar de baja la versión de Electron, concentrarse solo en la app web y convertirla en la mejor AWP posible. Además, ahora podemos publicar AWP en Play Store y en 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. Esto me gusta.

Manejo de archivos

Cuando digo que "la Web se volvió lo suficientemente buena", es por funciones como la próxima función para administrar archivos.

Esta es una instalación normal de macOS Big Sur. Ahora, echa un vistazo a lo que sucede cuando hago clic con el botón derecho en un archivo de Excalidraw. Puedo abrirlo con Excalidraw, la AWP instalada. Por supuesto que también funcionaría hacer doble clic, pero es menos drástico 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 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 la app, y el objeto de aceptación son pares clave-valor de los tipos de 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 de launchQueue, en la que necesito 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 ofrece un array de controladores de archivos para trabajar. Solo me interesa el primero y, desde este identificador de archivo, obtengo un BLOB que luego le 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 la administración de archivos, configura la marca experimental de funciones de la plataforma web. Está programado para aparecer 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, quizás agregar una marca de agua si quiero y, luego, pegarla en otra app. Por cierto, esta es una versión web de la app de Windows 95 Paint.

La forma en que funciona es sorprendentemente simple. 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 el de Jason y mi artículo.

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

Colaboración con otras personas

Comparte 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 nueva sesión, hago clic en el botón de colaboración en vivo y, luego, inicio una sesión. Puedo compartir fácilmente la URL de la sesión con mis colaboradores gracias a la API de Web Share que Excalidraw integra la URL.

Colaboración en vivo

Simulé una sesión de colaboración localmente trabajando con 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 que todos los cursores se mueven. El cursor de la 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 de la tablet del iPad Pro saltan de un lado a otro, ya que controlo estos dispositivos presionando con el dedo.

Cómo ver los estados de los colaboradores

Para mejorar la experiencia de colaboración en tiempo real, incluso hay un sistema de detección inactivo en ejecución. 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 diferente del navegador. Y cuando estoy en la app Excalidraw, pero sin hacer nada, el cursor me muestra como inactivo, simbolizado por las tres zZZ.

Los lectores ávidos de nuestras publicaciones pueden pensar que la detección de inactividad se logra a través de la API de Idle Detection, una propuesta en una etapa inicial en la que se ha trabajado en el contexto del proyecto Fugu. Alerta de spoiler: no es así. Si bien teníamos una implementación basada en esta API en Excalidraw, al final decidimos usar 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 de la detección de inactividad registrados en el repositorio de detección de inactividad de WICG.

Enviamos comentarios sobre por qué la API de Idle Detection no solucionaba el caso de uso que teníamos. Todas las APIs de Project Fugu se desarrollan de forma abierta, por lo que todos pueden intervenir y escuchar.

Lpis habla sobre lo que está reteniendo el Excalidraw

Para hablar de esto, le hice una última pregunta a lipis sobre lo que cree que falta en la plataforma web que frena Excalidraw:

La API de File System Access es genial, pero ¿sabes qué? Actualmente, la mayoría de los archivos que me interesan están en mi Dropbox o Google Drive, no en mi disco duro. Me gustaría que la API de File System Access incluya una capa de abstracción para que los proveedores de sistemas de archivos remotos, como Dropbox o Google, se integren y con la que los desarrolladores puedan codificar. Así, los usuarios pueden relajarse y saber que sus archivos están seguros con un proveedor de servicios en la nube de confianza.

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

Modo de aplicación con pestañas

¡Increíble! Hemos visto muchas integraciones de API realmente excelentes en Excalidraw. Sistema de archivos, control de archivos, clipboard, uso compartido en la Web y objetivo de uso compartido 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 anticipada del modo de aplicación con pestañas en Excalidraw. Así se ve.

Tengo un archivo existente abierto en la AWP de Excalidraw instalada que se ejecuta en modo independiente. Ahora abro una nueva pestaña en la ventana independiente. Esta no es una pestaña normal del navegador, sino una pestaña de la AWP. En esa pestaña nueva, puedo abrir un archivo secundario y trabajar en ellos 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 es definitivo. 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 mirar nuestro seguimiento de la API de Fugu. Estamos muy emocionados de impulsar la Web y permitirte hacer más en la plataforma. Por un Excalidraw que mejora constantemente y por todas las increíbles aplicaciones que crearás. Comienza a crear en excalidraw.com.

No puedo esperar a ver cómo aparecerán algunas de las APIs que mostré hoy en tus apps. Mi nombre es Tom y me pueden encontrar como @tomayac en Twitter y en Internet en general. Muchas gracias por mirar este video. Que disfruten del resto de Google I/O.