Excalidraw e Fugu: como melhorar as principais jornadas do usuário

Tecnologia suficientemente avançada é indistinguível da magia. A menos que você entenda. Meu nome é Thomas Steiner. Trabalho em relações com desenvolvedores no Google e, neste artigo da minha palestra no Google I/O, mostrarei algumas das novas APIs Fugu e como elas melhoram as principais jornadas do usuário no Excalidraw PWA, para que você possa se inspirar nessas ideias e aplicá-las aos seus próprios apps.

Como entrei na Excalidraw

Quero começar com uma história. Em 1o de janeiro de 2020, Christopher Chedeau, engenheiro de software do Facebook, tuitou sobre um pequeno app de desenho em que começara a trabalhar. Com essa ferramenta, você pode desenhar caixas e setas com visual de desenho animado e desenhada à mão. No dia seguinte, você também pode desenhar elipses e texto, além de selecionar objetos e movê-los. No dia 3 de janeiro, o app recebeu o nome Excalidraw e, como em todo projeto interessante, a compra do nome de domínio foi um dos primeiros atos de Christopher. Agora, você já pode usar cores e exportar o desenho inteiro como PNG.

Captura de tela do aplicativo de protótipo Excalidraw mostrando que ele era compatível com retângulos, setas, elipses e texto.

Em 15 de janeiro, Christopher fez uma postagem do blog que chamou muita atenção no Twitter, incluindo a minha. A publicação começou com algumas estatísticas impressionantes:

  • 12 mil usuários ativos únicos
  • 1,5 mil estrelas no GitHub
  • 26 colaboradores

Para um projeto que começou há apenas duas semanas, isso não é ruim. Mas o que realmente despertou meu interesse foi na publicação. Christopher escreveu que tentou algo novo: oferecer a todos que receberam uma solicitação de envio acesso de confirmação incondicional. No mesmo dia em que leia a postagem do blog, recebi uma solicitação de envio que adicionou suporte à API File System Access para o Excalidraw, corrigindo uma solicitação de recurso enviada por alguém.

Captura de tela do tweet em que anuncio meu RP.

Minha solicitação de envio foi mesclada um dia depois e, a partir daí, eu tinha acesso de confirmação total. Nem preciso dizer que não abusei do meu poder. E ninguém mais dos 149 colaboradores até agora.

Atualmente, o Excalidraw é um Progressive Web App instalável completo com suporte off-line, um modo escuro incrível e, sim, a capacidade de abrir e salvar arquivos graças à API File System Access.

Captura de tela do Excalidraw PWA no estado de hoje.

Lipis sobre por que ele dedica tanto tempo ao Excalidraw

Então, isso marca o fim da minha história de "como vim para o Excalidraw", mas antes de mergulhar em alguns dos recursos incríveis do Excalidraw, tenho o prazer de apresentar Panayiotis. A Panayiotis Lipiridis, na Internet conhecida simplesmente como lipis, é a colaboradora mais prolífica do Excalidraw. Perguntei ao lipis o que o motiva a dedicar tanto do seu tempo ao Excalidraw:

Como todos os outros que aprendi sobre o projeto no tweet de Christopher. Minha primeira contribuição foi adicionar a biblioteca Open Color, as cores que ainda fazem parte do Excalidraw. Com o crescimento do projeto e muitos pedidos, minha próxima grande contribuição foi criar um back-end para armazenar desenhos para que os usuários pudessem compartilhá-los. Mas o que realmente me leva a contribuir é que quem tentou o Excalidraw está procurando desculpas para usá-lo novamente.

Concordo totalmente com lipis. Quem tentou usar o Excalidraw está procurando desculpas para usá-lo novamente.

Excalidraw em ação

Quero mostrar agora como você pode usar o Excalidraw na prática. Não sou um grande artista, mas o logotipo do Google I/O é bem simples, então vou tentar. Uma caixa é o "i", uma linha pode ser a barra e o "o" é um círculo. Eu seguro Shift e faço o círculo perfeito. Vou mover um pouco a barra para que fique melhor. Agora uma cor para o "i" e o "o". Azul é bom. Talvez um estilo de preenchimento diferente? Totalmente sólido ou cruzado? Não, hachure está ótima. Não é perfeito, mas essa é a ideia do Excalidraw. Vou salvar.

Clico no ícone "Salvar" e insiro um nome de arquivo na caixa de diálogo "Salvar arquivo". No Chrome, um navegador que oferece suporte para a API File System Access, isso não é um download, mas uma operação de salvamento real, em que posso escolher o local e o nome do arquivo e onde, se eu fizer edições, posso simplesmente salvá-los no mesmo arquivo.

Vou alterar o logotipo e deixar o "i" vermelho. Se eu clicar em salvar novamente, minha modificação será salva no mesmo arquivo de antes. Como prova, vou limpar a tela e abrir o arquivo de novo. Como você pode ver, o logotipo vermelho-azul modificado está de novo.

Como trabalhar com arquivos

Em navegadores que não oferecem suporte à API File System Access, cada operação de salvamento é um download. Por isso, quando faço alterações, acabo com vários arquivos com um número crescente no nome do arquivo que preenchem minha pasta "Downloads". Mas, apesar da desvantagem, ainda é possível salvar o arquivo.

Abrir arquivos

Qual é o segredo? Como abrir e salvar o trabalho em diferentes navegadores que podem ou não ser compatíveis com a API File System Access? A abertura de um arquivo no Excalidraw acontece em uma função chamada loadFromJSON)(, que, por sua vez, chama uma função chamada 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);
};

A função fileOpen() que vem de uma pequena biblioteca que criei chamada browser-fs-access, que usamos no Excalidraw. Essa biblioteca oferece acesso ao sistema de arquivos pela API File System Access com um substituto legado para que possa ser usada em qualquer navegador.

Primeiro, vou mostrar como implementar a API. Depois de negociar os tipos MIME e as extensões de arquivo aceitos, a parte central está chamando a função showOpenFilePicker() da API File System Access. Essa função retorna uma matriz de arquivos ou um único arquivo, dependendo da seleção de vários arquivos. Tudo o que resta é colocar o identificador do arquivo no objeto file, para que ele possa ser recuperado novamente.

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

A implementação substituta depende de um elemento input do tipo "file". Após a negociação dos tipos MIME e extensões a serem aceitos, a próxima etapa é clicar de maneira programática no elemento de entrada para que a caixa de diálogo de abertura do arquivo seja mostrada. Na alteração, ou seja, quando o usuário seleciona um ou vários arquivos, a promessa é resolvida.

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

Como salvar arquivos

Agora vamos salvar. No Excalidraw, o salvamento acontece em uma função chamada saveAsJSON(). Primeiro, ele serializa a matriz de elementos do Excalidraw para JSON, converte o JSON em um blob e depois chama uma função chamada fileSave(). Essa função é fornecida pela 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 };
};

Vamos conferir de novo a implementação para navegadores com suporte à API File System Access. As primeiras linhas parecem um pouco envolvidas, mas tudo o que fazem é negociar os tipos MIME e as extensões de arquivo. Quando eu tiver salvado antes e já tiver um identificador de arquivo, nenhuma caixa de diálogo para salvar precisará ser mostrada. No entanto, se este for o primeiro salvamento, uma caixa de diálogo de arquivo será exibida, e o app receberá um identificador de arquivo para uso futuro. O restante é apenas gravar no arquivo, o que acontece por meio de um fluxo gravável.

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

O recurso "Salvar como"

Se eu decidir ignorar um identificador de arquivo já existente, posso implementar um recurso "salvar como" para criar um novo arquivo com base em um arquivo existente. Para mostrar isso, vou abrir um arquivo existente, fazer algumas modificações e não substituir o arquivo existente, mas criar um novo usando o recurso "Salvar como". Isso mantém o arquivo original intacto.

A implementação para navegadores que não oferecem suporte à API File System Access é curta, já que tudo o faz é criar um elemento âncora com um atributo download, cujo valor é o nome de arquivo desejado e um URL de blob como o valor do 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();
};

Em seguida, o elemento de âncora é clicado de maneira programática. Para evitar vazamentos de memória, o URL do blob precisa ser revogado após o uso. Como é apenas um download, nenhuma caixa de diálogo para salvar arquivos é mostrada, e todos os arquivos são colocados na pasta Downloads padrão.

Arrastar e soltar

Uma das minhas integrações de sistema favoritas para computadores é o recurso de arrastar e soltar. No Excalidraw, quando soltar um arquivo .excalidraw no aplicativo, ele abre imediatamente e posso começar a editar. Em navegadores compatíveis com a API File System Access, posso até salvar minhas alterações imediatamente. Não é necessário passar por uma caixa de diálogo para salvar o arquivo, já que o identificador de arquivo necessário foi recebido da operação de arrastar e soltar.

O segredo para fazer isso acontecer é chamar getAsFileSystemHandle() no item de transferência de dados quando há suporte para a API File System Access. Em seguida, transfiro esse identificador de arquivo para loadFromBlob(), que você pode se lembrar dos parágrafos acima. Muitas coisas que você pode fazer com os arquivos: abrir, salvar, salvar em excesso, arrastar e soltar. Meu colega Pete e eu documentamos todos esses truques e mais em nosso artigo para que você possa se atualizar caso tudo tenha ficado rápido demais.

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

Como compartilhar arquivos

Outra integração do sistema atualmente no Android, ChromeOS e Windows é pela API Web Share Target. Aqui, estou no app Arquivos, na minha pasta Downloads. Há dois arquivos, um deles com o nome untitled sem descrição e um carimbo de data/hora. Para conferir o conteúdo, clico nos três pontos e depois compartilho. Uma das opções que aparece é o Excalidraw. Quando toco no ícone, vejo que o arquivo contém apenas o logotipo de E/S novamente.

Lipis na versão descontinuada do Electron

Uma coisa que você pode fazer com os arquivos que ainda não mencionei é doubleclick-os. O que normalmente acontece quando você clica em um arquivo é que o app associado ao tipo MIME do arquivo é aberto. Por exemplo, para .docx, seria Microsoft Word.

O Excalidraw usava uma versão Electron do app com suporte a essas associações de tipos de arquivo. Por isso, quando você clicava duas vezes em um arquivo .excalidraw, o app Excalidraw Electron era aberto. Lipis, que você já conheceu antes, foi o criador e o depreciador do Excalidraw Electron. Perguntei a ele por que ele achava que era possível descontinuar a versão Electron:

As pessoas pediam um app Electron desde o início, principalmente porque queriam abrir arquivos clicando duas vezes. Pretendemos também colocar o aplicativo em app stores. Paralelamente, alguém sugeriu criar um PWA, então fizemos os dois. Felizmente, fomos apresentados às APIs do Project Fugu, como acesso ao sistema de arquivos, acesso à área de transferência, processamento de arquivos e muito mais. Com um único clique, você instala o app no computador ou dispositivo móvel, sem o peso extra do Electron. Foi uma decisão fácil descontinuar a versão Electron, focar apenas no app da Web e torná-la o melhor PWA possível. Além disso, agora podemos publicar PWAs na Play Store e na Microsoft Store. Isso é incrível!

Podemos dizer que o Excalidraw for Electron não foi descontinuado porque o Electron é ruim, de jeito nenhum, mas porque a Web já se tornou boa o suficiente. Gostei!

Gerenciamento de arquivos

Quando digo "a web tornou-se boa o suficiente", é por causa de recursos como o próximo processamento de arquivos.

Esta é uma instalação normal do macOS Big Sur. Agora, veja o que acontece quando clico com o botão direito em um arquivo do Excalidraw. Posso abrir com o Excalidraw, o PWA instalado. É claro que clicar duas vezes também funciona, mas é menos dramático para demonstrar em um screencast.

Então, como isso funciona? A primeira etapa é tornar conhecidos pelo sistema operacional os tipos de arquivo que podem ser processados pelo aplicativo. Faço isso em um novo campo chamado file_handlers no manifesto do app da Web. O valor dela é uma matriz de objetos com uma ação e uma propriedade accept. A ação determina o caminho do URL em que o sistema operacional inicia o app. O objeto Accept são pares de chave-valor de tipos MIME e extensões de arquivo associadas.

{
  "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"]
      }
    }
  ]
}

A próxima etapa é processar o arquivo quando o aplicativo for iniciado. Isso acontece na interface launchQueue, em que preciso definir um consumidor, chamando setConsumer(). O parâmetro para essa função é uma função assíncrona que recebe o launchParams. Esse objeto launchParams tem um campo chamado "files" que me fornece uma matriz de identificadores de arquivos para trabalhar. Eu só me importo com o primeiro e, a partir desse identificador de arquivo, recebo um blob, que será transmitido para nosso velho 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 });
      });
    });
}

Novamente, se o processo for muito rápido, leia mais sobre a API File Handling no meu artigo. Para ativar o gerenciamento de arquivos, defina a flag de recursos experimentais da plataforma da Web. Ele está programado para ser lançado no Chrome ainda este ano.

Integração da área de transferência

Outro recurso legal do Excalidraw é a integração com a área de transferência. Posso copiar todo o desenho ou apenas partes dele para a área de transferência, talvez adicionar uma marca-d'água, se quiser, e colar em outro app. Esta é uma versão da Web do app Windows 95 Paint.

O modo como isso funciona é surpreendentemente simples. Tudo o que preciso é a tela como um blob, que escrevo na área de transferência transmitindo uma matriz de um elemento com um ClipboardItem com o blob para a função navigator.clipboard.write(). Para mais informações sobre o que é possível fazer com a API da área de transferência, consulte o de Jason e o meu artigo.

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

Como colaborar com outras pessoas

Como compartilhar um URL de sessão

Você sabia que o Excalidraw também tem um modo colaborativo? Diferentes pessoas podem trabalhar juntas no mesmo documento. Para iniciar uma nova sessão, clico no botão "Colaboração ao vivo" e inicio uma sessão. Posso compartilhar o URL da sessão com meus colaboradores facilmente graças à API Web Share integrada pelo Excalidraw.

Colaboração em tempo real

Simulei uma sessão de colaboração localmente trabalhando no logotipo do Google I/O no Pixelbook, no smartphone Pixel 3a e no iPad Pro. Observe que as alterações que eu faço em um dispositivo são refletidas em todos os outros.

Consigo até ver todos os cursores se movendo. O cursor do Pixelbook se move de forma constante, já que é controlado por um trackpad, mas o cursor do smartphone Pixel 3a e o cursor do tablet do iPad Pro pulam, já que controlo esses dispositivos tocando com o dedo.

Como acessar os status dos colaboradores

Para melhorar a experiência de colaboração em tempo real, contamos com até um sistema de detecção de inatividade em execução. O cursor do iPad Pro mostra um ponto verde quando eu o uso. O ponto fica preto quando mudo para outra guia do navegador ou app. E quando estou no app Excalidraw, mas sem fazer nada, o cursor me mostra como inativo, simbolizado pelos três zZZs.

Leitores ávidos das nossas publicações podem pensar que a detecção de inatividade é realizada por meio da API Idle Detection, uma proposta em estágio inicial que tem sido trabalhada no contexto do Projeto Fugu. Spoiler: não é. Embora tivéssemos uma implementação com base nessa API no Excalidraw, no final, decidimos adotar uma abordagem mais tradicional com base na medição do movimento do ponteiro e da visibilidade da página.

Captura de tela do feedback da detecção de inatividade registrado no repositório de detecção de inatividade do WICG.

Enviamos um feedback sobre por que a API Idle Detection não estava resolvendo o caso de uso que tínhamos. Todas as APIs do Project Fugu estão sendo desenvolvidas de forma aberta, para que todos possam participar e ser ouvidos.

Lípis sobre o que está impedindo o Excalidraw

Falando nisso, fiz uma última pergunta ao Lipis sobre o que ele acha que está faltando na plataforma Web que retém o Excalidraw:

A API File System Access é ótima, mas sabe de uma coisa? A maioria dos arquivos que me importo atualmente ficam no Dropbox ou no Google Drive, e não no disco rígido. Quero que a API File System Access inclua uma camada de abstração para provedores de sistemas de arquivos remotos, como o Dropbox ou o Google, para integração e para uso dos desenvolvedores na programação. Assim, os usuários podem relaxar e saber que os arquivos estão seguros com o provedor de nuvem em que confiam.

Concordo totalmente com a Lipis, eu também moro na nuvem. Esperamos que isso seja implementado em breve.

Modo de aplicativo com guias

Uau! Vimos muitas integrações de API ótimas no Excalidraw. Sistema de arquivos, gerenciamento de arquivos, área de transferência, compartilhamento na Web e destino de compartilhamento na Web. Mas há mais uma coisa. Até agora, eu só conseguia editar um documento por vez. Não teria. Aproveite pela primeira vez uma versão inicial do modo de aplicativo com guias no Excalidraw. É assim que aparece.

Tenho um arquivo aberto no Excalidraw PWA instalado em execução no modo independente. Agora, abro uma nova guia na janela autônoma. Esta não é uma guia normal do navegador, mas uma guia do PWA. Nessa nova guia, posso abrir um arquivo secundário e trabalhar nele independentemente da mesma janela do app.

O modo de aplicativo com guias está nos estágios iniciais e nem tudo está errado. Se você tiver interesse, leia sobre o status atual desse recurso no meu artigo.

Encerramento

Para ficar por dentro desse e de outros recursos, confira nosso rastreador de API do Fugu. Estamos muito animados para levar a Web adiante e permitir que você faça mais na plataforma. Um brinde a um Excalidraw cada vez melhor e a todos os aplicativos incríveis que você criará. Comece a criar em excalidraw.com.

Mal posso esperar para ver algumas das APIs que mostrei hoje nos seus apps. Meu nome é Tom. Você pode me encontrar como @tomayac no Twitter e na Internet em geral. Muito obrigado por assistir. Aproveite o restante do Google I/O.