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

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

Como cheguei ao Excalidraw

Quero começar com uma história. Em 1º de janeiro de 2020, Christopher Chedeau, engenheiro de software do Facebook, tweetou sobre um pequeno app de desenho em que ele estava trabalhando. Com essa ferramenta, é possível desenhar caixas e setas que parecem desenhos animados e desenhados à 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 ganhou o nome de Excalidraw e, como em todos os bons projetos secundários, comprar o nome de domínio foi um dos primeiros atos de Cristóvão. Por enquanto, você pode usar cores e exportar o desenho inteiro como um PNG.

Captura de tela do aplicativo do protótipo do Excalidraw mostrando a compatibilidade com retângulos, setas, elipses e texto.

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

  • 12 mil usuários ativos únicos
  • 1.5K estrelas no GitHub
  • 26 colaboradores

Para um projeto que começou há apenas duas semanas, isso não é nada mal. Mas o que realmente despertou meu interesse mais adiante na postagem. Christopher disse que tentou algo novo dessa vez: dar a todos que fizeram uma solicitação de pull acesso incondicional a confirmações. No mesmo dia quando li a postagem do blog, recebi uma solicitação de envio (link em inglês) que adicionou suporte à API File System Access ao Excalidraw, corrigindo uma solicitação de recurso enviada por alguém.

Captura de tela do tweet em que anunciei meu recorde pessoal.

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

Hoje, o Excalidraw é um Progressive Web App instalável 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 PWA do Excalidraw no estado de hoje.

Lipis e explica por que ele dedica tanto tempo ao Excalidraw

Então, este é o fim da minha história "Como cheguei ao Excalidraw", mas antes de mergulhar em alguns dos recursos incríveis do Excalidraw, tenho o prazer de apresentar Panayiotis. Panayiotis Lipiridis, na Internet simplesmente conhecida como lipis, é o contribuidor mais prolífico do Excalidraw. Perguntei a Lipis o que o motiva a dedicar tanto tempo ao Excalidraw:

Como todos os outros, eu soube do projeto pelo tweet de Christopher. Minha primeira contribuição foi adicionar a biblioteca Open Color, as cores que ainda fazem parte do Excalidraw hoje. À medida que o projeto cresceu e recebemos muitas solicitações, 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 motiva a contribuir é que quem já usou o Excalidraw quer encontrar desculpas para usá-lo de novo.

Eu concordo plenamente com lipis. Quem usou o Excalidraw quer encontrar 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. Mantenho pressionada a tecla Shift para criar um círculo perfeito. Vou mover a barra para que ela fique melhor. Agora vamos mostrar cores para o "i" e o "o". Azul é bom. Talvez um estilo de preenchimento diferente? Só sólido ou com linhas cruzadas? Não, a hachura está ótima. Não é perfeito, mas essa é a ideia do Excalidraw. Vou salvar.

Clique no ícone de salvamento e insira um nome de arquivo na caixa de diálogo de salvamento. No Chrome, um navegador compatível com a API File System Access, isso não é um download, mas uma verdadeira operação de salvamento, em que posso escolher o local e o nome do arquivo e onde, se eu fizer edições, posso salvá-las no mesmo arquivo.

Vou mudar o logotipo e mudar o "i" para vermelho. Se clicarmos novamente em Salvar, a modificação será salva no mesmo arquivo anterior. Como prova, vou limpar a tela e abrir o arquivo de novo. Como você pode ver, o logotipo vermelho-azul modificado está lá novamente.

Como trabalhar com arquivos

Em navegadores que atualmente não oferecem suporte à API File System Access, cada operação de salvamento é um download. Portanto, quando faço alterações, acabo com vários arquivos com um número crescente no nome do arquivo que preenche minha pasta "Downloads". Apesar dessa desvantagem, ainda posso salvar o arquivo.

Como abrir arquivos

Qual é o segredo? Como abrir e salvar podem funcionar em diferentes navegadores que podem ou não oferecer suporte à 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 escrevi, chamada browser-fs-access, que usamos no Excalidraw. Esta biblioteca fornece acesso ao sistema de arquivos por meio da API File System Access com um substituto legado. Assim, ela pode ser usada em qualquer navegador.

Primeiro, vou mostrar a implementação quando a API tiver suporte. Depois de negociar os tipos MIME e extensões de arquivo aceitos, a peça central é chamar 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. Agora só falta colocar o identificador do arquivo no objeto de arquivo 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 e extensões MIME a serem aceitos, a próxima etapa é clicar no elemento de entrada de forma programática para que a caixa de diálogo de abertura de arquivo seja mostrada. 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 em JSON, converte o JSON em um blob e, em seguida, chama uma função chamada fileSave(). Essa função também é 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 };
};

Vou analisar primeiro a implementação para navegadores com suporte à API File System Access. As primeiras linhas parecem um pouco complexas, mas tudo o que elas fazem é negociar os tipos MIME e as extensões de arquivo. Quando eu já salvei e já tenho um identificador de arquivo, nenhuma caixa de diálogo de salvamento precisa ser mostrada. No entanto, se este for o primeiro salvamento, uma caixa de diálogo de arquivo será mostrada e o app vai receber um identificador de arquivo para uso futuro. O restante está apenas gravando no arquivo, o que acontece por meio de um stream 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 outro. Para mostrar isso, vou abrir um arquivo existente, fazer algumas modificações e não substituir o arquivo atual. Em vez disso, crie um novo arquivo usando o recurso "Salvar como". Isso deixa o arquivo original intacto.

A implementação em navegadores que não oferecem suporte à API File System Access é curta, já que tudo o que ela 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();
};

O elemento âncora é clicado programaticamente. Para evitar vazamentos de memória, o URL do blob precisa ser revogado após o uso. Como isso é apenas um download, nenhuma caixa de diálogo de salvamento de arquivos é mostrada, e todos os arquivos são salvos na pasta Downloads padrão.

Arrastar e soltar

Uma das minhas integrações de sistema favoritas no computador é a opção de arrastar e soltar. No Excalidraw, quando solto um arquivo .excalidraw no aplicativo, ele abre imediatamente e posso começar a editar. Em navegadores com suporte à API File System Access, é possível salvar as mudanças imediatamente. Não é necessário passar por uma caixa de diálogo para salvar arquivos, já que o identificador necessário foi recebido na operação de arrastar e soltar.

O segredo para fazer isso acontecer é chamar getAsFileSystemHandle() no item de transferência de dados quando a API File System Access for compatível. Em seguida, transmito esse identificador de arquivo para loadFromBlob(), que você pode lembrar de alguns parágrafos acima. É possível realizar diversas ações com os arquivos: abrir, salvar, salvar em excesso, arrastar e soltar. Meu colega Pete e eu documentamos todos esses truques e muito mais em nosso artigo para que você possa verificar se tudo isso correu um pouco 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 });
  });
}

Compartilhamento de arquivos

Outra integração de sistema atualmente no Android, ChromeOS e Windows é a API Web Share Target. Estou no app Arquivos na minha pasta Downloads. Vejo dois arquivos, um deles com o nome não descritivo untitled e um carimbo de data/hora. Para verificar o que ele contém, clico nos três pontos, depois em "Compartilhar", e uma das opções que aparece é o Excalidraw. Quando toco no ícone, o arquivo só contém o logotipo do I/O novamente.

Lipis na versão descontinuada do Electron

Uma coisa que você pode fazer com arquivos sobre os quais ainda não falei é usar o DoubleClick. O que normalmente acontece quando você clica duas vezes em um arquivo é que o app associado ao tipo MIME do arquivo é aberto. Por exemplo, para .docx, seria Microsoft Word.

O Excalidraw costumava ter uma versão Electron do app que ofereceva suporte a essas associações de tipos de arquivo. Assim, quando você clicasse duas vezes em um arquivo .excalidraw, o app Excalidraw Electron era aberto. Lipis, que você já conheceu, foi o criador e o responsável pela descontinuação do Excalidraw Electron. Perguntei por que ele achava que seria possível descontinuar o uso da versão Electron:

As pessoas têm solicitado o aplicativo Electron desde o início, principalmente porque queriam abrir arquivos clicando duas vezes. Também tínhamos a intenção de colocar o app nas app stores. Paralelamente, alguém sugeriu a criação de um PWA, então fizemos as duas coisas. Por sorte, fomos apresentados às APIs do Project Fugu, como acesso ao sistema de arquivos, acesso à área de transferência, gerenciamento de arquivos e muito mais. Com um único clique, você pode instalar o app no seu computador ou dispositivo móvel, sem o peso extra do Electron. Foi fácil descontinuar o uso da versão Electron, se concentrar apenas no app da Web e torná-lo o melhor PWA possível. Além disso, agora é possível publicar PWAs na Play Store e na Microsoft Store. Isso é incrível!

Poderíamos dizer que o Excalidraw para Electron não foi descontinuado porque o Electron é ruim, mas porque a Web ficou boa o suficiente. Gostei disso.

Processamento de arquivos

Quando digo "a Web se tornou boa o suficiente", é por causa de recursos como o futuro Gerenciamento de arquivos.

Esta é uma instalação comum do macOS Big Sur. Veja o que acontece quando clico com o botão direito do mouse em um arquivo do Excalidraw. Posso abrir o arquivo com o Excalidraw, o PWA instalado. Claro que um clique duplo também funciona, mas é menos dramático demonstrar em um screencast.

Então, como isso funciona? A primeira etapa é tornar conhecidos pelo sistema operacional os tipos de arquivo que meu aplicativo pode processar. Faço isso em um novo campo chamado file_handlers no manifesto do app da Web. O valor é 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, e os objetos de aceitação são pares de chave-valor de tipos MIME e as 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 dessa função é uma função assíncrona que recebe o launchParams. Esse objeto launchParams tem um campo chamado "files" que me dá uma matriz de identificadores de arquivos para trabalhar. Só me importo com o primeiro, e com esse identificador de arquivo eu recebo um blob que transmito 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 });
      });
    });
}

Se você não entendeu tudo, leia mais sobre a API File Handling no meu artigo. É possível ativar o processamento de arquivos definindo a flag de recursos experimentais da plataforma da Web. Ela está programada para ser lançada no Chrome ainda este ano.

Integração com a área de transferência

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

O funcionamento é surpreendentemente simples. Tudo o que preciso é a tela como um blob, que eu 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 você pode fazer com a API Clipboard, consulte o artigo 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);
    }
  });
};

Como colaborar com outras pessoas

Como compartilhar um URL de sessão

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

Colaboração ao vivo

Simulei uma sessão de colaboração localmente trabalhando no logotipo do Google I/O no meu Pixelbook, no meu smartphone Pixel 3a e no meu iPad Pro. As mudanças que 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 constantemente, 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 conferir os status dos colaboradores

Para melhorar a experiência de colaboração em tempo real, há 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 uma guia ou app de navegador diferente. E quando estou no app Excalidraw, mas não faço nada, o cursor mostra que estou inativo, simbolizado pelos três zZZs.

Os leitores assíduos das nossas publicações podem pensar que a detecção de inatividade é realizada pela API Idle Detection, uma proposta inicial que foi trabalhada no contexto do Project Fugu. Spoiler: não é. Embora tivéssemos uma implementação baseada 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 arquivado no repositório da Detecção de inatividade do WICG.

Enviamos 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 em ambiente aberto, para que todos possam participar e ter a voz ouvida.

Lipis sobre o que está atrapalhando o Excalidraw

Por falar nisso, perguntei ao lipis uma última pergunta sobre o que ele acha que falta na plataforma da Web que impede o Excalidraw:

A API File System Access é ótima, mas você sabe de uma coisa? A maioria dos arquivos que me interessam hoje em dia está no Dropbox ou no Google Drive, não no meu disco rígido. Eu queria que a API File System Access incluísse uma camada de abstração para provedores de sistemas de arquivos remotos, como o Dropbox ou o Google, se integrar e que os desenvolvedores possam usar na programação. Os usuários podem ficar tranquilos e saber que os arquivos estão seguros com o provedor de nuvem em que eles confiam.

Eu concordo plenamente com lipis, eu também moro na nuvem. Espero que isso seja implementado em breve.

Modo de aplicativo com guias

Uau! Vimos muitas integrações de API incríveis no Excalidraw. Sistema de arquivos, processamento de arquivos, área de transferência, compartilhamento da Web e destino do compartilhamento da Web. Mas aqui tem mais uma coisa. Até agora, eu só podia editar um documento por vez. Isso já não é mais necessário. Use pela primeira vez uma versão inicial do modo de aplicativo com guias no Excalidraw. É assim que fica.

Tenho um arquivo aberto no PWA do Excalidraw instalado que está sendo executado no modo independente. Agora, abro uma nova guia na janela autônoma. Essa não é uma guia comum do navegador, mas uma guia do PWA. Nessa nova guia, posso abrir um arquivo secundário e trabalhar nele de forma independente na mesma janela do app.

O modo de aplicação com guias está nos estágios iniciais e nem tudo é definitivo. Se você tiver interesse, leia o status atual desse recurso no meu artigo.

Encerramento

Para ficar por dentro desse e de outros recursos, confira nosso rastreador da API Fugu. Estamos muito animados para levar a Web adiante e permitir que você faça mais na plataforma. Este é o Excalidraw em constante melhoria e 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. Agradecemos por assistir e aproveite o Google I/O.