《Excalidraw》與《Fugu》:改善核心使用者歷程

任何堪稱先進的科技,皆與魔法無異。除非你瞭解,我是 Thomas Steiner,在 Google 的開發人員關係團隊服務。在本篇 Google I/O 演講的文字記錄中,我將介紹幾個新的 Fugu API,以及這些 API 如何改善 Excalidraw PWA 中的核心使用者歷程,讓您從這些概念中獲得靈感,並將這些概念應用到自己的應用程式。

我如何開始使用 Excalidraw

我想以一個故事開場。2020 年 1 月 1 日,Facebook 軟體工程師 Christopher ChedeauTwitter 上發文,提到他開始著手開發的簡易繪圖應用程式。您可以使用這項工具繪製看起來像卡通和手繪的方塊和箭頭。隔天,您也可以繪製橢圓和文字,以及選取物件並移動。1 月 3 日,應用程式有了名稱「Excalidraw」,就像每個優質的副項目一樣,購買網域名稱是 Christopher 的第一項行動。您現在可以使用顏色,並將整個繪圖匯出為 PNG。

Excalidraw 原型應用程式的螢幕截圖,顯示該應用程式支援矩形、箭頭、橢圓形和文字。

1 月 15 日,Christopher 發布了一篇網誌文章,在 Twitter 上引起廣大迴響,包括我自己也相當關注。這篇貼文一開始就列出一些令人驚豔的數據:

  • 12K 位活躍使用者
  • GitHub 上的 1.5 千顆星號
  • 26 位貢獻者

對於才剛開始兩週的專案來說,這一點也不差。但真正引起我興趣的是文章後半段的內容。Christopher 表示,這次嘗試了新做法:為所有提交合併要求的使用者提供無條件提交存取權。在閱讀這篇網誌文章的同一天,我提出了提取要求,為 Excalidraw 新增 File System Access API 支援,修正了使用者提交的功能要求

我宣布 PR 的推文螢幕截圖。

我的提取要求在一天後合併,從那時起,我擁有完整的提交存取權。不用說,我並沒有濫用權力。而目前 149 位貢獻者中,也沒有其他人這樣做。

如今,Excalidraw 是一款完整的漸進式網頁應用程式,可安裝、支援離線作業、提供精美的深色模式,並且透過 File System Access API 開啟及儲存檔案。

目前狀態下 Excalidraw PWA 的螢幕截圖。

Lipis 談到為何他會花費大量時間使用 Excalidraw

以上就是我「如何開始使用 Excalidraw」的故事,但在深入介紹 Excalidraw 的部分精彩功能之前,我很高興能介紹 Panayiotis。Panayiotis Lipiridis,網際網路上的簡稱是 lipis,是 Excalidraw 最活躍的貢獻者。我問了 lipis 為何願意花這麼多時間投入 Excalidraw:

和其他人一樣,我也是透過 Christopher 的推文得知這個專案。我第一個貢獻是新增 Open Color 資料庫,這些顏色目前仍是 Excalidraw 的一部分。隨著專案規模擴大,我們收到許多要求,因此我接下來的重要貢獻是建構用於儲存繪圖的後端,方便使用者分享繪圖。不過,真正促使我提供貢獻的是,只要有人試用過 Excalidraw,就會想找藉口再次使用。

我完全同意 lipis 的說法。任何使用過 Excalidraw 的人都會想找藉口再次使用這個工具。

Excalidraw 實際使用情形

我現在要示範如何實際使用 Excalidraw。我不是很擅長繪圖,但 Google I/O 的標誌很簡單,所以我來試試看。方塊代表「i」,線條代表斜線,而「o」則代表圓形。我按住 Shift 鍵,以便繪製完美的圓形。讓我稍微移動斜線,讓它看起來更美觀。接下來為「i」和「o」加上顏色。藍色代表做得很好。或許是不同的填滿樣式?是否全部為實心或交叉陰影?不,虛線圖看起來很棒。這不是完美的解決方案,但這是 Excalidraw 的概念,所以我會儲存這個檔案。

我按一下「儲存」圖示,並在檔案儲存對話方塊中輸入檔案名稱。在支援 File System Access API 的瀏覽器 Chrome 中,這並非下載作業,而是真正的儲存作業,我可以選擇檔案的位置和名稱,如果我進行編輯,可以直接將其儲存到同一個檔案中。

我來變更標誌,將「i」改為紅色。如果我現在再次按一下「儲存」,修改內容就會儲存至與先前相同的檔案。為了證明這一點,我會清除畫布並重新開啟檔案。如您所見,經過修改的紅藍標誌又出現了。

使用檔案

在目前不支援 File System Access API 的瀏覽器上,每個儲存作業都是下載作業,因此當我進行變更時,我會在「Downloads」資料夾中填滿多個檔案,這些檔案的檔名中都會加上遞增的數字。但即使有這個缺點,我還是可以儲存檔案。

開啟檔案

那麼,秘訣是什麼呢?如何在不同瀏覽器 (可能支援或不支援 File System Access API) 上開啟及儲存檔案?在 Excalidraw 中開啟檔案的動作會在名為 loadFromJSON)( 的函式中執行,而該函式會呼叫名為 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);
};

fileOpen() 函式來自我撰寫的 browser-fs-access 小型程式庫,我們在 Exaclidraw 中使用這個程式庫。這個程式庫會透過 File System Access API 提供檔案系統存取權,並提供舊版備用方案,因此可在任何瀏覽器中使用。

首先,讓我向您展示在支援 API 時的實作方式。在協商接受的 MIME 類型和檔案副檔名後,核心部分會呼叫 File System Access API 的 showOpenFilePicker() 函式。這個函式會傳回檔案陣列或單一檔案,取決於是否選取多個檔案。接著,只需將檔案句柄放在檔案物件上,即可再次擷取檔案。

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

備用實作項目會依賴 "file" 類型的 input 元素。在協商可接受的 MIME 類型和副檔名後,下一步是透過程式設計點選輸入元素,以便顯示檔案開啟對話方塊。變更時,也就是使用者選取一或多個檔案時,承諾會解析。

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

儲存檔案

接下來,我們來談談儲存方式。在 Excalidraw 中,儲存作業會在名為 saveAsJSON() 的函式中執行。它會先將 Excalidraw 元素陣列序列化為 JSON,再將 JSON 轉換為 blob,然後呼叫名為 fileSave() 的函式。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 };
};

再次說明,我會先看一下支援 File System Access API 的瀏覽器實作方式。前幾行看起來有點複雜,但它們只會協商 MIME 類型和檔案副檔名。如果我先前已儲存檔案,且已取得檔案句柄,就不需要顯示儲存對話方塊。但如果這是第一次儲存,系統會顯示檔案對話方塊,應用程式會取得檔案句柄,以利日後使用。其餘內容則會寫入檔案,這會透過可寫入串流進行。

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

「另存為」功能

如果我決定忽略現有的檔案句柄,可以實作「另存新檔」功能,根據現有檔案建立新檔案。為說明這一點,我將開啟現有檔案並進行一些修改,然後使用「另存新檔」功能建立新檔案,而不會覆寫現有檔案。這樣一來,原始檔案就不會受到影響。

對於不支援 File System Access API 的瀏覽器,實作內容較為簡短,因為它只會建立錨點元素,並附上 download 屬性,其值為所需的檔案名稱,以及 blob 網址做為 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();
};

系統會透過程式輔助方式點選錨點元素。為避免記憶體流失,請在使用 Blob 網址後撤銷該網址。由於這只是下載作業,因此不會顯示任何檔案儲存對話方塊,所有檔案都會儲存在預設的 Downloads 資料夾中。

拖曳

我最喜歡的電腦版系統整合功能之一,就是拖曳功能。在 Excalidraw 中,當我將 .excalidraw 檔案拖放到應用程式時,它會立即開啟,我就可以開始編輯。在支援 File System Access API 的瀏覽器上,我甚至可以立即儲存變更。由於已從拖曳及放置作業取得必要的檔案句柄,因此不需要透過檔案儲存對話方塊。

要達成這個目標,您必須在支援 File System Access API 時,對資料移轉項目呼叫 getAsFileSystemHandle()。接著,我會將這個檔案句柄傳遞至 loadFromBlob(),您可能會在前幾段文字中看到這個名稱。您可以執行許多操作來處理檔案,例如開啟、儲存、重複儲存、拖曳、放置。我的同事 Pete 和我已在這篇文章中記錄了所有這些技巧和其他資訊,以便您掌握這些技巧,因為這些技巧可能會讓您覺得有點太快。

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

分享檔案

目前在 Android、ChromeOS 和 Windows 上,另一個系統整合方式是透過 Web Share Target API。我現在在「檔案」應用程式的 Downloads 資料夾中。我可以看到兩個檔案,其中一個檔案的名稱是 untitled,並附有時間戳記。如要查看內容,我按一下三點圖示,然後點選「分享」,其中一個選項就是「Excalidraw」。輕觸圖示後,我發現檔案再次只包含 I/O 標誌。

Lipis 在已淘汰的 Electron 版本上

您可以雙擊檔案,這是我尚未提及的操作方式。一般來說,雙擊檔案時,系統會開啟與檔案 MIME 類型相關聯的應用程式。例如 .docx,這會是 Microsoft Word。

Excalidraw 曾經有支援這類檔案類型關聯的 Electron 版本,因此當您雙擊 .excalidraw 檔案時,Excalidraw Electron 應用程式就會開啟。您先前曾見過的 Lipis 是 Excalidraw Electron 的創作者和停用者。我問他為什麼覺得可以淘汰 Electron 版本:

自一開始,使用者就要求提供 Electron 應用程式,主要是因為他們希望能透過雙擊開啟檔案。我們也打算將應用程式上架至應用程式商店。與此同時,有人建議改為建立 PWA,因此我們就同時執行這兩項作業。幸運的是,我們認識了 Project Fugu API,例如檔案系統存取權、剪貼簿存取權、檔案處理等。只要按一下滑鼠,即可在電腦或行動裝置上安裝應用程式,而且不會額外增加 Electron 的負擔。我們很輕易地決定淘汰 Electron 版本,專注於網頁應用程式,並將其打造為最佳 PWA。最重要的是,我們現在可以將 PWA 發布至 Play 商店和 Microsoft 商店!太棒了!

有人說,Electron 版 Excalidraw 並未淘汰,並非因為 Electron 不好,而是因為網頁已經足夠好。我喜歡這個!

檔案處理

我說「網頁已經足夠好」,是因為有即將推出的檔案處理功能等功能。

這是一般 macOS Big Sur 安裝作業。接著,我們來看看在 Excalidraw 檔案上按一下滑鼠右鍵時會發生什麼事。我可以選擇使用已安裝的 PWA Excalidraw 開啟檔案。當然,雙擊也可以,只是在螢幕錄影中展示起來不夠生動。

那麼,這項功能的運作方式為何?第一步是讓作業系統知道應用程式可處理的檔案類型。我會在網頁應用程式資訊清單中,使用名為 file_handlers 的新欄位執行這項操作。其值是含有動作和 accept 屬性的物件陣列。動作會決定作業系統啟動應用程式的網址路徑,而接受物件則是 MIME 類型和相關檔案副檔名的鍵/值組合。

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

接下來,我們會在應用程式啟動時處理檔案。這會發生在 launchQueue 介面中,我需要呼叫 setConsumer() 來設定消費者。這個函式的參數是接收 launchParams 的非同步函式。這個 launchParams 物件含有名為 files 的欄位,可讓我取得要使用的檔案句柄陣列。我只在意第一個,從這個檔案句柄取得 blob,然後將其傳遞給我們的老朋友 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 });
      });
    });
}

再次提醒,如果您覺得這段內容太過快速,可以參閱我的文章,進一步瞭解 File Handling API。您可以設定實驗性網站平台功能旗標,啟用檔案處理功能。預計今年稍晚會在 Chrome 推出。

剪貼簿整合

Excalidraw 的另一項實用功能是剪貼簿整合功能。我可以將整張或部分繪圖複製到剪貼簿,並視需要加入浮水印,然後將其貼到其他應用程式中。順帶一提,這是 Windows 95 版 Paint 應用程式的網路版本。

這項功能的運作方式相當簡單,我只需要將畫布做為 Blob,然後透過 ClipboardItem 傳遞單一元素陣列,將 Blob 寫入剪貼簿。navigator.clipboard.write()如要進一步瞭解剪貼簿 API 的用途,請參閱 Jason 和我的文章

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

與他人協作

分享工作階段網址

您知道嗎?Excalidraw 也有協作模式。不同使用者可以共同編輯同一份文件。如要開始新的問與答階段,我按一下即時協作按鈕,然後啟動工作階段。由於 Excalidraw 已整合 Web Share API,我可以輕鬆與協作者分享工作階段網址。

即時協作

我已在本機模擬協作工作階段,在 Pixelbook、Pixel 3a 手機和 iPad Pro 上繪製 Google I/O 標誌。你可以看到我在一台裝置上所做的變更,會反映在所有其他裝置上。

我甚至可以看到所有游標移動。Pixelbook 的游標是透過觸控板控制,因此移動得很穩定,但 Pixel 3a 手機的游標和 iPad Pro 平板電腦的游標會跳動,因為我使用手指輕觸來控制這些裝置。

查看協作者狀態

為了改善即時協作體驗,我們還推出了閒置偵測系統。使用 iPad Pro 時,游標會顯示綠色圓點。當我切換至其他瀏覽器分頁或應用程式時,這個點會變成黑色。當我在 Excalidraw 應用程式中,但什麼事都沒做時,游標會顯示為閒置狀態,以三個 zZZ 做為象徵。

熱心閱讀我們出版品的讀者可能會認為,閒置偵測功能是透過 Idle Detection API 實現的,這是在 Project Fugu 的背景下提出的早期提案。提前爆雷:並非如此。雖然我們在 Excalidraw 中實作了這個 API,但最後我們決定採用更傳統的方法,也就是根據指標移動和頁面顯示情況進行評估。

在 WICG Idle Detection 存放區提交的 Idle Detection 意見回饋螢幕截圖。

我們已提交意見回饋,說明為何閒置偵測 API 無法解決我們的用途。所有 Project Fugu API 都是在公開環境下開發,因此每個人都可以參與討論,讓自己的聲音被聽見!

Lipis 說明 Excalidraw 的進度落後的原因

說到這裡,我問了 lipis 最後一個問題,請他談談他認為網頁平台缺少哪些功能,導致 Excalidraw 無法發揮效用:

File System Access API 很棒,但您知道嗎?我現在重視的大部分檔案都儲存在 Dropbox 或 Google 雲端硬碟中,而不是硬碟上。我希望 File System Access API 能包含一個抽象層,讓 Dropbox 或 Google 等遠端檔案系統供應商進行整合,並讓開發人員編寫程式碼。使用者可以放心,知道自己的檔案在信任的雲端服務供應商手中安全無虞。

我完全同意 lipis 的說法,我也使用雲端服務。希望這項功能能盡快實施。

分頁應用程式模式

太棒了!我們在 Excalidraw 中看到許多非常棒的 API 整合功能。檔案系統檔案處理剪貼簿網路分享網路分享目標。但還有一件事,到目前為止,我只能在特定時間編輯一個文件。答案是不需要。歡迎搶先體驗 Excalidraw 的早期分頁應用程式模式。如下圖所示。

我已在已安裝的 Excalidraw PWA 中開啟現有檔案,且檔案以獨立模式執行。我現在會在獨立視窗中開啟新分頁。這不是一般瀏覽器分頁,而是 PWA 分頁。接著,我可以在這個新分頁中開啟次要檔案,並在同一個應用程式視窗中獨立處理這些檔案。

分頁應用程式模式仍處於初期階段,並非所有內容都已確定。如有興趣,請務必參閱我的文章,瞭解這項功能目前的狀態。

結語

如要隨時掌握這項功能和其他功能的最新消息,請務必追蹤我們的 Fugu API 追蹤器。我們非常高興能推動網路技術的發展,並讓您在平台上發揮更多潛力。我們將持續改善 Excalidraw,也期待您打造出更多精彩的應用程式。前往 excalidraw.com 開始創作。

我很期待看到您在應用程式中使用今天介紹的部分 API。我是 Tom,在 Twitter 和網際網路上,我的帳號名稱是 @tomayac。感謝你收看,請繼續享受 Google I/O 大會的其他內容。