Każda wystarczająco zaawansowana technologia jest nieodróżnialna od magii. chyba że rozumiesz, Nazywam się Thomas Steiner i pracuję w Google w zespole ds. relacji z deweloperami. W tym artykule, który jest podsumowaniem mojego wystąpienia na konferencji Google I/O, omówię niektóre nowe interfejsy API Fugu i sposoby, w jakie ulepszają one podstawowe ścieżki użytkowników w progresywnej aplikacji internetowej Excalidraw. Możesz zainspirować się tymi pomysłami i zastosować je w swoich aplikacjach.
Jak trafiłem na Excalidraw
Chcę zacząć od historii. 1 stycznia 2020 r. Christopher Chedeau, inżynier oprogramowania w Facebooku, napisał na Twitterze o małej aplikacji do rysowania, nad którą zaczął pracować. Za pomocą tego narzędzia możesz rysować ramki i strzałki, które wyglądają jak z kreskówki i są narysowane ręcznie. Następnego dnia możesz też rysować elipsy i tekst, a także zaznaczać obiekty i przesuwać je. 3 stycznia aplikacja otrzymała nazwę Excalidraw, a jednym z pierwszych działań Christophera było kupienie nazwy domeny, co jest typowe dla każdego dobrego projektu pobocznego. Możesz już używać kolorów i wyeksportować cały rysunek jako plik PNG.
15 stycznia Christopher opublikował posta na blogu, który przyciągnął dużą uwagę na Twitterze, w tym moją. 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
Jak na projekt, który wystartował zaledwie 2 tygodnie temu, to całkiem niezły wynik. Jednak to, co naprawdę wzbudziło moje zainteresowanie, znajdowało się w dalszej części posta. Christopher napisał, że tym razem spróbował czegoś nowego: przyznał wszystkim, którzy przesłali prośbę o scalenie, bezwarunkowy dostęp do zatwierdzania zmian. Tego samego dnia, w którym przeczytałem ten post na blogu, wysłałem prośbę o scalenie, która dodawała do Excalidraw obsługę interfejsu File System Access API, rozwiązując zgłoszoną przez kogoś prośbę o dodanie funkcji.
Moja prośba o scalenie została scalona dzień później i od tego momentu miałem pełny dostęp do zatwierdzania zmian. Nie muszę chyba dodawać, że nie nadużyłem swojej władzy. Nie zrobił tego też nikt z pozostałych 149 osób, które do tej pory przesłały swoje materiały.
Obecnie Excalidraw to w pełni funkcjonalna, instalowana progresywna aplikacja internetowa z obsługą offline, trybem ciemnym i możliwością otwierania i zapisywania plików dzięki interfejsowi File System Access API.
Lipis o tym, dlaczego poświęca tak dużo czasu na Excalidraw
To koniec mojej historii o tym, jak trafiłem na Excalidraw. Zanim jednak przejdę do niektórych niesamowitych funkcji Excalidraw, mam przyjemność przedstawić Panayiotisa. Panayiotis Lipiridis, w internecie znany po prostu jako lipis, jest najbardziej aktywnym współtwórcą Excalidraw. Zapytałem lipisa, co motywuje go do poświęcania tak dużej ilości czasu na Excalidraw:
Podobnie jak wszyscy inni, dowiedziałem się o tym projekcie z tweeta Christophera. Moim pierwszym wkładem było dodanie biblioteki Open Color, której kolory są nadal częścią Excalidraw. Wraz z rozwojem projektu i wzrostem liczby próśb o pomoc moim kolejnym dużym wkładem było stworzenie backendu do przechowywania rysunków, aby użytkownicy mogli je udostępniać. Ale to, co mnie naprawdę motywuje do działania, to fakt, że każdy, kto wypróbował Excalidraw, szuka pretekstów, aby użyć go ponownie.
Całkowicie zgadzam się z lipis. Każdy, kto wypróbował Excalidraw, szuka pretekstu, aby użyć go ponownie.
Excalidraw w praktyce
Chcę Ci teraz pokazać, jak możesz korzystać z Excalidraw w praktyce. Nie jestem świetnym artystą, ale logo Google I/O jest wystarczająco proste, więc spróbuję. Kwadrat to „i”, linia to ukośnik, a „o” to okrąg. Przytrzymuję Shift, aby uzyskać idealny okrąg. Przesunę ukośnik, żeby wyglądał lepiej. Teraz dodajmy kolor do litery „i” i „o”. Niebieski to dobry znak. Może inny styl wypełnienia? Całe czy w kratkę? Nie, kreskowanie wygląda świetnie. Nie jest idealny, ale taki jest zamysł Excalidraw, więc go zapiszę.
Klikam ikonę zapisywania i wpisuję nazwę pliku w oknie dialogowym zapisywania 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 po prostu zapisać w tym samym pliku.
Zmienimy logo i sprawimy, że litera „i” będzie czerwona. Jeśli teraz ponownie kliknę „Zapisz”, moja modyfikacja zostanie zapisana w tym samym pliku co wcześniej. Aby to udowodnić, wyczyszczę obszar roboczy i ponownie otworzę plik. Jak widać, zmodyfikowane logo w kolorach czerwonym i niebieskim znowu się pojawiło.
Praca z plikami
W przypadku przeglądarek, które obecnie nie obsługują interfejsu File System Access API, każda operacja zapisywania jest pobieraniem, więc gdy wprowadzam zmiany, otrzymuję wiele plików z numerem w nazwie, które zapełniają folder Pobrane. Mimo tego minusa 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ą obsługiwać interfejs File System Access API lub nie? Otwieranie 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()
pochodzi z małej biblioteki o nazwie browser-fs-access, którą napisałem i której używamy w Excalidraw. Ta biblioteka zapewnia dostęp do systemu plików za pomocą interfejsu File System Access API z starszą wersją rezerwową, dzięki czemu można jej używać w dowolnej przeglądarce.
Najpierw pokażę Ci implementację w przypadku obsługi interfejsu API. Po uzgodnieniu akceptowanych typów MIME i rozszerzeń plików najważniejsze jest wywołanie funkcji showOpenFilePicker()
interfejsu File System Access API. Ta funkcja zwraca tablicę plików lub pojedynczy plik w zależności od tego, czy wybrano wiele plików. Pozostało 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 rezerwowa opiera się na elemencie input
typu "file"
. Po negocjacji akceptowanych typów MIME i rozszerzeń kolejnym krokiem jest programowe kliknięcie elementu wejściowego, aby wyświetlić okno otwierania pliku. Obietnica jest realizowana po zmianie, czyli gdy użytkownik wybierze co najmniej 1 plik.
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 przejdźmy do zapisywania. W Excalidraw zapisywanie odbywa się w funkcji o nazwie saveAsJSON()
. Najpierw serializuje tablicę elementów Excalidraw do formatu JSON, konwertuje JSON na obiekt blob, a następnie wywołuje funkcję o nazwie fileSave()
. Ta funkcja jest również dostępna w bibliotece 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 przypadku przeglądarek obsługujących interfejs File System Access API. Pierwsze kilka wierszy wygląda na skomplikowane, ale ich jedynym zadaniem jest negocjowanie typów MIME i rozszerzeń plików. Jeśli zapisywanie zostało już wcześniej wykonane i mam już uchwyt pliku, nie muszę wyświetlać okna zapisu. Jeśli jednak zapisujesz plik po raz pierwszy, wyświetli się okno dialogowe pliku, a aplikacja otrzyma uchwyt pliku, którego będzie mogła używać w przyszłości. Reszta to po prostu zapisywanie w pliku, które odbywa się za pomocą strumienia 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ę wdrożyć funkcję „zapisz jako”, aby utworzyć nowy plik na podstawie istniejącego. Aby to pokazać, otworzę istniejący plik, wprowadzę w nim zmiany, a następnie nie zastąpię istniejącego pliku, ale utworzę nowy, korzystając z funkcji zapisywania jako. Oryginalny plik pozostanie niezmieniony.
Implementacja w przypadku przeglądarek, które nie obsługują interfejsu File System Access API, jest krótka, ponieważ tworzy tylko element kotwicy z atrybutem download
, którego wartością jest żądana nazwa pliku, oraz URL-em 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();
};
Element zakotwiczony zostanie następnie kliknięty programowo. Aby zapobiec wyciekom pamięci, po użyciu adres URL obiektu blob musi zostać cofnięty. Ponieważ jest to tylko pobieranie, nigdy nie wyświetla się okno dialogowe zapisywania pliku, 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. Gdy w Excalidraw upuszczę plik w aplikacji, od razu się on otworzy i będę mogła zacząć go edytować..excalidraw
W przeglądarkach, które obsługują interfejs File System Access API, mogę od razu zapisać zmiany. Nie musisz przechodzić przez okno dialogowe zapisywania pliku, ponieważ wymagany uchwyt pliku został uzyskany w wyniku operacji przeciągania i upuszczania.
Aby to zrobić, wywołaj getAsFileSystemHandle()
w przypadku elementu przesyłania danych, gdy interfejs File System Access API jest obsługiwany. Następnie przekazuję ten uchwyt pliku do funkcji loadFromBlob()
, którą być może pamiętasz z kilku akapitów powyżej. Z plikami można robić wiele rzeczy: otwierać je, zapisywać, nadpisywać, przeciągać i upuszczać. Wszystkie te triki i inne wskazówki opisaliśmy z Petem w naszym artykule, więc możesz się z nim zapoznać, jeśli wszystko to było dla Ciebie zbyt szybkie.
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 systemowa, która jest obecnie dostępna na Androidzie, ChromeOS i Windowsie, odbywa się za pomocą interfejsu Web Share Target API. Jestem w aplikacji Pliki w folderze Downloads
. Widzę 2 pliki, z których jeden ma nieokreśloną nazwę untitled
i sygnaturę czasową. Aby sprawdzić, co zawiera, klikam 3 kropki, a następnie udostępniam. Jedną z opcji, która się pojawia, jest Excalidraw. Po kliknięciu ikony widzę, że plik zawiera tylko logo I/O.
Lipis w wycofanej wersji Electrona
Jedną z rzeczy, które możesz zrobić z plikami, o której jeszcze nie wspomniałem, jest dwukrotne kliknięcie. Gdy dwukrotnie klikniesz plik, zwykle otworzy się aplikacja powiązana z typem MIME pliku. Na przykład w przypadku .docx
będzie to Microsoft Word.
Excalidraw miał wersję aplikacji Electron, która obsługiwała takie skojarzenia typów plików, więc gdy klikniesz dwukrotnie plik .excalidraw
, otworzy się aplikacja Excalidraw Electron. Lipis, którego już znasz, był zarówno twórcą, jak i osobą, która wycofała Excalidraw Electron. Zapytałem go, dlaczego uważa, że można wycofać wersję Electrona:
Użytkownicy od początku prosili o aplikację Electron, głównie dlatego, że chcieli otwierać pliki przez dwukrotne kliknięcie. Planowaliśmy też umieścić aplikację w sklepach z aplikacjami. Równolegle ktoś zasugerował utworzenie aplikacji PWA, 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. Jednym kliknięciem możesz zainstalować aplikację na komputerze lub urządzeniu mobilnym bez dodatkowego obciążenia związanego z Electronem. Decyzja o wycofaniu wersji Electrona i skupieniu się na aplikacji internetowej oraz przekształceniu jej w najlepszą możliwą aplikację PWA była łatwa. Dodatkowo możemy teraz publikować PWA w Sklepie Play i Microsoft Store. To ogromna liczba.
Można powiedzieć, że Excalidraw dla Electrona nie został wycofany z powodu tego, że Electron jest zły – wcale nie – ale dlatego, że sieć stała się wystarczająco dobra. Podoba mi się to!
Obsługa plików
Gdy mówię, że „sieć stała się wystarczająco dobra”, mam na myśli funkcje takie jak nadchodząca funkcja obsługi plików.
Jest to standardowa instalacja systemu macOS Big Sur. Teraz zobacz, co się stanie, gdy kliknę prawym przyciskiem myszy plik Excalidraw. Mogę go otworzyć w Excalidraw, zainstalowanej aplikacji PWA. Oczywiście możesz też kliknąć dwukrotnie, ale w nagraniu ekranu nie wygląda to tak efektownie.
Jak to działa? Pierwszym krokiem jest poinformowanie systemu operacyjnego o typach plików, które może obsługiwać moja aplikacja. Robię to w nowym polu o nazwie file_handlers
w pliku manifestu aplikacji internetowej. Jego wartością jest tablica obiektów z działaniem i właściwością accept
. Działanie określa ścieżkę adresu URL, pod którą system operacyjny uruchamia aplikację, a obiekt accept to pary klucz-wartość typów MIME i powiązanych z nimi 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"]
}
}
]
}
Następnym krokiem jest obsługa pliku po uruchomieniu aplikacji. Dzieje się to w launchQueue
interfejsie, w którym muszę ustawić konsumenta, wywołując funkcję setConsumer()
. Parametrem tej funkcji jest funkcja asynchroniczna, która otrzymuje launchParams
. Ten launchParams
obiekt
ma pole o nazwie files, które zwraca tablicę uchwytów plików, z którymi mogę pracować. Interesuje mnie tylko pierwszy z nich, a z tego uchwytu pliku otrzymuję obiekt blob, który następnie przekazuję do naszego starego znajomego 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 File Handling API znajdziesz w moim artykule. Aby włączyć obsługę plików, ustaw flagę funkcji eksperymentalnych platformy internetowej. Planujemy wprowadzić ją w Chrome jeszcze w tym roku.
Integracja z schowkiem
Kolejną ciekawą funkcją Excalidraw jest integracja ze schowkiem. Mogę skopiować cały rysunek lub tylko jego fragmenty do schowka, ewentualnie dodać znak wodny, a potem wkleić go do innej aplikacji. To jest internetowa wersja aplikacji Paint z systemu Windows 95.
Sposób działania tej funkcji jest zaskakująco prosty. Wystarczy mi obszar rysowania w formacie blob, który następnie zapisuję w schowku, przekazując do funkcji navigator.clipboard.write()
tablicę jednoelementową z obiektem ClipboardItem
zawierającym blob. Więcej informacji o tym, co możesz zrobić za pomocą interfejsu Clipboard API, znajdziesz w tym 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ą wspólnie pracować nad tym samym dokumentem. Aby rozpocząć nową sesję, klikam przycisk współpracy na żywo, a potem rozpoczynam sesję. Dzięki zintegrowanemu z Excalidraw interfejsowi Web Share API mogę łatwo udostępniać adres URL sesji współpracownikom.
Współpraca na żywo
Przeprowadziłem symulację sesji współpracy lokalnej, pracując nad logo Google I/O na Pixelbooku, telefonie Pixel 3a i iPadzie Pro. Widać, że zmiany wprowadzone na jednym urządzeniu są odzwierciedlane na wszystkich pozostałych.
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 ekranu palcem.
Wyświetlanie statusów współpracowników
Aby usprawnić współpracę w czasie rzeczywistym, działa nawet system wykrywania braku aktywności. Kursor iPada Pro wyświetla zieloną kropkę, gdy go używam. Kropka zmienia kolor na czarny, gdy przełączam się na inną kartę przeglądarki lub aplikację. A gdy jestem w aplikacji Excalidraw, ale nic nie robię, kursor pokazuje, że jestem nieaktywny, co symbolizują trzy litery „zZZ”.
Osoby, które regularnie czytają nasze publikacje, mogą sądzić, że wykrywanie braku aktywności jest realizowane za pomocą interfejsu Idle Detection API, czyli propozycji na wczesnym etapie, nad którą pracujemy w ramach projektu Fugu. Uwaga spojler: nie. W Excalidraw mieliśmy implementację opartą na tym interfejsie API, ale ostatecznie zdecydowaliśmy się na bardziej tradycyjne podejście oparte na pomiarze ruchu wskaźnika i widoczności strony.
Przesłaliśmy opinię, w której wyjaśniliśmy, dlaczego interfejs Idle Detection API nie rozwiązuje naszego problemu. Wszystkie interfejsy API Project Fugu są rozwijane w sposób otwarty, więc każdy może się włączyć i wyrazić swoją opinię.
Lipis o tym, co powstrzymuje rozwój Excalidraw
Przy okazji zadałem lipisowi ostatnie pytanie o to, czego jego zdaniem brakuje na platformie internetowej, co ogranicza rozwój Excalidraw:
Interfejs File System Access API jest świetny, ale wiesz co? Większość plików, na których mi zależy, znajduje się obecnie na Dropboxie lub 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, która umożliwiałaby integrację i programowanie przez deweloperów. Użytkownicy mogą wtedy spać spokojnie, wiedząc, że ich pliki są bezpieczne u zaufanego dostawcy usług w chmurze.
W pełni zgadzam się z lipis. Ja też żyję w chmurze. Mamy nadzieję, że ta funkcja zostanie wkrótce wdrożona.
Tryb aplikacji z kartami
Niesamowite! W Excalidraw widzieliśmy wiele naprawdę świetnych integracji interfejsu API. System plików, obsługa plików, schowek, udostępnianie w internecie i miejsce docelowe udostępniania w internecie. Ale jest jeszcze jedna rzecz. Do tej pory mogłem edytować tylko jeden dokument naraz. To już nie jest problem. Po raz pierwszy możesz wypróbować wczesną wersję 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 samodzielnym oknie. Nie jest to zwykła karta przeglądarki, ale karta aplikacji PWA. Na tej nowej karcie mogę otworzyć dodatkowy plik i pracować nad nim niezależnie od głównego pliku w tym samym oknie aplikacji.
Tryb aplikacji z kartami jest we wczesnej fazie rozwoju i nie wszystko jest jeszcze ustalone. Jeśli Cię to interesuje, przeczytaj mój artykuł, aby dowiedzieć się więcej o obecnym stanie tej funkcji.
Zakończenie
Aby być na bieżąco z tą i innymi funkcjami, śledź nasz tracker interfejsu Fugu API. Bardzo się cieszymy, że możemy rozwijać internet i umożliwiać Ci więcej działań na platformie. Życzymy ciągłego rozwoju Excalidraw i powodzenia w tworzeniu niesamowitych aplikacji. Zacznij tworzyć na stronie excalidraw.com.
Nie mogę się doczekać, aż zobaczę w Twoich aplikacjach niektóre z API, które dziś pokazałem. Mam na imię Tom. Znajdziesz mnie na Twitterze jako @tomayac i w internecie. Dziękujemy za uwagę i życzymy miłego dalszego korzystania z Google I/O.