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. kontaktów z deweloperami w Google. W trakcie naszej prezentacji na konferencję Google I/O chcę Panu/Pani opowiedzieć o niektórych nowych interfejsach API Fugu i o tym, jak usprawniają one podstawowe doświadczenia użytkowników w aplikacji Excalidraw PWA. Może Pan/Pani czerpać inspirację z tych pomysłów i zastosować je we własnych aplikacjach.

Jak udało mi się przejść do Excalidraw

Chcę zacząć od opowieści. 1 stycznia 2020 r. Christopher Chedeau, inżynier oprogramowania w firmie Facebook, zamieścił tweeta o niewielkiej aplikacji do rysowania, nad którymi pracują. Dzięki niemu możesz rysować kreskówkowe pola i strzały, ręcznie rysowana. Następnego dnia mogłem też rysować elipsy i tekst, a także zaznaczać obiekty i poruszać się ich otoczenie. 3 stycznia aplikacja otrzymała nazwę Excalidraw. w ramach projektu, zakup nazwy domeny był jednym z pierwszych działań Krzysztofa. Według Teraz możesz użyć kolorów i wyeksportować 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 wymyślił post na blogu, który przyciągnął na Twitterze. 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

W przypadku projektu, który rozpoczął się zaledwie dwa tygodnie temu, nie jest to nic złego. Jednak prawdziwą rzeczą moje zainteresowanie rosło na dalszej części posta. Christopher pisał, że próbował czegoś nowego time: przyznanie wszystkim użytkownikom, którzy otrzymali żądanie pull, bezwarunkowego dostępu do zatwierdzenia. Tego samego dnia czytając post na blogu, doszła do mnie prośba o wyciągnięcie ręki. dodano obsługę interfejsu File System Access API do Excalidraw, naprawiono przesłanej przez kogoś prośby 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 chyba mówić, Nie nadużywałam swojej mocy. Nikt inny spośród 149 współtwórców również do tej pory.

Dziś Excalidraw to w pełni funkcjonalna, progresywna aplikacja internetowa, którą można zainstalować. obsługę offline, efektowny tryb ciemny, a tak, możliwość otwierania i zapisywania plików Interfejs File System Access API.

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

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

Na tym kończy się mój „Jak udało mi się dotrzeć do Excalidraw” ale zanim zajmę się czymś innym, Niezwykłe funkcje Excalidraw, miło mi przedstawić Panayiotis. Panayiotis Lipiridis, na internet (określany po prostu jako lipis) jest najbardziej efektywnym twórcą Excalidraw. Zapytałem lipisa, co motywuje go do poświęcania tak dużej ilości czasu na Excalidraw:

Tak jak wszyscy, dowiedziałam się o tym projekcie z tweeta Christophera. Moja pierwsza publikacja dodaję otwartą bibliotekę kolorów, czyli kolory, dostępny obecnie w Excalidraw. W miarę jak projekt się rozwijał i otrzymano coraz więcej próśb, moim kolejnym dużym wspieraliśmy tworzenie backendu do przechowywania rysunków, aby użytkownicy mogli je udostępniać. Ale co Zachęcam do wkładu w to, że kto korzysta z Excalidraw, szuka wymówki go powtórzyć.

W pełni zgadzam się z lipi. Każdy, kto próbował używać Excalidraw, szuka powodu, by ponownie go używać.

Excalidraw w akcji

Teraz pokażę Ci, jak w praktyce używać Excalidraw. Nie jestem wielkim artystą, ale Logo Google I/O jest wystarczająco proste, więc warto je wypróbować. Pole to litera „i”, a wiersz może być ukośnik i „o” to okrąg. Przytrzymuję wciśnięty klawisz shift, aby uzyskać idealną okrąg. Pozwól mi się ruszać przewężenie, dzięki czemu wygląda lepiej. Teraz trochę koloru dla „i” i „o”. Niebieski to dobrze. Może i zmienić styl wypełnienia? Wszystkie wymiary pełne, czy krzyżowe? Nie, czapka wygląda świetnie. Nie jest to idealne, Tak działa Excalidraw, więc zachowam go.

Klikam ikonę zapisywania i wprowadzam nazwę pliku w oknie dialogowym zapisywania. W przeglądarce Chrome, obsługuje interfejs File System Access API, nie jest to pobieranie, tylko prawdziwa operacja zapisu, wybierz lokalizację i nazwę pliku. Jeśli wprowadzam zmiany, mogę je po prostu zapisać w ten sam plik.

Chcę zmienić logo i dodać literę „i” czerwony. Jeśli teraz ponownie kliknę Zapisz, moja modyfikacja zostanie zapisana w taki sam jak poprzednio. W ramach dowodu chcę wyczyścić obszar roboczy i ponownie otworzyć plik. Jak widać, zmodyfikowane czerwono-niebieskie logo.

Praca z plikami

W przeglądarkach, które obecnie nie obsługują interfejsu File System Access API, każda operacja zapisu jest pobierania. Po wprowadzeniu zmian otrzymam wiele plików o rosnącej liczbie w która wypełnia folder Pobrane. Mimo to mogę zapisać plik.

Otwieranie plików

Na czym polega sekret? Jak otwierać i zapisywać pliki w różnych przeglądarkach, które mogą, ale nie muszą, obsługuje interfejs File System Access API? Otwarcie pliku w Excalidraw odbywa się w funkcji o nazwie loadFromJSON)(), która z kolei wywołuje funkcję 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 małej biblioteki o nazwie browser-fs-access używany w Excalidraw. Ta biblioteka zapewnia dostęp do systemu plików za pomocą File System Access API ze starszą wersją zastępczą, więc może być używana w dowolnym przeglądarki.

Najpierw pokażę Ci implementację, gdy interfejs API jest obsługiwany. Po wynegocjowaniu akceptowane typy MIME i rozszerzenia plików, najważniejszym elementem jest wywoływanie interfejsu File System Access API funkcja showOpenFilePicker(). Ta funkcja zwraca tablicę plików lub pojedynczy plik, w zależności od czy zaznaczyć wiele plików. Teraz wystarczy tylko dodać do niego uchwyt tak 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 kreacji zastępczej opiera się na elemencie input typu "file". Po wynegocjowaniu Akceptowane typy i rozszerzenia MIME, następny krok to programowe kliknięcie danych wejściowych tak by pojawiło się okno otwierania pliku. W przypadku zmiany, czyli gdy użytkownik wybierze jedną lub wiele plików, obietnica rozwiązana.

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

Czas na zapisywanie. W Excalidraw zapisywanie odbywa się w funkcji saveAsJSON(). To najpierw zserializuje tablicę elementów Excalidraw do formatu JSON, konwertuje plik JSON na obiekt blob, a następnie wywołuje metodę funkcja o nazwie fileSave(). Funkcja ta jest również dostępna przez 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 w przeglądarkach obsługujących interfejs File System Access API. Na początku kilka pierwszych wierszy wygląda na trochę skomplikowanych, ale wystarczy negocjować typy MIME rozszerzeń. Jeśli mam już zapisany plik i mam już uchwyt, nie trzeba otwierać okna zapisywania wyświetlane. Jeśli jednak jest to pierwszy zapis, wyświetla się okno dialogowe pliku, a aplikacja otrzymuje uchwyt. z powrotem do wykorzystania w przyszłości. Pozostała część to zapis w pliku, który odbywa się 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;
};

Przycisk „Zapisz jako” cecha

Jeśli zignoruję istniejący uchwyt pliku, mogę zaimplementować funkcję „zapisz jako” do utworzenia nowy plik na podstawie istniejącego pliku. Aby to pokazać, pozwól mi otworzyć istniejący plik, i nie można zastąpić istniejącego pliku, ale można utworzyć nowy plik za pomocą polecenia funkcji. Dzięki temu pierwotny plik pozostaje niezmieniony.

Implementacja w przeglądarkach, które nie obsługują interfejsu File System Access API, jest krótka, czyli utworzenie elementu kotwicy z atrybutem download, którego wartością jest żądana nazwa pliku i adresu URL obiektu blob jako wartości 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();
};

Następnie element kotwicy jest klikany automatycznie. Aby zapobiec wyciekom pamięci, adres URL obiektu blob wymaga zostaną unieważnione po użyciu. Ponieważ jest to plik do pobrania, nigdy nie wyświetla się okno zapisywania plików, trafiają do domyślnego folderu Downloads.

Przeciągnij i upuść

Jedną z moich ulubionych integracji systemowych na komputerach jest przeciąganie i upuszczanie. W Excalidraw po upuszczeniu .excalidraw. Plik zostanie otwarty od razu i będzie można rozpocząć edycję. W przeglądarkach które obsługują interfejs File System Access API, mogę wtedy nawet od razu zapisać zmiany. Nie musisz w oknie zapisywania pliku, ponieważ wymagany uchwyt został uzyskany w wyniku przeciągania i upuszczania pliku .

Aby tak się stało, musisz wywołać funkcję getAsFileSystemHandle() na transfer danych, jeśli obsługiwany jest interfejs File System Access API. Następnie przekazuję uchwytu do loadFromBlob(), który być może pamiętasz z kilku powyższych akapitów. Tak wiele Czynności, które można wykonać z plikami: otwieranie, zapisywanie, nadmierne zapisywanie, przeciąganie i upuszczanie. Mój kolega, Piotr Wszystkie te i inne sztuczki znajdziesz w naszym artykule, dzięki któremu Chcę nadrobić zaległości, bo to wszystko przebiegł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ą systemów z systemem Android, ChromeOS i Windows jest Interfejs API docelowego udziału w internecie. Jestem w aplikacji Files w moim folderze Downloads. Ja może zobaczyć dwa pliki, w tym 1 z nieopisową nazwą untitled i sygnaturą czasową. Aby sprawdzić, co klikam 3 kropki i udostępniam. Jedna z wyświetlonych opcji to 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. Co zwykle Gdy klikniesz dwukrotnie plik, oznacza to, że aplikacja powiązana z jego typem MIME zostanie otwarte. Na przykład w przypadku .docx będzie to Microsoft Word.

Firma Excalidraw miała wcześniej wersję Electron, która obsługuje takie powiązania, dlatego po dwukrotnym kliknięciu pliku .excalidraw Otworzyłaby się aplikacja Excalidraw Electron. Lipis, którą już znasz, została twórczynią oraz wycofanie Excalidraw Electron. Zapytałem go, dlaczego uważa, że można wycofać Wersja elektroniczna:

Użytkownicy od samego początku pytali o aplikację Electron, głównie dlatego, otwierać pliki, klikając je dwukrotnie. Planowaliśmy też udostępnić aplikację w sklepach z aplikacjami. Równolegle ktoś zaproponowaliśmy utworzenie PWA, więc zrobiliśmy obie. Na szczęście dowiedzieliśmy się o Projekcie Fugu, Interfejsy API takie jak dostęp do systemu plików, dostęp do schowka, obsługa plików i inne. Jednym kliknięciem możesz Zainstaluj ją na komputerze lub komórce, bez dodatkowej wagi urządzenia Electron. To było łatwe decyzji o wycofaniu wersji Electron, skupieniu się wyłącznie na aplikacji internetowej i uczynieniu z niej progresywnych aplikacji internetowych. Teraz możemy publikować aplikacje PWA w Sklepie Play, Sklep. To imponująca liczba!

Można powiedzieć, że rozwiązanie Excalidraw do Electron nie zostało wycofane ze względu na to, że Electron jest zły, ponieważ sieć jest wystarczająco dobra. Podoba mi się!

Obsługa plików

Kiedy powiem „internet jest wystarczająco dobry”, wynika to z funkcji takich jak nowa wersja Pliku Obsługa.

To jest zwykła instalacja systemu macOS Big Sur. Teraz zobacz, co się dzieje, gdy kliknę prawym przyciskiem myszy Plik Excalidraw. Mogę otworzyć go za pomocą zainstalowanej aplikacji PWA Excalidraw. Oczywiście. dwukrotne klikanie też zadziała, ale w screencaście będzie to mniej dramatyczne.

Jak to działa? Pierwszym krokiem jest określenie typów plików, które obsługuje moja aplikacja. oraz system operacyjny. Robię to w nowym polu o nazwie file_handlers w manifeście aplikacji internetowej. To jest tablicą obiektów z działaniem i właściwością accept. Czynność określa adres URL ścieżka, na której system operacyjny uruchamia Twoją aplikację, a obiektem „Akceptuję” są pary klucz-wartość MIME. typów plików i powiązanych z nimi rozszerzeń.

{
  "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. Dzieje się tak w launchQueue w którym trzeba określić konsumenta, dzwoniąc pod numer setConsumer(). Parametr argumentu jest funkcją asynchroniczną, która odbiera funkcję launchParams. Ten obiekt typu launchParams zawiera pole o nazwie pliki, w którym znajduje się tablica uchwytów plików do pracy. Interesuje mnie tylko z nowego uchwytu pliku, powstaje obiekt blob, który przesyłam poprzedniemu 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 przebiegnie zbyt szybko, możesz dowiedzieć się więcej o interfejsie File Handling API w artykule moj artykuł. Możesz włączyć obsługę plików, konfigurując eksperymentalną platformę internetową. flaga funkcji. Ma pojawić się w Chrome jeszcze w tym roku.

Integracja ze schowkiem

Inną ciekawą funkcją Excalidraw jest integracja ze schowek. Mogę skopiować cały rysunek lub do schowka, 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. Potrzebny mi tylko obiekt canvas w postaci bloba, który następnie piszę do schowka, przekazując do funkcji blob tablicę jednoelementową z obiektem ClipboardItem navigator.clipboard.write(). Więcej informacji o możliwościach schowka API, przeczytaj artykuły Jacka i mój artykuł.

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 ten sam dokument. Żeby rozpocząć nową sesję, kliknę przycisk współpracy na żywo i rozpocznę . Mogę łatwo udostępnić URL sesji współpracownikom dzięki Web Share API zintegrowany przez Excalidraw.

Współpraca na żywo

Stworzyłem lokalnie sesję współpracy nad logo Google I/O na Pixelbooku. mój telefon Pixel 3a i iPad Pro. Jak widać, zmiany wprowadzone na jednym urządzeniu są odzwierciedlane na na wszystkich innych urządzeniach.

Widzę nawet ruchy wszystkich kursorów. Kursor na Pixelbooku porusza się stabilnie, ponieważ za pomocą trackpada, ale kursor telefonu Pixel 3a i kursor na tablecie iPad Pro skaczą się dookoła. sterować tymi urządzeniami, dotykając palcem.

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

Aby usprawnić współpracę w czasie rzeczywistym, wprowadziliśmy nawet system wykrywania bezczynności. Kursor na iPadzie Pro pokazuje zieloną kropkę, gdy go używam. Po przełączeniu na w innej karcie lub w innej aplikacji przeglądarki. Gdy jestem w aplikacji Excalidraw, ale nic nie robię, kursor pokazuje mnie jako nieaktywnego, co symbolizuje trzy zZZ.

Regularni czytelnicy naszych publikacji mogą sądzić, że wykrywanie bezczynności jest realizowane przez Idle Detection API, czyli propozycję na wczesnym etapie, nad którą pracowaliśmy w kontekście Projektu Fugu. Uwaga spojler: nie. Chociaż mieliśmy wdrożenie oparte na tym interfejsie API, Ostatecznie zdecydowaliśmy się zastosować bardziej tradycyjne podejście, oparte na pomiarze poruszanie się wskaźnikiem i widoczność 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 który nie był możliwy do rozwiązania. Wszystkie interfejsy API Project Fugu są opracowywane w otwartej wersji, każdy może dołączyć do nas i usłyszeć swój głos.

Lipis o tym, co powstrzymuje Excalidraw

W związku z tym zadałam ostatnie pytanie. Jego pytanie dotyczyło tego, czego brakuje w internecie. która powstrzymuje Excalidraw:

Interfejs File System Access API jest świetny, ale wiesz co? Obecnie większość plików, na których mi zależy znajdziesz na moim koncie usługi Dropbox lub na Dysku Google, a nie na moim dysku twardym. Chcę, aby interfejs File System Access API nie uwzględnić warstwę abstrakcji w przypadku zdalnych dostawców systemów plików, takich jak Dropbox czy Google. i na które programiści mogą kodować. Dzięki temu użytkownicy mogą mieć pewność, że ich pliki są bezpieczne z zaufanym dostawcą chmury.

W pełni się z nimi zgadzam, ja też mieszkam w chmurze. Mamy nadzieję, że uda się to wdrożyć wkrótce.

Tryb aplikacji z kartami

Niesamowite! Widzimy, że w Excalidraw jest wiele świetnych integracji API. System plików, obsługa plików, schowek, udostępnianie w internecie oraz Cel udziału w internecie. Tu jednak jeszcze jedno. Do tej pory mogłem to robić tylko edytowania jednego dokumentu naraz. Już nie. Po raz pierwszy ciesz się wczesną wersją aplikacji trybu aplikacji z kartami w Excalidraw. Tak to wygląda.

Mam otwarty plik w zainstalowanej aplikacji PWA Excalidraw, która działa w trybie samodzielnym. Teraz Otwieram nową kartę w osobnym oknie. To nie jest zwykła karta przeglądarki, tylko karta PWA. W tym nowa karta Mogę otworzyć dodatkowy plik i pracować na nich niezależnie w tym samym oknie aplikacji.

Tryb aplikacji z kartami jest na wczesnym etapie i nie wszystko jest gotowe. Jeśli zapoznaj się z aktualnym stanem tej funkcji tutaj: moj artykuł.

Zakończenie

Aby być na bieżąco z tą i innymi funkcjami, obejrzyj Tag śledzenia interfejsu Fugu API. Cieszymy się, że możemy rozwijać sieć pozwala robić więcej na platformie. Stale ulepszamy Excalidraw. Tu wszystkich które stworzycie. Zacznij tworzyć od 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żecie znaleźć mnie pod adresem @tomayac na Twitterze i w internecie. Dziękuję za uwagę i życzę miłej lekcji z konferencji Google I/O.