Excalidraw e Fugu: migliorare i percorsi dell'utente principali

Qualsiasi tecnologia sufficientemente avanzata è indistinguibile dalla magia. A meno che tu non lo capisca. Mi chiamo Thomas Steiner, lavoro in Developer Relations presso Google e in questo articolo, che riassume il mio intervento a Google I/O, esaminerò alcune delle nuove API Fugu e il modo in cui migliorano i percorsi utente principali nella PWA Excalidraw, in modo che tu possa trarre ispirazione da queste idee e applicarle alle tue app.

Come sono arrivato a Excalidraw

Voglio iniziare con una storia. Il 1° gennaio 2020, Christopher Chedeau, un ingegnere software di Facebook, ha twittato di una piccola app di disegno su cui aveva iniziato a lavorare. Con questo strumento, puoi disegnare caselle e frecce che sembrano cartoni animati e disegni a mano libera. Il giorno successivo, potresti anche disegnare ellissi e testo, nonché selezionare oggetti e spostarli. Il 3 gennaio l'app ha ricevuto il nome Excalidraw e, come per ogni buon progetto secondario, l'acquisto del nome di dominio è stato uno dei primi atti di Christopher. A questo punto, puoi utilizzare i colori ed esportare l'intero disegno come PNG.

Screenshot del prototipo dell'applicazione Excalidraw che mostra che supporta rettangoli, frecce, ellissi e testo.

Il 15 gennaio, Christopher ha pubblicato un post del blog che ha attirato molta attenzione su Twitter, inclusa la mia. Il post iniziava con alcune statistiche impressionanti:

  • 12.000 utenti attivi unici
  • 1500 stelle su GitHub
  • 26 collaboratori

Per un progetto iniziato appena due settimane fa, non è niente male. Ma la cosa che ha davvero suscitato il mio interesse si trovava più in basso nel post. Christopher ha scritto di aver provato qualcosa di nuovo questa volta: dare a tutti coloro che hanno inviato una pull request l'accesso in scrittura incondizionato. Lo stesso giorno in cui ho letto il post del blog, ho inviato una pull request che aggiungeva il supporto dell'API File System Access a Excalidraw, risolvendo una richiesta di funzionalità che qualcuno aveva inviato.

Screenshot del tweet in cui annuncio le mie PR.

La mia pull request è stata unita il giorno successivo e da quel momento ho avuto accesso completo ai commit. Inutile dire che non ho abusato del mio potere. E nemmeno nessuno degli altri 149 collaboratori finora.

Oggi, Excalidraw è un'app web progressiva installabile a tutti gli effetti con supporto offline, una splendida modalità Buio e, sì, la possibilità di aprire e salvare file grazie all'API File System Access.

Screenshot della PWA Excalidraw nello stato attuale.

Lipis spiega perché dedica così tanto tempo a Excalidraw

Si conclude così la mia storia di come sono arrivato a Excalidraw, ma prima di approfondire alcune delle straordinarie funzionalità di Excalidraw, ho il piacere di presentare Panayiotis. Panayiotis Lipiridis, su internet noto semplicemente come lipis, è il collaboratore più prolifico di Excalidraw. Ho chiesto a lipis cosa lo spinge a dedicare così tanto tempo a Excalidraw:

Come tutti gli altri, ho scoperto questo progetto dal tweet di Christopher. Il mio primo contributo è stato l'aggiunta della libreria Open Color, i colori che ancora oggi fanno parte di Excalidraw. Man mano che il progetto cresceva e ricevevamo molte richieste, il mio contributo successivo è stato quello di creare un backend per archiviare i disegni in modo che gli utenti potessero condividerli. Ma ciò che mi spinge davvero a contribuire è che chiunque abbia provato Excalidraw cerca scuse per usarlo di nuovo.

Sono pienamente d'accordo con lipis. Chiunque abbia provato Excalidraw sta cercando scuse per usarlo di nuovo.

Excalidraw in azione

Ora voglio mostrarti come puoi utilizzare Excalidraw in pratica. Non sono un grande artista, ma il logo di Google I/O è abbastanza semplice, quindi ci provo. Un quadrato è la "i", una linea può essere la barra e la "o" è un cerchio. Tengo premuto Maiusc, così ottengo un cerchio perfetto. Sposto un po' la barra in modo che l'immagine sia migliore. Ora un po' di colore per la "i" e la "o". Il blu è un buon segno. Forse uno stile di riempimento diverso? Tutti pieni o tratteggiati? No, le tratteggiature sono perfette. Non è perfetto, ma è questo il concetto di Excalidraw, quindi fammelo salvare.

Faccio clic sull'icona di salvataggio e inserisco un nome file nella finestra di dialogo di salvataggio del file. In Chrome, un browser che supporta l'API File System Access, non si tratta di un download, ma di una vera e propria operazione di salvataggio, in cui posso scegliere la posizione e il nome del file e in cui, se apporto modifiche, posso semplicemente salvarle nello stesso file.

Fammi cambiare il logo e rendere la "i" rossa. Se ora faccio di nuovo clic su Salva, la mia modifica viene salvata nello stesso file di prima. Come prova, cancello il canvas e riapro il file. Come puoi vedere, il logo rosso-blu modificato è di nuovo lì.

Utilizzo dei file

Sui browser che attualmente non supportano l'API File System Access, ogni operazione di salvataggio è un download, quindi quando apporto modifiche, finisco per avere più file con un numero crescente nel nome file che riempiono la cartella Download. Nonostante questo svantaggio, posso comunque salvare il file.

Apertura dei file

Qual è il segreto? Come possono funzionare l'apertura e il salvataggio su browser diversi che potrebbero supportare o meno l'API File System Access? L'apertura di un file in Excalidraw avviene in una funzione chiamata loadFromJSON)(, che a sua volta chiama una funzione chiamata 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 funzione fileOpen() proviene da una piccola libreria che ho scritto chiamata browser-fs-access che utilizziamo in Excalidraw. Questa libreria fornisce l'accesso al file system tramite l'API File System Access con un fallback legacy, quindi può essere utilizzata in qualsiasi browser.

Innanzitutto, ti mostrerò l'implementazione per quando l'API è supportata. Dopo aver negoziato i tipi MIME e le estensioni dei file accettati, la parte centrale è la chiamata alla funzione showOpenFilePicker() dell'API File System Access. Questa funzione restituisce un array di file o un singolo file, a seconda che siano selezionati più file. A questo punto, non ti resta che inserire l'handle del file nell'oggetto file in modo che possa essere recuperato di nuovo.

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

L'implementazione di riserva si basa su un elemento input di tipo "file". Dopo la negoziazione dei tipi MIME e delle estensioni da accettare, il passaggio successivo consiste nel fare clic a livello di programmazione sull'elemento di input in modo che venga visualizzata la finestra di dialogo di apertura del file. La promessa viene risolta quando l'utente ha selezionato uno o più file.

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

Salvataggio dei file

Ora passiamo al salvataggio. In Excalidraw, il salvataggio avviene in una funzione chiamata saveAsJSON(). Innanzitutto, serializza l'array di elementi Excalidraw in JSON, converte il JSON in un blob e poi chiama una funzione denominata fileSave(). Questa funzione è fornita anche dalla libreria 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 };
};

Ancora una volta, diamo prima un'occhiata all'implementazione per i browser con supporto dell'API File System Access. Le prime due righe sembrano un po' complicate, ma si limitano a negoziare i tipi MIME e le estensioni dei file. Se ho già salvato e ho già un handle del file, non è necessario mostrare la finestra di dialogo di salvataggio. Tuttavia, se si tratta del primo salvataggio, viene visualizzata una finestra di dialogo del file e l'app riceve un handle del file per un uso futuro. Il resto consiste semplicemente nella scrittura nel file, che avviene tramite un flusso scrivibile.

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 funzionalità "Salva come"

Se decido di ignorare un handle di file già esistente, posso implementare una funzionalità "Salva con nome" per creare un nuovo file basato su un file esistente. Per dimostrarlo, aprirò un file esistente, apporterò alcune modifiche e non sovrascriverò il file esistente, ma ne creerò uno nuovo utilizzando la funzionalità Salva con nome. Il file originale rimane invariato.

L'implementazione per i browser che non supportano l'API File System Access è breve, poiché tutto ciò che fa è creare un elemento di ancoraggio con un attributo download il cui valore è il nome file desiderato e un URL blob come valore dell'attributo 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();
};

L'elemento di ancoraggio viene quindi selezionato in modo programmatico. Per evitare perdite di memoria, l'URL del blob deve essere revocato dopo l'uso. Poiché si tratta solo di un download, non viene mai visualizzata alcuna finestra di dialogo di salvataggio dei file e tutti i file vengono inseriti nella cartella Downloads predefinita.

Trascina

Una delle mie integrazioni di sistema preferite sul computer è il trascinamento. In Excalidraw, quando trascino un file .excalidraw sull'applicazione, si apre immediatamente e posso iniziare a modificarlo. Sui browser che supportano l'API File System Access, posso anche salvare immediatamente le modifiche. Non è necessario passare a una finestra di dialogo di salvataggio dei file poiché l'handle del file richiesto è stato ottenuto dall'operazione di trascinamento.

Il segreto per farlo è chiamare getAsFileSystemHandle() sull'elemento data transfer quando l'API File System Access è supportata. Poi passo questo handle del file a loadFromBlob(), che potresti ricordare da un paio di paragrafi precedenti. Tante cose che puoi fare con i file: aprirli, salvarli, sovrascriverli, trascinarli, rilasciarli. Il mio collega Pete e io abbiamo documentato tutti questi trucchi e altri ancora nel nostro articolo, così potrai recuperare se tutto questo è andato un po' troppo veloce.

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

Condivisione di file

Un'altra integrazione di sistema attualmente disponibile su Android, ChromeOS e Windows avviene tramite l'API Web Share Target. Mi trovo nell'app File nella mia cartella Downloads. Vedo due file, uno dei quali con il nome generico untitled e un timestamp. Per controllare il contenuto, faccio clic sui tre puntini, poi su Condividi e una delle opzioni visualizzate è Excalidraw. Quando tocco l'icona, vedo che il file contiene di nuovo solo il logo I/O.

Lipis sulla versione di Electron deprecata

Una cosa che puoi fare con i file di cui non ho ancora parlato è fare doppio clic. In genere, quando fai doppio clic su un file, si apre l'app associata al tipo MIME del file. Ad esempio, per .docx sarebbe Microsoft Word.

Excalidraw aveva una versione Electron dell'app che supportava queste associazioni di tipi di file, quindi quando facevi doppio clic su un file .excalidraw, si apriva l'app Excalidraw Electron. Lipis, che hai già incontrato, è stato sia il creatore che il responsabile del ritiro di Excalidraw Electron. Gli ho chiesto perché riteneva possibile ritirare la versione Electron:

Gli utenti chiedono un'app Electron fin dall'inizio, principalmente perché vogliono aprire i file con un doppio clic. Inoltre, avevamo intenzione di inserire l'app negli store. Contemporaneamente, qualcuno ha suggerito di creare una PWA, quindi abbiamo fatto entrambe le cose. Fortunatamente, ci sono state presentate le API Project Fugu come l'accesso al file system, l'accesso agli appunti, la gestione dei file e altro ancora. Con un solo clic puoi installare l'app sul tuo computer o dispositivo mobile, senza il peso aggiuntivo di Electron. È stato facile decidere di ritirare la versione Electron, concentrarci solo sull'app web e renderla la migliore PWA possibile. Inoltre, ora possiamo pubblicare le PWA sul Play Store e sul Microsoft Store. È un sacco!

Si potrebbe dire che Excalidraw per Electron non è stato ritirato perché Electron è un prodotto scadente, tutt'altro, ma perché il web è diventato abbastanza buono. Mi piace!

Gestione dei file

Quando dico che "il web è diventato abbastanza buono", è grazie a funzionalità come la prossima gestione dei file.

Si tratta di una normale installazione di macOS Big Sur. Ora controlla cosa succede quando faccio clic con il tasto destro del mouse su un file Excalidraw. Posso scegliere di aprirlo con Excalidraw, la PWA installata. Naturalmente anche il doppio clic funziona, ma è meno spettacolare da dimostrare in un screencast.

Come funziona? Il primo passaggio consiste nel comunicare al sistema operativo i tipi di file che la mia applicazione può gestire. Lo faccio in un nuovo campo chiamato file_handlers nel manifest dell'app web. Il suo valore è un array di oggetti con un'azione e una proprietà accept. L'azione determina il percorso URL in cui il sistema operativo avvia l'app e l'oggetto accept sono coppie chiave-valore di tipi MIME e le estensioni dei file associate.

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

Il passaggio successivo consiste nella gestione del file all'avvio dell'applicazione. Questo accade nell'interfaccia launchQueue in cui devo impostare un consumatore chiamando, beh, setConsumer(). Il parametro di questa funzione è una funzione asincrona che riceve launchParams. Questo oggetto launchParams ha un campo denominato files che mi restituisce un array di handle di file con cui lavorare. Mi interessa solo il primo e da questo handle del file ottengo un blob che poi passo al nostro vecchio amico 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 è andato troppo veloce, puoi leggere di più sull'API File Handling nel mio articolo. Puoi attivare la gestione dei file impostando il flag delle funzionalità sperimentali della piattaforma web. È previsto che venga implementato in Chrome entro la fine dell'anno.

Integrazione degli appunti

Un'altra funzionalità interessante di Excalidraw è l'integrazione degli appunti. Posso copiare l'intero disegno o solo alcune parti negli appunti, magari aggiungendo una filigrana se mi va, e poi incollarlo in un'altra app. Questa è una versione web dell'app Paint di Windows 95.

Il funzionamento è sorprendentemente semplice. Mi serve solo il canvas come blob, che poi scrivo negli appunti passando un array di un elemento con un ClipboardItem con il blob alla funzione navigator.clipboard.write(). Per ulteriori informazioni su cosa puoi fare con l'API Clipboard, consulta l'articolo di Jason e il mio.

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

Collaborazione con altri utenti

Condividere l'URL di una sessione

Sapevi che Excalidraw ha anche una modalità collaborativa? Persone diverse possono collaborare allo stesso documento. Per avviare una nuova sessione, faccio clic sul pulsante di collaborazione in tempo reale e poi avvio una sessione. Posso condividere facilmente l'URL della sessione con i miei collaboratori grazie all'API Web Share integrata in Excalidraw.

Collaborazione dal vivo

Ho simulato una sessione di collaborazione in locale lavorando sul logo di Google I/O sul mio Pixelbook, sul mio smartphone Pixel 3a e sul mio iPad Pro. Puoi notare che le modifiche che apporto su un dispositivo vengono applicate a tutti gli altri dispositivi.

Posso persino vedere tutti i cursori muoversi. Il cursore di Pixelbook si muove in modo uniforme, poiché è controllato da un trackpad, ma il cursore dello smartphone Pixel 3a e del tablet iPad Pro saltano, poiché controllo questi dispositivi toccando con il dito.

Visualizzare gli stati dei collaboratori

Per migliorare l'esperienza di collaborazione in tempo reale, è in esecuzione anche un sistema di rilevamento dell'inattività. Il cursore dell'iPad Pro mostra un punto verde quando lo uso. Il punto diventa nero quando passo a un'altra scheda del browser o app. Quando mi trovo nell'app Excalidraw, ma non faccio nulla, il cursore mi mostra come inattivo, simboleggiato dalle tre ZZZ.

I lettori assidui delle nostre pubblicazioni potrebbero essere portati a pensare che il rilevamento dell'inattività venga realizzato tramite l'API Idle Detection, una proposta in fase iniziale su cui si sta lavorando nel contesto del progetto Fugu. Spoiler: non lo è. Sebbene avessimo un'implementazione basata su questa API in Excalidraw, alla fine abbiamo deciso di adottare un approccio più tradizionale basato sulla misurazione del movimento del puntatore e della visibilità della pagina.

Screenshot del feedback sul rilevamento dell&#39;inattività inviato al repository WICG Idle Detection.

Abbiamo inviato un feedback sul motivo per cui l'API Idle Detection non risolveva il caso d'uso che avevamo. Tutte le API Project Fugu sono in fase di sviluppo in modo aperto, quindi chiunque può intervenire e far sentire la propria voce.

Lipis su ciò che frena Excalidraw

A proposito, ho posto a lipis un'ultima domanda su cosa pensa che manchi alla piattaforma web che frena Excalidraw:

L'API File System Access è fantastica, ma sai cosa? La maggior parte dei file che mi interessano si trova su Dropbox o Google Drive, non sul mio disco rigido. Vorrei che l'API File System Access includesse un livello di astrazione per i fornitori di file system remoti come Dropbox o Google da integrare e che gli sviluppatori potessero codificare. Gli utenti possono rilassarsi e sapere che i loro file sono al sicuro con il provider cloud di cui si fidano.

Sono pienamente d'accordo con lipis, anch'io vivo nel cloud. Speriamo che questa funzionalità venga implementata presto.

Modalità applicazione a schede

Wow! Abbiamo visto molte integrazioni API davvero ottime in Excalidraw. File system, gestione dei file, appunti, condivisione web e destinazione di condivisione web. Ma ecco un'altra cosa. Fino ad ora, potevo modificare un solo documento alla volta. Non più. Per la prima volta, puoi provare una versione di anteprima della modalità applicazione a schede in Excalidraw. Ecco come appare.

Ho un file esistente aperto nella PWA Excalidraw installata in modalità autonoma. Ora apro una nuova scheda nella finestra indipendente. Non è una normale scheda del browser, ma una scheda PWA. In questa nuova scheda posso quindi aprire un file secondario e lavorarci in modo indipendente dalla stessa finestra dell'app.

La modalità applicazione a schede è nelle sue fasi iniziali e non tutto è definitivo. Se ti interessa, assicurati di leggere lo stato attuale di questa funzionalità nel mio articolo.

Chiusura

Per rimanere aggiornato su questa e altre funzionalità, assicurati di seguire il nostro tracker delle API Fugu. Siamo entusiasti di far progredire il web e di consentirti di fare di più sulla piattaforma. Auguriamo a Excalidraw un futuro sempre migliore e a tutti voi di creare applicazioni straordinarie. Inizia a creare su excalidraw.com.

Non vedo l'ora di vedere alcune delle API che ho mostrato oggi nelle tue app. Mi chiamo Tom, puoi trovarmi come @tomayac su Twitter e su internet in generale. Grazie per l'attenzione e buona continuazione di Google I/O.