Excalidraw i Fugu: ulepszanie podstawowych doświadczeń użytkownika

Każdą dostatecznie zaawansowaną technologię nie da się odróżnić od magii. Chyba że go rozumiesz. Nazywam się Thomas Steiner i pracuję w zespole ds. relacji z deweloperami w Google. W tym podsumowaniu mojego wystąpienia na konferencji Google I/O omówię niektóre nowe interfejsy API Fugu i sposób, w jaki poprawiają one najważniejsze ścieżki użytkownika w aplikacji PWA Excalidraw. Możesz się zainspirować tymi pomysłami i zastosować je w swoich aplikacjach.

Jak trafiłem do Excalidraw

Chcę zacząć od opowieści. 1 stycznia 2020 r. Christopher Chedeau, inżynier oprogramowania Facebooka, opublikował tweeta o niewielkiej aplikacji do rysowania, nad którą zaczął pracę. Dzięki temu narzędziu możesz rysować prostokąty i strzałki, które będą rysowane ręcznie. Następnego dnia możesz narysować elipsy i tekst, a także wybrać obiekty i je przenosić. 3 stycznia aplikacja otrzymała nazwę Excalidraw. Jak w przypadku każdego dobrego projektu pobocznego, jednym z pierwszych działań Christophera było kupienie nazwy domeny. Teraz możesz używać kolorów i eksportować cały rysunek jako plik PNG.

Zrzut ekranu przedstawiający prototyp aplikacji Excalidraw, który świadczy o obsłudze prostokątów, strzałek, elips i tekstów.

15 stycznia Christopher opublikował post na blogu, który przyciągnął na Twitterze wiele uwagi, w tym moją. Post zaczynał się od kilku imponujących statystyk:

  • 12 tys. unikalnych aktywnych użytkowników
  • 1,5 tys.gwiazdek w GitHubie
  • 26 współtwórców

To całkiem niezły wynik, biorąc pod uwagę, że projekt rozpoczął się zaledwie 2 tygodnie temu. Ale to, co naprawdę wzbudziło moje zainteresowanie, znajdowało się niżej w poście. Christopher stwierdził, że tym razem wypróbował coś nowego: zapewnienie wszystkim, którzy otrzymali prośbę o pullowanie, bezwarunkowego dostępu do zatwierdzenia. Tego samego dnia, w którym przeczytałam ten post, dotarła do mnie prośba pull o obsługę interfejsu File System Access API do Excalidraw, poprawiając prośbę o dodanie funkcji.

Zrzut ekranu tweeta, w którym ogłaszam swój PR.

Moje żądanie pull zostało scalone dzień później i od tego momentu mam pełny dostęp do zatwierdzenia. Nie trzeba dodawać, że nie nadużywam swojej mocy. Nikt inny spośród 149 współtwórców również do tej pory.

Obecnie Excalidraw to w pełni instalowana progresywna aplikacja internetowa z obsługą offline, efektownym trybem ciemnym oraz możliwością otwierania i zapisywania plików za pomocą interfejsu File System Access API.

Zrzut ekranu przedstawiający aktualną aplikację PWA Excalidraw.

Lipis wyjaśnia, dlaczego poświęca tyle czasu na Excalidraw

To koniec mojej historii o tym, jak dotarła do Excalidraw. Zanim jednak zagłębię się w niezwykłe funkcje Excalidraw, z przyjemnością przedstawię Panayiotis. Panayiotis Lipiridis, w internecie znany jako lipis, jest najbardziej aktywnym współtwórcą programu Excalidraw. Zapytaliśmy lipisa, co motywuje go do poświęcania tak dużo czasu na Excalidraw:

Tak jak wszyscy, dowiedziałam się o tym projekcie z tweeta Christophera. Moim pierwszym wkładem było dodanie biblioteki Open Color, czyli kolorów, które są nadal częścią Excalidraw. W miarę jak projekt się rozwijał i otrzymano coraz więcej żądań, moim kolejnym wielkim wkładem było utworzenie backendu do przechowywania rysunków, aby użytkownicy mogli je udostępniać. Ale to, co naprawdę mnie motywuje do wkładu, to fakt, że każdy, kto wypróbował Excalidraw, szuka wymówek, aby z niego korzystać ponownie.

Całkowicie się zgadzam z opinią lipis. Każdy, kto wypróbował Excalidraw, szuka wymówek, aby z niego znów skorzystać.

Excalidraw w akcji

Teraz pokażę Ci, jak w praktyce używać Excalidraw. Nie jestem wielkim wykonawcą, ale logo Google I/O jest wystarczająco proste, więc warto spróbować. Pole to „i”, linia to „/”, a „o” to koło. Przytrzymuję wciśnięty klawisz shift, aby uzyskać idealną okrąg. Chcę trochę przesunąć ukośnik, aby wyglądało lepiej. Teraz kolory dla „i” i „o”. Niebieski to dobrze. Może inny styl wypełnienia? Cały pełny czy kreskowany? Nie, buźka wygląda świetnie. Nie jest doskonały, ale tak działa Excalidraw, więc zachowam go.

Klikam ikonę zapisywania i wprowadzam nazwę pliku w oknie dialogowym zapisywania. W Chrome, przeglądarce obsługującej interfejs File System Access API, nie jest to pobieranie, ale prawdziwa operacja zapisywania, w której mogę wybrać lokalizację i nazwę pliku, a jeśli wprowadzę zmiany, mogę je zapisać w tym samym pliku.

Zmień logo i ustaw czerwoną literę „i”. Jeśli teraz kliknę Zapisz ponownie, moje zmiany zostaną zapisane w tym samym pliku co poprzednio. W ramach dowodu chcę wyczyścić obszar roboczy i ponownie otworzyć plik. Jak widać, zmodyfikowane czerwono-niebieskie logo jest znów widoczne.

Praca z plikami

W przeglądarkach, które obecnie nie obsługują interfejsu API dostępu do systemu plików, każda operacja zapisu jest pobieraniem, więc po wprowadzeniu zmian mam wiele plików z rosnącą liczbą w nazwie, które wypełniają mój folder Pobrane. Mimo to mogę zapisać plik.

Otwieranie plików

Jaki jest sekret? W jaki sposób otwieranie i zapisywanie może działać w różnych przeglądarkach, które mogą lub nie obsługują interfejsu File System Access API? Otwieranie pliku w Excalidraw odbywa się w ramach funkcji o nazwie loadFromJSON)(, która z kolei wywołuje funkcję o nazwie 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);
};

Funkcja fileOpen() pochodzi z małej biblioteki o nazwie browser-fs-access, której używamy w Excalidraw. Ta biblioteka zapewnia dostęp do systemu plików za pomocą interfejsu File System Access API z użyciem starszego rozwiązania, dzięki czemu można jej używać w dowolnej przeglądarce.

Najpierw pokażę Ci implementację, gdy interfejs API jest obsługiwany. Po wynegocjowaniu akceptowanych typów MIME i rozszerzeń plików główny element wywołuje funkcję showOpenFilePicker() File System Access API. Ta funkcja zwraca tablicę plików lub pojedynczy plik w zależności od tego, czy wybrano wiele plików. Teraz wystarczy tylko umieścić uchwyt pliku w obiekcie pliku, aby można go było ponownie pobrać.

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

Implementacja kreacji zastępczej opiera się na elemencie input typu "file". Po wynegocjowaniu akceptowanych typów i rozszerzeń MIME należy kliknąć element wejściowy automatycznie, aby otworzyło się okno dialogowe pliku. W przypadku zmiany to znaczy, że gdy użytkownik wybierze co najmniej 1 plik, obietnica wygaśnie.

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

Zapisuję pliki

Teraz o zapisywaniu. W Excalidraw zapisywanie odbywa się w funkcji o nazwie saveAsJSON(). Najpierw serializuje tablicę elementów Excalidraw do formatu JSON, a następnie konwertuje ją do pliku blob. Następnie wywołuje funkcję fileSave(). Tę funkcję zapewnia biblioteka 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 };
};

Najpierw przyjrzyjmy się implementacji w przeglądarkach z obsługą interfejsu File System Access API. Pierwsze kilka linii może wyglądać na skomplikowane, ale w istocie służy tylko do negocjowania typów MIME i rozszerzeń plików. Gdy plik został już zapisany i mam do niego uchwyt, nie wyświetlaj okna dialogowego zapisu. Jeśli jednak jest to pierwszy zapis, wyświetla się okno pliku, a aplikacja otrzymuje uchwyt z powrotem do użycia w przyszłości. Pozostała część to zapis w pliku, który odbywa się przez strumień z możliwością zapisu.

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

Funkcja „Zapisz jako”

Jeśli zdecyduję się zignorować istniejący już uchwyt pliku, mogę użyć funkcji „Zapisz jako”, aby utworzyć nowy plik na podstawie istniejącego. Aby to pokazać, otwórz istniejący plik, wprowadź w nim pewne zmiany, a potem nie zastąp go istniejącym plikiem, lecz utwórz nowy plik, korzystając z funkcji Zapisz jako. Dzięki temu pierwotny plik pozostaje niezmieniony.

Implementacja dla przeglądarek, które nie obsługują interfejsu API dostępu do systemu plików, jest krótka, ponieważ polega tylko na utworzeniu elementu kotwicy z atrybutem download, którego wartość to żądana nazwa pliku, oraz z atrybutem href zawierającym URL bloba.

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

Element zakotwiczony jest wtedy klikany automatycznie. Aby zapobiec wyciekom pamięci, po użyciu adres URL pliku blob należy cofnąć. Ponieważ jest to tylko pobieranie, nie wyświetla się okno zapisywania plików, a wszystkie pliki trafiają do domyślnego folderu Downloads.

Przeciągnij i upuść

Jedną z moich ulubionych integracji systemowych na komputerach jest przeciąganie i upuszczanie. Gdy w aplikacji Excalidraw umieszczę plik .excalidraw, otwiera się on od razu i można zacząć edycję. W przeglądarkach, które obsługują interfejs File System Access API, mogę nawet od razu zapisać zmiany. Nie trzeba otwierać okna zapisywania pliku, ponieważ wymagany identyfikator pliku został uzyskany z operacji przeciągania i upuszczania.

Aby to zrobić, wywołaj funkcję getAsFileSystemHandle() elementu transfer danych, gdy interfejs File System Access API jest obsługiwany. Przekazuję teraz ten nick pliku do loadFromBlob(), który być może pamiętasz z kilku akapitów powyżej. W plikach jest tyle rzeczy: otwieranie, zapisywanie, nadmierne zapisywanie, przeciąganie i upuszczanie. Mój kolega z Piotru i ja udokumentowaliśmy wszystkie te i inne sztuczki w naszym artykule, dzięki czemu możesz się z nimi zapoznać, jeśli wszystko poszło za szybko.

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

Udostępnianie plików

Inną integracją systemu, która jest obecnie dostępna na urządzeniach z Androidem, ChromeOS i systemem Windows, jest interfejs Web Share Target API. Tutaj jestem w aplikacji Pliki w folderze Downloads. Widzę dwa pliki, z których jeden ma nieopisową nazwę untitled i sygnaturę czasową. Aby sprawdzić, co zawiera, klikam 3 kropki, a potem udostępniam. Jedną z opcji, która się wyświetla, jest Excalidraw. Po dotknięciu ikony plik ponownie zawiera logo I/O.

Lipis w wycofanej wersji Electron

Z plikami, o których jeszcze nie mówiliśmy, możesz zrobić dwukrotne kliknięcie pliku. Gdy klikniesz plik dwukrotnie, otwiera się aplikacja powiązana z typem MIME pliku. Na przykład w przypadku .docx będzie to Microsoft Word.

Excalidraw wcześniej miało wersję Electron, która obsługiwała takie skojarzenia typów plików, więc po dwukrotnym kliknięciu pliku .excalidraw otwierała się aplikacja Electron Excalidraw. Lipis, którą już znasz, jest zarówno twórcą, jak i wycofającym Excalidraw Electron. Spytałem, dlaczego uważa, że można wycofać wersję Electron:

Użytkownicy pytali o aplikację Electron od samego początku, głównie dlatego, że chcieli otwierać pliki, klikając dwukrotnie. Chcieliśmy też umieścić aplikację w sklepach z aplikacjami. Ktoś zasugerował też stworzenie aplikacji internetowej, więc zrobiliśmy obie te rzeczy. Na szczęście poznaliśmy interfejsy API Project Fugu, takie jak dostęp do systemu plików, dostęp do schowka, obsługa plików i inne. Wystarczy jedno kliknięcie, aby zainstalować aplikację na komputerze lub urządzeniu mobilnym bez dodatkowego obciążenia Electron. Łatwo było wycofać wersję Electron i skupić się tylko na aplikacji internetowej, aby była jak najlepszą PWA. Co więcej, możesz teraz publikować Progressive Web Apps w Sklepie Play i Microsoft Store. To świetna wiadomość.

Można powiedzieć, że oprogramowanie Excalidraw do firmy Electron nie zostało wycofane, bo technologia Electron jest zła. Podoba mi się!

Obsługa plików

Kiedy mówię „internet stał się wystarczająco dobry”, wynika to z takich funkcji jak nadchodząca obsługa plików.

To jest zwykła instalacja systemu macOS Big Sur. Sprawdź, co się dzieje, gdy klikam prawym przyciskiem myszy plik programu Excalidraw. Mogę otworzyć go w Excalidraw, czyli zainstalowanej aplikacji PWA. Oczywiście dwukrotne kliknięcie też zadziała, ale w screencaście będzie to mniej dramatyczne.

Jak to działa? Pierwszym krokiem jest poinformowanie systemu operacyjnego o typach plików, które może obsługiwać aplikacja. Robię to w nowym polu o nazwie file_handlers w manifeście aplikacji internetowej. Jego wartością jest tablica obiektów z działaniem i właściwością accept. To działanie określa ścieżkę adresu URL, pod którym system operacyjny uruchamia aplikację, a obiektem akceptowania są pary klucz-wartość typów MIME i powiązane rozszerzenia plików.

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

Następnym krokiem jest obsługa pliku po uruchomieniu aplikacji. Występuje to w interfejsie launchQueue, w którym muszę ustawić konsumenta, wywołując funkcję setConsumer(). Parametr tej funkcji to funkcja asynchroniczna, która przyjmuje parametr launchParams. Obiekt launchParams ma pole o nazwie files, które zawiera tablicę uchwytów plików. Interesuje mnie tylko ten pierwszy, a z jego uchwytu pliku otrzymuję bloba, który przekazuję naszemu staremu znajomemu 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 });
      });
    });
}

Jeśli to było zbyt szybkie, więcej informacji o interfejsie API do obsługi plików znajdziesz w tym artykule. Obsługę plików możesz włączyć, ustawiając flagę funkcji eksperymentalnej platformy internetowej. Ta funkcja zostanie dodana do Chrome jeszcze w tym roku.

Integracja ze schowkiem

Inną fajną funkcją Excalidraw jest integracja ze schowkiem. Mogę skopiować do schowka cały rysunek lub tylko jego fragmenty, a w razie potrzeby dodać znak wodny i wkleić go do innej aplikacji. Przy okazji – jest to internetowa wersja aplikacji Windows 95 Paint.

Sposób działania jest zaskakująco prosty. Potrzebuję tylko kanwy jako bloba, który następnie zapisuję w schowku, przekazując jednoelementowy tablica z wartością ClipboardItem i blobem do funkcji navigator.clipboard.write(). Więcej informacji o tym, co można zrobić za pomocą interfejsu schowka API, znajdziesz w materiałach Jacka i mojego artykułu.

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

Współpraca z innymi

Udostępnianie adresu URL sesji

Czy wiesz, że Excalidraw też ma tryb współpracy? Różne osoby mogą wspólnie pracować nad tym samym dokumentem. Aby rozpocząć nową sesję, kliknę przycisk współpracy na żywo i rozpocznij sesję. Mogę łatwo udostępnić URL sesji współpracownikom dzięki zintegrowanemu interfejsowi Web Share API.

Współpraca na żywo

Pracuję nad logo Google I/O na Pixelbooku, telefonie Pixel 3a i iPadzie Pro. Możesz zobaczyć, że zmiany wprowadzone na jednym urządzeniu są odzwierciedlane na wszystkich innych urządzeniach.

Widzę nawet ruchy wszystkich kursorów. Kursor na Pixelbooku porusza się stabilnie, ponieważ steruje go trackpadem, ale kursor telefonu Pixel 3a i kursor na tablecie iPad Pro skaczą się, bo steruję urządzeniami, dotykając palcem.

Wyświetlanie stanów współpracowników

Aby usprawnić współpracę w czasie rzeczywistym, uruchomiliśmy nawet system wykrywania bezczynności. Kursor na iPadzie Pro pokazuje zieloną kropkę, gdy go używam. Gdy przechodzę na inną kartę lub do innej aplikacji, kropka zmienia kolor na czarny. Gdy korzystam z aplikacji Excalidraw, lecz nic nie robię, kursor pokazuje, że jestem bezczynny, co symbolizuje 3 ZZZ.

Regularni czytelnicy naszych publikacji mogą sądzić, że wykrywanie bezczynności jest realizowane za pomocą interfejsu Idle Detection API – propozycja na wczesnym etapie, nad którą pracowaliśmy w kontekście projektu Fugu. Uwaga, spoiler: nie jest. W Excalidraw mieliśmy implementację opartą na tym interfejsie API, ale ostatecznie zdecydowaliśmy się na bardziej tradycyjne podejście polegające na pomiarze ruchu wskaźnika i widoczności strony.

Zrzut ekranu pokazujący opinię dotyczącą wykrywania bezczynności przesłaną w repozytorium WICG Idle Detection.

Przesłaliśmy opinię na temat tego, dlaczego interfejs Idle Detection API nie pomagał nam w rozwiązaniu naszego przypadku użycia. Wszystkie interfejsy API Project Fugu są opracowywane na zasadach otwartych, więc każdy może dołączyć do dyskusji i usłyszeć swój głos.

Lipis o tym, co powstrzymuje Excalidraw

W związku z tym zadałam mu ostatnie pytanie na temat tego, czego brakuje na platformie internetowej, która powstrzymuje Excalidraw:

Interfejs File System Access API jest świetny, ale wiesz co? Większość plików, które są dla mnie ważne, znajduje się w moim Dropboxie lub na Dysku Google, a nie na dysku twardym. Chciałbym, aby interfejs File System Access API zawierał warstwę abstrakcji dla dostawców zdalnych systemów plików, takich jak Dropbox czy Google, z którą integracją byłby interfejs, z którym programiści mogliby kodować. Dzięki temu użytkownicy mogą mieć pewność, że ich pliki są bezpieczne u dostawcy chmury, do którego mają zaufanie.

Całkowicie się zgadzam z lipis, bo ja też mieszkam w chmurze. Mam nadzieję, że to zostanie wdrożone w najbliższym czasie.

Tryb aplikacji z kartami

Niesamowite! Zauważyliśmy wiele naprawdę świetnych integracji interfejsu API w Excalidraw. System plików, obsługa plików, Schowek, udostępnianie w interneciecel udostępniania w internecie. Ale mam jeszcze jedną rzecz. Do tej pory mogłem edytować tylko jeden dokument naraz. Już nie. Po raz pierwszy możesz skorzystać z wczesnej wersji trybu aplikacji z kartami w Excalidraw. Wygląda to tak.

Mam otwarty plik w zainstalowanej aplikacji PWA Excalidraw, która działa w trybie samodzielnym. Otwieram nową kartę w osobnym oknie. To nie jest zwykła karta przeglądarki, ale karta PWA. Na nowej karcie mogę otworzyć dodatkowy plik i pracować nad nim niezależnie w tym samym oknie aplikacji.

Tryb aplikacji z kartami jest we wczesnej fazie rozwoju i nie wszystko jest jeszcze ustalone. Jeśli chcesz dowiedzieć się więcej, przeczytaj mój artykuł, w którym znajdziesz aktualny stan tej funkcji.

Zakończenie

Aby być na bieżąco z tą i innymi funkcjami, obejrzyj nasz moduł do śledzenia interfejsu Fugu API. Cieszymy się, że możemy rozwijać internet i umożliwiać Ci jeszcze więcej możliwości na platformie. Wierzymy w udoskonalanie Excalidraw, a także o wszystkich niesamowitych aplikacjach, które utworzysz. Zacznij tworzyć na excalidraw.com.

Nie mogę się doczekać, aż niektóre z przedstawionych dzisiaj interfejsów API pojawią się w Twoich aplikacjach. Mam na imię Tom. Możesz mnie znaleźć pod nazwą @tomayac na Twitterze i w internecie. Dziękuję za uwagę i życzę miłej lekcji na konferencji Google I/O.