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 cajas y flechas que parecen caricaturas y dibujadas 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 obtuvo su nombre, Excalidraw y, como en todos los proyectos buenos, 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 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 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. 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 mucho de su tiempo a la 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 los demás, aprendí sobre este proyecto por el tweet de Christopher. Mi primera contribución fue agregar la Biblioteca Open Color, los colores que todavía forman 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 impulsa a contribuir es que quien haya probado Excalidraw busca excusas para volver a usarla.

Estoy totalmente de acuerdo con lipis. Quien haya probado Excalidraw busca excusas para volver a usarlo.

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 intentarlo. Un cuadro es la "i", una línea puede ser la barra inclinada 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, un poco de color para 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, el hachure se ve genial. No es perfecto, pero esa es la idea de Excalidraw, así que permíteme guardarlo.

Hago clic en el ícono de guardar e ingreso un nombre de archivo en el cuadro de diálogo de guardado. 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 hago cambios, puedo guardarlos en el mismo archivo.

Voy a cambiar el logotipo y a poner la "i" de color rojo. Si vuelvo a hacer clic en Guardar, la 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-azul modificado está allí de nuevo.

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, obtengo varios archivos con un número creciente en el nombre de archivo que ocupan mi carpeta Descargas. Sin embargo, a pesar de esta desventaja, puedo guardar el archivo.

Cómo abrir archivos

Entonces, ¿cuál es el secreto? ¿Cómo funciona la apertura y el guardado en diferentes navegadores que pueden o no admitir la API de File System Access? La apertura de un archivo en Excalidraw ocurre 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í, se llama browser-fs-access 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 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 manera programática en el elemento de entrada para que se muestre el diálogo de archivo abierto. Si se produce 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 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(). 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 un archivo, se mostrará un diálogo de archivo y la app obtendrá un controlador de archivos para usarlo más adelante. 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 controlador de archivo existente, puedo implementar la función “Guardar como” para crear un archivo nuevo basado en uno existente. Para mostrar esto, permíteme abrir un archivo existente, hacer alguna modificación y, luego, no reemplazar el archivo existente, sino crear uno nuevo con la función de guardar como. De esta manera, el archivo original permanecerá intacto.

La implementación para los navegadores que no admiten la API de File System Access es corta, ya que todo lo que hace es crear un elemento de anclaje con un atributo download cuyo valor sea 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 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 para 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 navegadores compatibles con la API de File System Access, puedo guardar de inmediato mis cambios. 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 File System Access sea 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 actualmente en Android, ChromeOS y Windows es a través de la API de Web Share Target. Estoy en la app de Archivos en la 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 una de las opciones que aparece es Excalidraw. Cuando presiono el ícono, puedo ver que el archivo solo contiene el logotipo de I/O otra vez.

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

Los usuarios solicitaron la posibilidad de usar una app Electron desde el comienzo, 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 AWP, por lo que hicimos ambas cosas. Por suerte, conocimos las APIs de Project Fugu, como el acceso al sistema de archivos, al portapapeles y al manejo de archivos. 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 en Microsoft Store. Eso es fundamental.

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, es por funciones como File Handling, que se implementará próximamente.

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 que hacer doble clic también funcionaría, pero demostrarlo en una presentación en pantalla es menos dramático.

¿Cómo funciona? El primer paso es hacer que el sistema operativo conozca los tipos de archivos que puede manejar 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 permite trabajar con un array de controladores de archivos. Solo me importa el primero y, desde este controlador 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 });
      });
    });
}

Nuevamente, si esto fue muy rápido, puedes leer más sobre la API de File Handling en mi artículo. Para habilitar el control de archivos, configura la marca experimental de funciones 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 en el portapapeles, quizás agregar una marca de agua si lo siento y, luego, pegarlo en otra aplicación. Por cierto, esta es una versión web de la aplicación 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. Para ello, debo pasar 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 artículo 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);
    }
  });
};

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 a nivel 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 dispositivos.

Incluso puedo ver todos los cursores moverse. El cursor de la Pixelbook se mueve de manera constante, ya que lo controla un panel táctil, pero el cursor del teléfono Pixel 3a y el de la tablet del iPad Pro saltan, 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 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. 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 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 para que todos puedan intervenir y escuchar sus opiniones.

Lípis sobre lo que está reteniendo a Excalidibujo

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 genial, 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. Me gustaría que la API de File System Access incluyera una capa de abstracción para que se integren los proveedores de sistemas de archivos remotos, como Dropbox o Google, y con los que los desarrolladores puedan programar. Los usuarios pueden relajarse y saber que sus archivos están seguros con el proveedor de servicios.

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! Hemos visto muchas integraciones de API realmente excelentes en Excalidraw. Sistema de archivos, control de archivos, clipboard, Web Share y objetivo de uso compartido 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 normal del navegador, sino una pestaña 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 es grabado a fuego. 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. Estamos muy contentos de impulsar la web y permitirte hacer más en la plataforma. Celebremos una Excalidraw en constante mejora, y aquí te presentamos todas las increíbles aplicaciones que compilarás. Comienza a crear en excalidraw.com.

No puedo esperar a ver algunas de las APIs que mostré hoy en tus apps. Mi nombre es Tom. Puedes encontrarme como @tomayac en Twitter y en Internet en general. Muchas gracias por mirar este video. Que disfrutes el resto de Google I/O.