Excalidraw e Fugu: migliorare i percorsi dell'utente principali

Qualsiasi tecnologia sufficientemente avanzata è indistinguibile dalla magia. A meno che tu non lo 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 ho creato Excalidraw

Voglio iniziare con una storia. Il 1° gennaio 2020, Christopher Chedeau, un ingegnere del software di Facebook, ha twittato su 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 ricevuto il suo nome, Excalidraw, e, come per ogni buon progetto secondario, l'acquisto del nome di dominio è stato uno dei primi atti 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 stimolato il mio interesse era più avanti 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 pubblicato una pull request che aggiungeva il supporto dell'API File System Access a Excalidraw, correggendo una richiesta di funzionalità che qualcuno aveva inviato.

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. Né nessun altro dei 149 collaboratori finora.

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

Questo è il finale della mia storia "come ho creato Excalidraw", ma prima di approfondire alcune delle fantastiche funzionalità di Excalidraw, ho il piacere di presentarti Panayiotis. Panayiotis Lipiridis, su internet conosciuto semplicemente come lipis, è il collaboratore più prolifico di Excalibur. Ho chiesto a Lipis cosa lo motiva a dedicare così tanto tempo a Excalidraw:

Come tutti gli altri, ho saputo di questo progetto dal tweet di Christopher. Il mio primo contributo è stato aggiungere la raccolta di colori aperti, i colori che fanno ancora parte di Excalidraw. Con la crescita del progetto e l'aumento delle richieste, il mio contributo successivo è stato 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 Lipis. Chiunque abbia provato Excalidraw cerca scuse per usarlo di nuovo.

Excalidraw in azione

Ora voglio mostrarti come utilizzare Excalidraw nella pratica. Non sono un grande artista, ma il logo di Google I/O è abbastanza semplice, quindi fammi fare un tentativo. Una casella è la "i", una linea può essere la slash 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 è buono. Forse uno stile di riempimento diverso? Tutto a tinta unita o tratteggio incrociato? No, l'ombreggiatura è perfetta. Non è perfetto, ma è l'idea di Excalidraw, quindi fammi salvarlo.

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, se apporto modifiche, posso 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, consentimi di cancellare la tela e riaprire il file. Come puoi vedere, il logo rosso-blu modificato è di nuovo presente.

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 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 browser diversi che supportano 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 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. Non 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 programmaticamente sull'elemento di input in modo che venga visualizzata la finestra di dialogo di apertura del file. Al cambio, 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

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

Ancora una volta, esamino prima l'implementazione per i browser con supporto dell'API File System Access. Le prime due righe sembrano un po' complicate, ma non fanno altro che negoziare i tipi MIME e le estensioni dei file. Se ho già salvato e ho già un handle file, non è necessario visualizzare 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 nuovamente un handle del file per un uso 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 come"

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 invariato.

L'implementazione per i browser che non supportano l'API File System Access è breve, in quanto non fa altro che creare un elemento anchor con un attributo download il cui valore è il nome del file desiderato e un URL del blob come valore dell'attributo download.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 viene mai visualizzata una finestra di dialogo di salvataggio dei file e tutti i file vengono inseriti 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 segreto per farlo è chiamare getAsFileSystemHandle() sull'elemento trasferimento dati quando l'API Accesso al file system è 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 trucchi e altro ancora nel nostro articolo, per consentirti di recuperare il tempo perso se tutto ciò è 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 è tramite l'API Web Share Target. Sono nell\'app File nella 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 che il file contiene 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 su di essi. 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 hai già incontrato, è stato sia il creator sia chi ha ritirato 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. Fortunatamente, abbiamo avuto modo di conoscere le API di Project Fugu come l'accesso al file system, all'apposita area di memoria, alla 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. È stata una decisione facile ritirare la versione Electron, concentrarci solo sull'app web e renderla la PWA migliore possibile. Inoltre, ora possiamo pubblicare le PWA sul Play Store e sul Microsoft Store. È fantastico.

Si potrebbe dire che Excalidraw per Electron non è stato ritirato perché Electron è un cattivo prodotto, ma non è così, ma perché il web è diventato abbastanza buono. Mi piace.

Gestione dei file

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

Si tratta di 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 comunicare 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 da cui il sistema operativo avvia l'app e l'oggetto accept è costituito da coppie chiave-valore di tipi MIME e dalle estensioni 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 nel gestire il file al momento dell'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 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 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 la spiegazione è stata troppo veloce, puoi scoprire di più sull'API File Handling nel mio articolo. Puoi attivare la gestione dei file impostando il flag delle funzionalità sperimentali della piattaforma web. È prevista la sua introduzione in Chrome entro la fine dell'anno.

Integrazione della clipboard

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

Il funzionamento è sorprendentemente semplice. Mi serve solo la tela come blob, che poi scrivo nella clipboard 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 un URL di sessione

Sapevi che Excalidraw ha anche una modalità di collaborazione? Più persone possono lavorare contemporaneamente 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 in tempo reale

Ho simulato una sessione di collaborazione localmente lavorando al logo di 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 che si muovono. 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 dell'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ì. Sebbene in Excalidraw fosse presente un'implementazione basata su questa API, alla fine abbiamo deciso di optare per un approccio più tradizionale basato sulla misurazione del movimento del cursore e della visibilità della pagina.

Screenshot del feedback di Idle Detection inviato nel repository di Idle Detection di WICG.

Abbiamo inviato un feedback sul motivo per cui l'API Idle Detection non risolveva il caso d'uso che avevamo. 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 cosa sta frenando 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 interessano al momento si trova su Dropbox o Google Drive, non sul mio hard disk. 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. Gli utenti possono quindi stare tranquilli, sapendo che i loro file sono al sicuro con il provider cloud 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 dei file, apposita area, condivisione web e destinazione della condivisione web. Ma c'è un'altra cosa. Fino ad ora, potevo modificare un solo 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 un file esistente aperto nella PWA Excalidraw installata che è 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 aprire un file secondario e lavorarci in modo indipendente dalla stessa finestra dell'app.

La modalità di 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 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. Ecco a te un Excalidraw in continua evoluzione e a tutte le fantastiche 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. Puoi trovarmi come @tomayac su Twitter e su internet in generale. Grazie mille per l'attenzione e buona continuazione di Google I/O.