Excalidraw e Fugu: migliorare i percorsi dell'utente principali

Qualsiasi tecnologia sufficientemente avanzata è indistinguibile dalla magia. A meno che tu non la capisca. Sono Thomas Steiner, lavoro in Developer Relations in Google e in questo articolo del mio talk di Google I/O esamino alcune delle nuove API Fugu e come 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, ingegnere informatico di Facebook, ha twittato in merito a una piccola app di disegno su cui aveva iniziato a lavorare. Con questo strumento puoi disegnare caselle e frecce che sembrano disegnate a mano e da cartone animato. Il giorno successivo, puoi anche disegnare ellissi e testo, nonché selezionare oggetti e spostarli. Il 3 gennaio l'app ha preso il suo nome, Excalidraw e, come per ogni progetto collaterale valido, l'acquisto del nome di dominio è stato una delle prime azioni di Christopher. Ora puoi utilizzare i colori ed esportare l'intero disegno come file PNG.

Screenshot dell'applicazione prototipo di Excalidraw che mostra il supporto di rettangoli, frecce, ellissi e testo.

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

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

Per un progetto iniziato solo due settimane fa, non è affatto male. ma ciò che ha davvero suscitato il mio interesse era più in basso nel post. Christopher ha scritto di aver provato qualcosa di nuovo questa volta: ha concesso a tutti coloro che hanno inviato una pull request l'accesso ai commit incondizionato. Lo stesso giorno in cui ho letto il post del blog, ho ricevuto una richiesta di pull che aggiungeva il supporto dell'API File System Access a Excalidraw, correggendo una richiesta di funzionalità presentata da qualcuno.

Screenshot del tweet in cui annuncio il mio RP.

La mia richiesta di pull è stata unita un giorno dopo e da quel momento ho avuto accesso completo ai commit. Inutile dire che non ho abusato del mio potere. E nessun altro utente dai 149 collaboratori finora.

Oggi Excalidraw è un'app web progressiva installabile a tutti gli effetti con supporto offline, una straordinaria modalità Buio e 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

Questa è la fine della mia storia su "Come sono arrivato a Excalidraw", ma prima di addentrarmi in alcune delle sue incredibili caratteristiche, ho il piacere di presentare la Panayiotis. Panayiotis Lipiridis, su internet conosciuto semplicemente come lipis, è il collaboratore più prolifico di Excalibur. Ho chiesto a Lipis che cosa lo spinge a dedicare così tanto tempo a Excalidraw:

Come tutte le altre persone che ho scoperto su questo progetto dal tweet di Christopher. Il mio primo contributo è stato l'aggiunta della Open Color Library, i colori che fanno ancora parte di Excalidraw oggi. Con la crescita del progetto e con molte richieste, il mio successivo grande contributo è stato quello di creare un backend per l'archiviazione dei disegni, in modo che gli utenti potessero condividerli. Ma ciò che mi spinge davvero a dare il mio contributo è che chi ha provato Excalidraw cerca scuse per usarlo di nuovo.

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

Excalidraw in azione

Ora voglio mostrarti come puoi usare Excalidraw nella pratica. Non sono un bravo artista, ma il logo di Google I/O è abbastanza semplice, quindi provo. Una casella è la "i", una linea può essere la barra e la "o" è un cerchio. Tengo premuto Maiusc, in modo da ottenere un cerchio perfetto. Faccio un po' di spazio tra la barra, così sarà più bella. Ora aggiungi un po' di colore alle lettere "i" e "o". Il blu è un buon segno. Forse hai uno stile di riempimento diverso? Tutto a tinta unita o tratteggio incrociato? No, hachure sembra fantastico. Non è perfetto, ma è l'idea di Excalidraw, quindi fammi salvarlo.

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

Fammi cambiare il logo e rendere rossa la "i". Se ora faccio di nuovo clic su Salva, la modifica viene salvata nello stesso file di prima. Come prova, cancellerò la tela e riaprirò il file. Come puoi vedere, il logo rosso-blu modificato è di nuovo visibile.

Lavorare con i file

Nei browser che al momento 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 che riempiono la mia cartella Download. Tuttavia, nonostante questo svantaggio, posso comunque salvare il file.

Apertura dei file

Qual è il segreto? In che modo l'apertura e il salvataggio possono funzionare su diversi browser che potrebbero o meno supportare l'API File System Access? L'apertura di un file in Excalidraw avviene in una funzione denominata loadFromJSON)(), che a sua volta chiama una funzione denominata 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 precedente, pertanto può essere utilizzata in qualsiasi browser.

Vediamo prima l'implementazione quando l'API è supportata. Dopo aver negoziato i tipi MIME e le estensioni dei file accettati, l'elemento centrale è la chiamata alla funzione showOpenFilePicker() dell'API Accesso al file system. Questa funzione restituisce un array di file o un singolo file, a seconda che siano selezionati più file. Rimane solo l'inserimento dell'handle del file nell'oggetto del file, in modo che possa essere recuperato.

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 programmaticamente sull'elemento di input in modo che venga visualizzata la finestra di dialogo di apertura del file. In caso di modifica, ovvero quando l'utente ha selezionato uno o più file, la promessa viene risolta.

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 in corso...

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 chiamata fileSave(). Anche questa funzione è fornita 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 };
};

Esaminiamo di nuovo l'implementazione per i browser che supportano l'API File System Access. Le prime due righe sembrano un po' complesse, ma tutto quello che fanno è negoziare i tipi MIME e le estensioni dei file. Se ho già eseguito il salvataggio e ho già un handle di file, non deve essere visualizzata alcuna finestra di dialogo per il salvataggio. Tuttavia, se si tratta del primo salvataggio, viene visualizzata una finestra di dialogo del file e l'app recupera l'handle del file da usare in futuro. Il resto è solo la scrittura nel file, che avviene tramite un stream scritturabile.

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 con nome"

Se decido di ignorare un handle file già esistente, posso implementare una funzionalità "Salva come" per creare un nuovo file in base a uno esistente. Per dimostrarlo, apro un file esistente, apporto alcune modifiche e non sovrascrivo il file esistente, ma ne creo uno nuovo utilizzando la funzionalità Salva come. Il file originale rimane quindi intatto.

L'implementazione per i browser che non supportano l'API File System Access è breve, dato che tutto ciò è creare un elemento anchor con un attributo download il cui valore è il nome file desiderato e un URL del 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 tramite programmazione. Per evitare perdite di memoria, l'URL del blob deve essere revocato dopo l'utilizzo. Poiché si tratta solo di un download, non verrà visualizzata alcuna finestra di dialogo per il salvataggio dei file e tutti i file finiranno nella cartella predefinita Downloads.

Trascina

Una delle mie integrazioni di sistema preferite su computer è il trascinamento. In Excalidraw, quando inserisco un .excalidraw file nell'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 visualizzare una finestra di dialogo di salvataggio del file poiché l'handle del file richiesto è stato ottenuto dall'operazione di trascinamento.

Il secret per ottenere questo risultato è chiamare getAsFileSystemHandle() sull'elemento Data Transfer quando l'API File System Access è supportata. Poi passo questo handle file a loadFromBlob(), che potresti ricordare da un paio di paragrafi sopra. Puoi fare tantissime cose con i file: aprirli, salvarli, salvarli di nuovo, trascinarli, rilasciarli. Io e il mio collega Pete abbiamo documentato tutti questi e altri trucchi nel nostro articolo, così potete consultarvi nel caso in cui tutto questo sia andato un po' troppo velocemente.

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 su Android, ChromeOS e Windows è tramite l'API Web Share Target. Eccomi nell'app Files nella mia cartella Downloads. Vedo due file, uno dei quali con il nome non descrittivo untitled e un timestamp. Per controllare cosa contiene, faccio clic sui tre puntini, poi su Condividi e una delle opzioni visualizzate è Excalibur. Quando tocco l'icona, vedo di nuovo che il file contiene di nuovo il logo di I/O.

Lipis nella versione Electron deprecata

Con i file di cui non ho ancora parlato puoi eseguire un doubleclick. In genere, quando fai doppio clic su un file, si apre l'app associata al tipo MIME del file. Ad esempio per .docx si tratta di Microsoft Word.

Excalidraw aveva una versione Electron dell'app che supportava queste associazioni di tipo di file, quindi quando facevi doppio clic su un file .excalidraw, si apriva l'app Electron Excalidraw. Lipis, che avete già incontrato, è stata l'autore e il deprecatore di Excalidraw Electron. Gli ho chiesto perché riteneva possibile ritirare la versione Electron:

Fin dall'inizio, le persone chiedevano un'app Electron, soprattutto perché volevano aprire i file facendo doppio clic. Inoltre, avevamo intenzione di mettere l'app negli store. Parallelamente, qualcuno ha suggerito di creare una PWA, quindi abbiamo fatto entrambe le cose. Per fortuna abbiamo presentato le API di 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 su computer o dispositivo mobile, senza il peso aggiuntivo di Electron. È stata facile decidere di ritirare la versione di Electron, di concentrarsi solo sull'app web e di renderla la migliore PWA possibile. Inoltre, ora possiamo pubblicare le PWA sul Play Store e sul Microsoft Store. È fantastico.

Si potrebbe dire che Excalidraw for Electron non è stato deprecato perché Electron non è affatto cattivo, ma perché il web è diventato abbastanza buono. Mi piace!

Gestione dei file

Quando dico "il web è diventato abbastanza buono", è grazie a funzionalità come l'imminente Gestione dei file.

Questa è una normale installazione di macOS Big Sur. Ora guarda cosa succede quando faccio clic con il tasto destro del mouse su un file di Excalibur. Posso scegliere di aprirlo con Excalidraw, la PWA installata. Naturalmente anche il doppio clic funzionerebbe, ma è meno efficace da dimostrare in uno screencast.

Come funziona? Il primo passaggio consiste nel rendere noti al sistema operativo i tipi di file che la mia applicazione può gestire. Lo faccio in un nuovo campo chiamato file_handlers nel file manifest dell'app web. Il suo valore è un array di oggetti con un'azione e una proprietà accept. L'azione determina il percorso dell'URL in cui il sistema operativo avvia l'app e l'oggetto di accettazione 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 setConsumer(). Il parametro di questa funzione è una funzione asincrona che riceve l'elemento launchParams. Questo oggetto launchParams ha un campo denominato file che mi restituisce un array di handle 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 });
      });
    });
}

Anche in questo caso, se il processo è troppo veloce, puoi leggere ulteriori informazioni sull'API File handling nel mio articolo. Puoi attivare la gestione dei file impostando il flag delle funzionalità della piattaforma web sperimentale. È prevista la sua introduzione in Chrome entro la fine dell'anno.

Integrazione della clipboard

Un'altra funzionalità interessante di Excalidraw è l'integrazione negli appunti. Posso copiare tutto il disegno o solo alcune parti negli appunti, magari aggiungendo una filigrana, se necessario, e incollarlo in un'altra app. A proposito, questa è una versione web dell'app Windows 95 Paint.

Il funzionamento è sorprendentemente semplice. Tutto ciò di cui ho bisogno è il canvas sotto forma di blob, che poi inserisco negli appunti passando alla funzione navigator.clipboard.write() un array a un elemento con ClipboardItem con il blob. Per ulteriori informazioni su cosa puoi fare con l'API Appunti, vedi l'articolo di Giuseppe e il mio articolo.

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 un URL di sessione

Sapevi che Excalidraw ha anche una modalità collaborativa? Persone diverse possono collaborare sullo stesso documento. Per avviare una nuova sessione, faccio clic sul pulsante Collaborazione dal vivo e avvio una sessione. Posso condividere facilmente l'URL della sessione con i miei collaboratori grazie all'API Web Share integrata da Excalidraw.

Collaborazione in tempo reale

Ho simulato una sessione di collaborazione in locale lavorando sul logo Google I/O sul mio Pixelbook, sul mio smartphone Pixel 3a e sul mio iPad Pro. Puoi vedere che le modifiche che apporto su un dispositivo si riflettono su 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 quello 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 di inattività. Quando uso il cursore dell'iPad Pro, viene visualizzato un punto verde. Il punto diventa nero quando passo a un'altra scheda del browser o un'altra app. Quando sono nell'app Excalidraw, ma non faccio nulla, il cursore indica che non sono attivo, simboleggiato dalle tre zZZ.

I lettori assidui delle nostre pubblicazioni potrebbero essere inclini a pensare che il rilevamento di inattività venga realizzato tramite l'API Idle Detection, una proposta in fase iniziale su cui è stato lavorato nel contesto del progetto Fugu. Spoiler: non è così. Anche se avevamo un'implementazione basata su questa API in Excalidraw, alla fine abbiamo deciso di adottare un approccio più tradizionale basato sulla misurazione del movimento dei cursori e della visibilità delle pagine.

Screenshot del feedback relativo al rilevamento di inattività registrato nel repository WICG Idle Detection.

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

Lipis su ciò che frena Excalidraw

A proposito, ho chiesto a Lipis un'ultima domanda su cosa manca alla piattaforma web che frena Excalidraw:

L'API Accesso al file system è fantastica, ma sai cosa? La maggior parte dei file che mi interessa in questi giorni si trova nella mia cartella Dropbox o Google Drive, non sul disco rigido. Vorrei che l'API File System Access includesse un livello di astrazione per l'integrazione con i fornitori di file system remoti come Dropbox o Google e che gli sviluppatori potessero scrivere codice. In questo modo gli utenti possono rilassarsi e sapere che i loro file sono al sicuro con il cloud provider di cui si fidano.

Sono d'accordo con Lipis, anche io vivo nel cloud. Speriamo che questa funzionalità venga implementata a breve.

Modalità di applicazione a schede

Wow! Abbiamo visto molte integrazioni API davvero fantastiche in Excalidraw. File system, gestione di file, appunti, condivisione web e target della condivisione web. Ma ecco un'altra cosa: Finora potevo solo modificare un documento alla volta. Non più. Prova per la prima volta una versione preliminare della modalità di applicazione a schede in Excalidraw. Ecco come si presenta.

Ho già un file aperto nella PWA Excalidraw installata in esecuzione in modalità autonoma. Ora apro una nuova scheda nella finestra autonoma. Non si tratta di una normale scheda del browser, ma di 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à di applicazione a schede è ancora in fase di sviluppo e non è tutto definitivo. Se ti interessa, assicurati di leggere lo stato attuale di questa funzionalità nel mio articolo.

Chiusura

Per non perderti nessuna novità su questa e altre funzionalità, non perderti il nostro tracker dell'API Fugu. Siamo entusiasti di far progredire il web e di consentirti di fare di più sulla piattaforma. Un esempio di Excalidraw, in costante miglioramento, e di tutte le incredibili applicazioni che creerai. 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 e mi trovi come @tomayac su Twitter e su internet in generale. Grazie mille per l'attenzione e buona continuazione di Google I/O.