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

Każda wystarczająco zaawansowana technologia jest nie do odróżnienia od magii. Chyba że rozumiesz to. 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 Fugu API 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

Zacznijmy od historii. 1 stycznia 2020 r. Christopher Chedeau, inżynier oprogramowania w firmie Facebook, wysłał tweeta o małej aplikacji do rysowania, nad którą zaczął pracować. Za pomocą tego narzędzia możesz rysować pola i strzałki, które wyglądają jak rysunki ręczne. Następnego dnia możesz narysować elipsy i tekst, a także wybrać obiekty i je przenosić. 3 stycznia aplikacja otrzymała nazwę Excalidraw. Podobnie 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 prototypu aplikacji Excalidraw pokazujący, że obsługuje ona prostokąty, strzałki, elipsy i tekst.

15 stycznia Christopher opublikował post na blogu, który przyciągnął na Twitterze wiele uwagi, w tym moją. Post zaczyna się od podania 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 napisał, że tym razem wypróbował coś nowego: udostępnił wszystkim, którzy przesłali prośbę o przechwycenie, bezwarunkowy dostęp do zatwierdzania. Tego samego dnia, w którym przeczytałem wpis na blogu, wysłałem prośbę o przejęcie kodu źródłowego, która dodała do Excalidraw obsługę interfejsu File System Access API, rozwiązując prośbę o dodanie funkcji, którą ktoś wcześniej przesłał.

Zrzut ekranu z twittem, w którym ogłaszam moje PR.

Mój request pull został scalony dzień później i od tego czasu miałem pełny dostęp do commitów. Nie muszę chyba mówić, że nie nadużywam swoich uprawnień. Do tej pory nikt z 149 współtwórców nie zgłosił też żadnych problemów.

Obecnie Excalidraw to pełna, instalowana progresywna aplikacja internetowa z obsługą trybu offline, wspaniałym trybem ciemnym i możliwością otwierania i zapisywania plików dzięki interfejsowi File System Access API.

Zrzut ekranu z obecną wersją PWA Excalidraw.

Lipis o tym, dlaczego poświęca tyle czasu na Excalidraw

To już koniec historii o tym, jak zaczęłam używać Excalidraw, ale zanim zaprezentuję niektóre z jego niesamowitych funkcji, chciałabym przedstawić Panayiotisa. 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:

Podobnie jak wszyscy inni dowiedziałem się o tym projekcie z twaszego tweeta. Moim pierwszym wkładem było dodanie biblioteki Open Color, czyli kolorów, które są nadal częścią Excalidraw. Gdy projekt się rozwijał i mieliśmy sporo próśb, moim kolejnym dużym wkładem było stworzenie 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 działaniu

Teraz pokażę Ci, jak możesz używać Excalidraw w praktyce. Nie jestem wybitnym artystą, ale logo Google I/O jest dość proste, więc spróbuję. Pole to „i”, linia to „/”, a „o” to koło. Przytrzymaj Shift, aby uzyskać idealny okrąg. Przesuń trochę ukośnik, aby wyglądał lepiej. Teraz kolory dla „i” i „o”. Niebieski to dobry znak. Może inny styl wypełnienia? Cały pełny czy kreskowany? Nie, hachure wygląda świetnie. Nie jest to idealne, ale taka jest idea Excalidraw, więc zapiszmy.

Klikam ikonę zapisywania i w oknie zapisywania pliku wpisuję nazwę pliku. 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 celu potwierdzenia czystego płótna otwórz ponownie plik. Jak widać, zmodyfikowane logo w kolorach czerwonym i niebieskim 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 otrzymuję 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? Jak otwieranie i zapisywanie może działać w różnych przeglądarkach, które mogą lub nie obsługiwać interfejsu API dostępu do systemu plików? 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 negocjowaniu akceptowanych typów MIME i rozszerzeń plików centralnym elementem jest wywołanie funkcji interfejsu File System Access API showOpenFilePicker(). Ta funkcja zwraca tablicę plików lub pojedynczy plik w zależności od tego, czy wybrano wiele plików. Teraz wystarczy umieścić uchwyt pliku w obiekcie pliku, aby można go było pobrać ponownie.

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 zastępcza opiera się na elemencie input typu "file". Po negocjowaniu akceptowanych typów i rozszerzeń MIME należy kliknąć element wejściowy za pomocą kodu, aby otworzyć okno otwierania pliku. Po zmianie, czyli gdy użytkownik wybierze jeden lub więcej plików, obietnica zostanie spełniona.

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

Zapisywanie plików

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(). Ta funkcja jest również udostępniana przez bibliotekę 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 };
};

Ponownie zacznijmy od przeglądarek 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 zapisywania. Jeśli jednak jest to pierwszy zapis, wyświetli się okno pliku, a aplikacja otrzyma informacje o pliku na przyszłość. Reszta to tylko zapisywanie w pliku, co odbywa się za pomocą strumenienia do 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ępuj istniejącego pliku, lecz utwórz nowy, korzystając z funkcji Zapisz jako. Oryginalny 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, którego wartość to 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();
};

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

Przeciągnij i upuść

Jednym z moich ulubionych systemów integracji na komputerze jest przeciąganie i upuszczanie. W Excalidraw, gdy przeciągnę plik .excalidraw do aplikacji, otwiera się on od razu i mogę zacząć go edytować. W przeglądarkach, które obsługują interfejs File System Access API, można 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. Następnie przekazuję ten identyfikator pliku do funkcji loadFromBlob(), o której wspominałem kilka akapitów wyżej. Z plikami można robić wiele rzeczy: otwierać, zapisywać, zapisywać ponownie, przeciągać i upuszczać. Mój współpracownik Pete opisał te i inne triki w tym artykule, abyś mógł nadrobić zaległości, jeśli wszystko poszło zbyt 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 Androidzie, ChromeOS i Windows, jest interfejs Web Share Target API. Tutaj jestem w aplikacji Pliki w folderze Downloads. Widzę 2 pliki. Jeden z nich ma niejasną 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. Gdy klikam ikonę, widzę, że plik zawiera tylko logo I/O.

Lipis w wycofanej wersji Electron

Jedną z rzeczy, które możesz zrobić z plikami, o których jeszcze nie mówiłem, jest ich podwójne kliknięcie. 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órego już znasz, był twórcą i osobą, która wycofała Excalidraw Electron. Spytałem, dlaczego jego zdaniem można wycofać wersję Electron:

Od samego początku użytkownicy prosili o aplikację Electron, głównie dlatego, że chcieli otwierać pliki przez dwukrotne kliknięcie. Zamierzaliś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 projektu Fugu, takie jak dostęp do systemu plików, dostęp do schowka czy obsługa plików. Wystarczy jedno kliknięcie, aby zainstalować aplikację na komputerze lub urządzeniu mobilnym bez dodatkowego obciążenia Electronem. Ł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 Excalidraw for Electron nie zostało wycofane, ponieważ Electron jest zły, ale dlatego, że internet stał się wystarczająco dobry. Podoba mi się

Obsługa plików

Gdy mówię, że „internet stał się wystarczająco dobry”, mam na myśli funkcje takie jak nadchodzące Przetwarzanie plików.

Jest to zwykła instalacja systemu macOS Big Sur. Zobacz, co się dzieje, gdy klikam prawym przyciskiem myszy plik programu Excalidraw. Mogę otworzyć go w zainstalowanej aplikacji PWA Excalidraw. Oczywiście można też kliknąć dwukrotnie, ale nie jest to tak spektakularne w ramach screencastu.

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 pliku manifestu aplikacji internetowej. Jego wartość to tablica obiektów z działaniem i właściwością accept. Działanie określa ścieżkę URL, pod którą system operacyjny uruchamia aplikację, a obiekt accept to pary klucz-wartość typów MIME i powiązanych rozszerzeń 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"]
      }
    }
  ]
}

Kolejnym krokiem jest przetworzenie 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ć cały rysunek lub tylko jego fragmenty do schowka, a na przykład dodać znak wodny, a potem wkleić go do innej aplikacji. To internetowa wersja aplikacji Paint z Windowsa 95.

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 możliwościach korzystania z interfejsu API schowka znajdziesz w artykule Jasona i moim artykule.

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 ma też tryb współpracy? Różne osoby mogą pracować nad tym samym dokumentem. Aby rozpocząć nową sesję, klikam przycisk współpracy na żywo, a następnie rozpoczynam sesję. Dzięki zintegrowanemu w Excalidraw interfejsowi Web Share API mogę łatwo udostępniać adres URL sesji współpracownikom.

Współpraca na żywo

Symulowaliśmy sesję współpracy lokalnie, pracując nad logo Google I/O na Pixelbooku, telefonie Pixel 3a i tablecie iPad Pro. Widzisz, że zmiany wprowadzone na jednym urządzeniu są odzwierciedlane na wszystkich innych urządzeniach.

Widzę nawet, jak poruszają się wszystkie kursory. Kursor na Pixelbooku porusza się płynnie, ponieważ jest sterowany za pomocą trackpada, ale kursor na telefonie Pixel 3a i na tablecie iPad Pro przeskakuje, ponieważ steruję tymi urządzeniami, dotykając ich palcem.

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

Aby usprawnić współpracę w czasie rzeczywistym, uruchomiliśmy nawet system wykrywania bezczynności. Podczas korzystania z iPada Pro kursor pokazuje zieloną kropkę. Gdy przełączam się na inną kartę przeglądarki lub aplikację, kropka zmienia kolor na czarny. Gdy jestem w aplikacji Excalidraw, ale nic nie robię, kursor pokazuje, że jestem nieaktywny – symbolizują to 3 z z z.

Zagorzali czytelnicy naszych publikacji mogą sądzić, że wykrywanie bezczynności jest realizowane za pomocą interfejsu ‎Idle Detection API, który jest propozycją na wczesnym etapie rozwoju, nad którą pracujemy w ramach 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 z opinią dotyczącą wykrywania bezczynności przesłaną w repozytorium WICG.

Przesłaliśmy opinię na temat tego, dlaczego interfejs API do wykrywania bezczynności nie spełniał naszego przypadku użycia. Wszystkie interfejsy API projektu Fugu są rozwijane w ogólnodostępny sposób, więc każdy może się wypowiedzieć i wyrazić swoją opinię.

Lipis o tym, co powstrzymuje Excalidraw

W związku z tym zapytałem go jeszcze o to, czego jego zdaniem brakuje w tej platformie internetowej, co hamuje rozwój 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ą można się integrować i którą programiści mogą wykorzystać do pisania kodu. Użytkownicy mogliby mieć pewność, że ich pliki są bezpieczne u zaufanego dostawcy usług w chmurze.

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 można było edytować tylko 1 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. Teraz otwieram nową kartę w oknie samodzielnym. 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, obserwuj nasz tracker interfejsu Fugu API. Cieszymy się, że możemy rozwijać internet i umożliwiać Ci korzystanie z większej liczby funkcji na tej platformie. Oto stale ulepszane narzędzie Excalidraw i wszystkie wspaniałe aplikacje, które dzięki niemu stworzysz. Zacznij tworzyć na stronie excalidraw.com.

Nie mogę się doczekać, aż niektóre z przedstawionych dzisiaj interfejsów API pojawią się w Twoich aplikacjach. Mam na imię Tom. Na Twitterze i w internecie możesz mnie znaleźć pod nazwą @tomayac. Dziękujemy za obejrzenie filmu. Do zobaczenia na Google I/O.