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

Nie da się odróżnić żadnej wystarczająco zaawansowanej technologii od magii. Chyba że go rozumiesz. Nazywam się Thomas Steiner. Pracuję w zespole ds. relacji z deweloperami w Google. W tym dokumencie z Google I/O przyjrzę się niektórym nowym interfejsom API Fugu i zobaczę, jak usprawniają one podstawowe ścieżki użytkowników w aplikacji Excalidraw PWA. Dzięki temu możesz czerpać inspiracje z tych pomysłów i stosować je w swoich aplikacjach.

Jak trafiłem do Excalidraw

Chcę zacząć od historii. 1 stycznia 2020 r. Christopher Chedeau, programista z Facebooka, napisał na Twitterze o niewielkiej aplikacji do rysowania, nad którą zaczął pracować. Za jego pomocą możesz rysować prostokąty i strzałki. Następnego dnia można było też rysować wielokropki i tekst, a także zaznaczać obiekty i je przesuwać. 3 stycznia aplikacja otrzymała nazwę Excalidraw. Tak jak w przypadku każdego dobrego projektu pobocznego zakup nazwy domeny był jednym z pierwszych działań Christophera. Do tej pory można było używać kolorów i eksportować cały rysunek jako plik PNG.

Zrzut ekranu przedstawiający prototypową aplikację Excalidraw z widocznym prostokątem, strzałkami, elipsą i tekstem.

15 stycznia Christopher opublikował na Twitterze posta, który przyciągnął uwagę użytkowników – także mojego. Post zaczyna się od imponujących statystyk:

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

Jeśli chodzi o projekt, który rozpoczął się zaledwie 2 tygodnie temu, to nie jest źle. Ale to, co naprawdę mnie zainteresowało, to w dalszej części posta. Krzysztof pisze, że tym razem spróbował czegoś nowego: zapewnił każdemu, kto otrzymał prośbę typu pull bezwarunkowego dostępu do zatwierdzania. W tym samym dniu, w którym przeczytałem post na blogu, dotarło do mnie żądanie pobrania, które dodało obsługę interfejsu File System Access API do Excalidraw i poprawiało przesłane przez kogoś żądanie funkcji.

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

Dzień później moje żądanie pull zostało scalone i od tego momentu miałem pełny dostęp do zatwierdzania. Nie trzeba dodawać, że nie nadużywałam swojej mocy. Do tej pory ani żaden inny spośród 149 uczestników.

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

Zrzut ekranu z dzisiejszej aplikacji PWA Excalidraw.

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

To już koniec mojej historii o tym, jak dotarłam do Excalidraw, ale zanim przejdziemy do niesamowitych funkcji Excalidraw, z przyjemnością przedstawię Panayiotis. Panayiotis Lipiridis, czyli w internecie lipis, najczęściej przyczynia się do powstania bakterii Excalidraw. Zapytałam Lipis, co go motywuje, żeby poświęcać tyle czasu dla Excalidraw:

Tak jak wszyscy, dowiedziałem się o tym projekcie z tweeta Christophera. Pierwszą rzeczą, którą dodałem, było dodanie otwartej biblioteki kolorów – kolorów, które są dziś wciąż dostępne w Excalidraw. Wraz z rozwojem projektu i otrzymywaniu dużej liczby próśb wziąłem się za telefon, więc stworzyłem backend do przechowywania rysunków, by użytkownicy mogli je udostępniać. Ale motywuje mnie to, że osoba, która wypróbowała Excalidraw, szuka wymówek, żeby znów z niej korzystać.

Całkowicie się zgadzam z lipisami. Każdy, kto używał Excalidraw, szuka wymówek, żeby znów z niego korzystać.

Excalidraw w akcji

Teraz pokażę Ci, jak korzystać z Excalidraw w praktyce. Nie jestem wielkim artystą, ale logo Google I/O jest dość proste, więc wypróbuję. Pole to „i”, linia może być ukośnikiem, a „o” to okrąg. Przytrzymuję Shift, aby utworzyć idealny okrąg. Przesunę trochę ukośnik, by wyglądało lepiej. Teraz trochę kolorów dla liter „i” i „o”. Niebieski jest dobry. Może inny styl wypełnienia? Wszystkie stałe czy krzyżowe? Nie, hachure wygląda świetnie. Nie jest idealna, ale taka jest koncepcja Excalidraw, więc chcę ją zapisać.

Klikam ikonę zapisywania i wprowadzam nazwę pliku w oknie dialogowym zapisywania. W Chrome, której obsługuje interfejs File System Access API, nie jest to pobieranie, ale prawdziwa operacja zapisu, która pozwala wybrać lokalizację i nazwę pliku oraz gdzie po wprowadzeniu zmian zapisać je w tym samym pliku.

Zmienię logo i zmienię logo na czerwone. Jeśli ponownie kliknę „Zapisz”, zmiany zostaną zapisane w tym samym pliku co wcześniej. Na dowód odznaczę obszar roboczy i otwórz plik ponownie. Jak widać, zmodyfikowane czerwono-niebieskie logo znów się tam znajduje.

Praca z plikami

W przeglądarkach, które obecnie nie obsługują interfejsu File System Access API, każda operacja zapisu to pobieranie. Gdy wprowadzam zmiany, w nazwie pliku pojawia się wiele plików o rosnącej liczbie, które wypełniają folder Pobrane pliki. Pomimo tego problemu mogę zapisać plik.

Otwieranie plików

Na czym polega tajemnica? Jak otwieranie i zapisywanie działa w różnych przeglądarkach, które mogą, ale nie muszą, obsługiwać interfejs File System Access API? Otwieranie pliku w Excalidraw odbywa się w funkcji 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() pochodząca z napisanej przeze mnie biblioteki o nazwie browser-fs-access, której używamy w Excalidraw. Ta biblioteka zapewnia dostęp do systemu plików przez interfejs File System Access API ze starszą wersją kreacji zastępczej, dzięki czemu można jej używać w dowolnej przeglądarce.

Za chwilę przedstawię implementację, gdy ten interfejs API jest obsługiwany. Po wynegocjowaniu akceptowanych typów MIME i rozszerzeń plików centralnym elementem jest funkcja showOpenFilePicker() interfejsu File System Access API. Ta funkcja zwraca tablicę plików lub pojedynczy plik w zależności od tego, czy wybierzesz wiele plików. Teraz wystarczy tylko umieścić w obiekcie uchwyt pliku, aby można było ponownie pobrać plik.

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 wykorzystuje element input typu "file". Po wynegocjowaniu akceptowanych typów i rozszerzeń MIME należy automatycznie kliknąć element wejściowy, aby wyświetlić okno otwierania pliku. W przypadku zmiany, czyli gdy użytkownik wybierze co najmniej 1 plik, obietnica ustępuje.

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

Przejdźmy do zapisywania. W Excalidraw zapisywanie odbywa się w funkcji saveAsJSON(). Najpierw zestawia tablicę elementów Excalidraw do formatu JSON, konwertuje obiekt JSON na obiekt blob, a następnie wywołuje funkcję o nazwie fileSave(). Funkcja ta 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 };
};

Jeszcze raz przyjrzyjmy się implementacji przeglądarek z obsługą interfejsu File System Access API. Pierwsze kilka wierszy wydaje się nieco zajęte, ale ich zadaniem jest negocjowanie typów MIME i rozszerzeń plików. Jeśli zapisałem już wcześniej i mam już uchwyt pliku, nie trzeba wyświetlać okna zapisywania. Jeśli jednak jest to pierwsze zapisywanie, wyświetli się okno dialogowe pliku, a aplikacja odzyska uchwyt do wykorzystania w przyszłości. Cała reszta jest po prostu zapisywana do pliku, co odbywa się za pomocą strumienia 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 uchwyt pliku, mogę zaimplementować funkcję „zapisz jako”, aby utworzyć nowy plik na podstawie istniejącego pliku. Aby to pokazać, otwórz istniejący plik, wprowadź zmiany i nie zastępuj istniejącego pliku. Zamiast tego utwórz nowy plik przy użyciu funkcji „Zapisz jako”. Oryginalny plik pozostanie nienaruszony.

Implementacja przeglądarek, które nie obsługują interfejsu File System Access API, jest krótka, ponieważ tylko tworzy element kotwicy z atrybutem download, którego wartością jest pożądana nazwa pliku oraz adres URL obiektu blob jako wartość atrybutu 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();
};

Zakotwiczony element jest następnie klikany automatycznie. Aby zapobiec wyciekom pamięci, adres URL obiektu blob musi zostać unieważniony po użyciu. Jest to tylko pobieranie, więc żadne okno zapisywania plików nie jest wyświetlane, a wszystkie pliki trafiają do domyślnego folderu Downloads.

Przeciągnij i upuść

Jedną z moich ulubionych integracji systemowych na komputerze jest przeciąganie i upuszczanie. W Excalidraw po upuszczeniu w aplikacji pliku .excalidraw od razu się on otwiera i można zacząć go edytować. W przeglądarkach, które obsługują interfejs File System Access API, mogę nawet natychmiast zapisać zmiany. Nie musisz przechodzić przez okno dialogowe zapisywania, ponieważ wymagany uchwyt pliku został uzyskany podczas operacji przeciągania i upuszczania.

Aby to umożliwić, musisz wywołać getAsFileSystemHandle() w elemencie transferu danych, gdy interfejs File System Access API jest obsługiwany. Przekażę ten nick do loadFromBlob(), który być może pamiętasz z kilku akapitów powyżej. Z plikami można korzystać na wiele sposobów: otwieranie, zapisywanie, zapisywanie, zapisywanie, przeciąganie. Razem z Peterem udokumentowaliśmy wszystkie te i inne sztuczki w naszym artykule, dzięki czemu możesz być na bieżąco, ponieważ wszystko pójdzie 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

Kolejna integracja systemu, która jest obecnie używana na Androidzie, ChromeOS i Windows, odbywa się przy użyciu interfejsu Web Share Target API. Oto, w aplikacji Files jestem w folderze Downloads. Widzę 2 pliki. Jeden z nich ma nieopisową nazwę untitled i sygnaturę czasową. Aby sprawdzić, co zawiera, klikam 3 kropki i udostępniam. Jedna z wyświetlonych opcji to Excalidraw. Po kliknięciu tej ikony widzę, że plik zawiera tylko logo I/O.

Lipis w wycofanej wersji Electron

Jedną z rzeczy, które można zrobić z plikami, o których jeszcze nie wspomnieliśmy, jest dodanie ich do doubleclick. Gdy klikasz plik w doubleclick, zwykle dzieje się tak, że otworzy się aplikacja powiązana z typem MIME tego pliku. Na przykład w przypadku .docx będzie to Microsoft Word.

Aplikacja Excalidraw używała wersji Electron aplikacji, która obsługuje powiązania tego typu plików. Gdy klikniesz dwukrotnie plik .excalidraw, otworzyła się aplikacja Excalidraw Electron. Lipis, którą już znasz, była twórcą i wycofającym Excalidraw Electron. Zapytałem go, dlaczego uważa, że może wycofać wersję elektroniczną:

Użytkownicy od samego początku prosili o aplikację Electron, głównie po to, żeby otwierać pliki dwukrotnym kliknięciem. Planowaliśmy też udostępnić aplikację w sklepach z aplikacjami. Ktoś jednak zaproponował utworzenie PWA. Na szczęście zaprezentowaliśmy interfejsy Project Fugu API, takie jak dostęp do systemu plików, dostęp do schowka czy obsługa plików. Aby zainstalować aplikację na komputerze lub urządzeniu mobilnym, wystarczy jedno kliknięcie. Wycofanie wersji Electron, skupienie się na aplikacji internetowej i stworzenie najlepszej aplikacji PWA było bardzo łatwe. Teraz możemy też publikować aplikacje PWA zarówno w Sklepie Play, jak i w Microsoft Store. To niesamowite!

Można powiedzieć, że nie wycofano platformy Excalidraw dla Electron, ponieważ Electron jest zły i wcale nie, ale sieć stała się już wystarczająco dobra. Podoba mi się!

Obsługa plików

Gdy mówię, że „internet stał się już wystarczająco dobry”, oznacza to, że chodzi o nowe funkcje, takie jak wprowadzenie obsługi plików.

To jest zwykła instalacja Big Sur w systemie macOS. Zobaczmy teraz, co się dzieje, gdy kliknę plik Excalidraw prawym przyciskiem myszy. Mogę otworzyć go w zainstalowanej aplikacji PWA Excalidraw. Oczywiście dwukrotne klikanie też się sprawdzi, ale niezbyt dramatyczne jest przedstawienie tego w screencastu.

Jak to działa? Najpierw rozpoznaj w systemie operacyjnym typy plików, które może obsłużyć moja aplikacja. Mogę to zrobić 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órą system operacyjny uruchamia Twoją aplikację, a obiekt akceptacji to 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ługę pliku po uruchomieniu aplikacji. Odbywa się to w interfejsie launchQueue, gdzie muszę skonfigurować klienta, wywołując metodę setConsumer(). Parametr tej funkcji to funkcja asynchroniczna, która odbiera launchParams. Ten obiekt launchParams ma pole o nazwie „Pliki”, dzięki któremu mam do dyspozycji tablicę uchwytów do pracy z plikami. Interesuje mnie tylko pierwszy z nich, a z nicka tego pliku pojawia się obiekt blob, który następnie przekazuję naszemu 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 przebiega to zbyt szybko, więcej o interfejsie File handling API znajdziesz w moim artykule. Możesz włączyć obsługę plików, ustawiając eksperymentalną flagę funkcji platformy internetowej. Planujemy udostępnić ją w Chrome jeszcze w tym roku.

Integracja ze schowkiem

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

Sposób działania jest zaskakująco prosty. Potrzebuję tylko obiektu canvas jako obiektu blob, który następnie zapisuję w schowku, przekazując do funkcji navigator.clipboard.write() jednoelementową tablicę z obiektem ClipboardItem z blobem. Więcej informacji o tym, co można zrobić za pomocą interfejsu schowka, znajdziesz w artykule Jasona i w 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 osobami

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 potem rozpoczynam sesję. Mogę z łatwością udostępniać adres URL sesji moim współpracownikom dzięki zintegrowanemu interfejsowi Web Share API, które zintegrowałem przez Excalidraw.

Współpraca na żywo

Udało mi się zasymulować sesję współpracy lokalnie, używając logo Google I/O na Pixelbooku, telefonie Pixel 3a i iPadzie Pro. Jak widać, zmiany wprowadzone na jednym urządzeniu są odzwierciedlane na pozostałych.

Widać nawet, że wszystkie kursory się poruszają. Kursor Pixelbooka porusza się płynnie, ponieważ jest sterowany za pomocą trackpada, ale kursor w telefonie Pixel 3a i tablecie iPad Pro przesuwa się, ponieważ steruję nimi, 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 iPada Pro pokazuje podczas używania zieloną kropkę. Gdy przechodzę na inną kartę lub aplikację przeglądarki, kropka zmienia kolor na czarny. A gdy jestem w aplikacji Excalidraw, ale nic nie robię, kursor pokazuje mnie jako nieaktywnego, co symbolizuje 3 zZZ.

Regularni czytelnicy naszych publikacji mogą sądzić, że wykrywanie bezczynności jest realizowane za pomocą interfejsu Idle Detection API – pakietu na wczesnym etapie pracy, nad którym pracowaliśmy w kontekście projektu Fugu. Uwaga spojler: to nie to. Chociaż w pełni korzystaliśmy z tego interfejsu API w Excalidraw, zdecydowaliśmy się jednak wybrać bardziej tradycyjne podejście oparte na pomiarze ruchu wskaźników i widoczności strony.

Zrzut ekranu z opinią o wykrywaniu bezczynności przesłaną w repozytorium WICG wykrywania bezczynności.

Przesłaliśmy opinię o tym, dlaczego interfejs Idle Detection API nie rozwiązał naszego przypadku użycia. Wszystkie interfejsy API Project Fugu są opracowywane w wersji otwartej, aby każdy mógł dołączyć do dyskusji i wysłuchać jej opinii.

Lipis na tym, co powstrzymuje Excalidraw

Zadałem ostatnie pytanie dotyczące tego, czego jego zdaniem brakuje na platformie internetowej, która ogranicza Excalidraw:

Interfejs File System Access API to świetna sprawa, ale wiesz co? Większość ważnych dla mnie plików znajduje się w usłudze Dropbox lub na Dysku Google, a nie na dysku twardym. Chciałbym, aby interfejs File System Access API zawierał warstwę abstrakcji, z którą dostawcy zdalnych systemów plików, tacy jak Dropbox czy Google, mogliby zintegrować swoją aplikację i która umożliwiała programistom kodowanie. Dzięki temu użytkownicy mogą się odprężyć i mieć pewność, że ich pliki są bezpieczne u zaufanego dostawcy chmury.

W pełni się zgadzam z lipis, też mieszkam w chmurze. Mamy nadzieję, że wkrótce zostanie to wprowadzone.

Tryb aplikacji z kartami

Niesamowite! W środowisku Excalidraw obserwujemy wiele znakomitych integracji interfejsów API. System plików, obsługa plików, schowek, udostępnianie w internecie i cel udostępniania w internecie. Jest jednak jeszcze jedna rzecz. Do tej pory mogłem(-am) edytować tylko jeden dokument naraz. Już nie. Korzystaj po raz pierwszy ze wczesnej wersji trybu aplikacji z kartami w Excalidraw. Tak to wygląda.

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

Tryb aplikacji z kartami jest we wczesnej fazie rozwoju i nie wszystko jest gotowe. Jeśli Cię to interesuje, przeczytaj artykuł o aktualnym stanie tej funkcji.

Zakończenie

Aby być na bieżąco z tą i innymi funkcjami, obejrzyj nasz narzędzie do śledzenia interfejsu Fugu API. Cieszymy się, że możemy dalej rozwijać sieć i dać Ci dostęp do nowych możliwości na platformie. Zaraz udoskonalamy Excalidraw. A oto wszystkie niesamowite aplikacje, które uda Ci się stworzyć. Zacznij tworzyć na stronie excalidraw.com.

Nie mogę się doczekać, aż niektóre z interfejsów API, które dziś przedstawiłam, pojawią się w Twoich aplikacjach. Mam na imię Tom. Moja nazwa to @tomayac. Jej nazwa jest dostępna na Twitterze i w innych miejscach w internecie. Dziękuję za uwagę i życzę udanego udziału w konferencji Google I/O.