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, trabalho com relações com desenvolvedores no Google e, neste artigo sobre minha palestra no Google I/O, vou falar sobre algumas das novas APIs Fugu e como elas melhoram as jornadas principais do usuário no PWA Excalidraw. Assim, você pode se inspirar nessas 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, você pode desenhar caixas e setas que parecem caricaturas e desenhadas à mão. No dia seguinte, você também pode desenhar elipses e texto, além de selecionar objetos e movê-los. Em 3 de janeiro, o app recebeu o nome Excalidraw. E, como todo bom projeto paralelo, a compra do nome de domínio foi uma das primeiras ações de Christopher. Por enquanto, você pode usar cores e exportar o desenho inteiro como um PNG.

Captura de tela do protótipo do aplicativo Excalidraw mostrando que ele oferece suporte a 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 é ruim. Mas o que realmente me chamou a atenção foi mais para baixo 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 em que li a postagem do blog, recebi um pull request que adicionou suporte à API File System Access ao Excalidraw, corrigindo uma solicitação de recurso que alguém havia enviado.

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. Não preciso dizer que eu não abusei do meu poder. E nenhum dos outros 149 colaboradores até agora.

Atualmente, o Excalidraw é um app da Web progressivo 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 da PWA do Excalidraw no estado atual.

Lipis sobre 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, conhecido na Internet como lipis, é o colaborador mais produtivo do Excalidraw. Perguntei a Lipis o que o motiva a dedicar tanto tempo ao Excalidraw:

Como todo mundo, soube do projeto pelo tweet do 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.

Concordo totalmente com lipis. Quem já usou o Excalidraw está procurando desculpas para usá-lo de novo.

Excalidraw em ação

Agora vou mostrar como 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 o traço 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 adicionar um pouco de cor 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.

Eu clico no ícone de salvamento e insiro um nome de arquivo na caixa de diálogo de salvamento de arquivos. No Chrome, um navegador que oferece suporte à API File System Access, não é um download, mas uma operação de salvamento real, em que posso escolher o local e o nome do arquivo e, se fizer edições, posso salvá-las no mesmo arquivo.

Vou mudar o logotipo e deixar o "i" vermelho. Se eu clicar em "Salvar" novamente, minha modificação será salva no mesmo arquivo. Como prova, vou limpar a tela e reabrir o arquivo. 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. Assim, quando faço alterações, acabo com vários arquivos com um número de incremento no nome de arquivo que preenchem minha pasta de downloads. Apesar dessa desvantagem, ainda posso salvar o arquivo.

Como abrir arquivos

Qual é o segredo? Como a abertura e o salvamento funcionam 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() vem de uma pequena biblioteca que escrevi chamada browser-fs-access, que usamos no Excalidraw. Essa biblioteca oferece acesso ao sistema de arquivos pela API File System Access com uma substituição legada, para que possa 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. Essa função retorna uma matriz de arquivos ou um único arquivo, dependendo se vários arquivos foram selecionados. O que resta é colocar o identificador de 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 falar sobre economia. 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 };
};

Vamos 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 esta for a primeira vez, uma caixa de diálogo de arquivo será mostrada e o app receberá um identificador de arquivo para uso futuro. O restante é apenas gravado no arquivo, o que acontece por 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 que já existe, posso implementar um recurso "salvar como" para criar um novo arquivo com base em um arquivo existente. Para mostrar isso, vou abrir um arquivo, fazer algumas modificações e, em vez de substituir o arquivo, criar um novo usando o recurso de salvar como. Isso deixa o arquivo original intacto.

A implementação para navegadores que não oferecem suporte à API File System Access é curta, já que ela cria um elemento de â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 de forma programática. 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 eu coloco um arquivo .excalidraw no aplicativo, ele é aberto imediatamente e posso começar a editar. Em navegadores que oferecem suporte à API File System Access, posso salvar minhas alterações imediatamente. Não é necessário acessar uma caixa de diálogo de salvamento de arquivos, já que o identificador de arquivo necessário foi obtido da 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. Você pode fazer muitas coisas com arquivos: abrir, salvar, salvar novamente, arrastar, soltar. Meu colega Pete e eu documentamos todos esses truques e muito mais em nosso artigo para que você possa se atualizar caso tudo tenha sido 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 é feita pela 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 que eu ainda não mencionei é clicar duas vezes neles. 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 o Microsoft Word.

O Excalidraw tinha uma versão do Electron que oferecia suporte a essas associações de tipos de arquivo. Assim, ao clicar 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 a ele por que ele achava que era possível descontinuar a versão do Electron:

As pessoas pedem um app Electron desde o início, principalmente porque querem abrir arquivos clicando duas vezes. Também tínhamos a intenção de colocar o app nas app stores. Paralelamente, alguém sugeriu criar uma PWA, então fizemos as duas coisas. Felizmente, conhecemos as 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ê pode instalar o app no computador ou dispositivo móvel, sem o peso extra do Electron. Foi fácil decidir descontinuar a versão do 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 é muito!

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 que "a Web ficou boa o suficiente", é por causa de recursos como o processamento de arquivos.

Esta é uma instalação regular do macOS Big Sur. Agora confira 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 duplo clique também funciona, mas é menos dramático demonstrar em um screencast.

Como isso funciona? A primeira etapa é informar ao sistema operacional os tipos de arquivo que meu app 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 o objeto 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 fornece 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. O recurso está programado para chegar ao Chrome ainda este ano.

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

Outro recurso legal 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 processo é surpreendentemente simples. Tudo o que preciso é da tela como um blob, que depois 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 em tempo real

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.

Posso até ver todos os cursores se movendo. O cursor do Pixelbook se move de forma estável, já que é controlado por um trackpad, mas o cursor do smartphone Pixel 3a e do tablet 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 aparece como 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. Alerta de spoiler: não é. Embora tivéssemos uma implementação baseada nessa API no Excalidraw, no final, decidimos adotar uma abordagem mais tradicional baseada 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á impedindo o Excalidraw

A propósito, fiz uma última pergunta a lipis sobre o que ele acha que está faltando na plataforma da Web que impede o Excalidraw:

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

Concordo totalmente com o lipis. Eu também vivo na nuvem. Esperamos 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 há mais uma coisa. Até agora, só conseguia editar um documento por vez. Isso já não é mais necessário. Aproveite pela primeira vez uma versão inicial do modo de aplicativo com guias no Excalidraw. Confira como fica.

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

O modo de aplicativo com guias está nos estágios iniciais, e nem tudo está definido. 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 felizes em ajudar a Web a avançar e permitir que você faça mais na plataforma. Que o Excalidraw continue melhorando e que você crie aplicativos incríveis. Comece a criar em excalidraw.com.

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