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

任何堪稱先進的科技,皆與魔法無異。除非您瞭解這項功能。我是 Google 開發人員關係團隊的 Thomas Steiner。在本文中,我將根據 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 上引起廣大迴響,包括我本人。這篇貼文開頭列出了一些令人印象深刻的數據:

  • 12,000 位不重複活躍使用者
  • GitHub 上的 1.5K 星號
  • 26 位貢獻者

這項專案才剛啟動兩週,這樣的成績已相當不錯。但真正引起我興趣的是貼文下方的內容。Christopher 寫道,這次他嘗試了新做法:無條件授予所有合併提取要求的提交存取權。當天我讀完這篇網誌文章後,就提交了提取要求,在 Excalidraw 中新增 File System Access API 支援,解決了某人提出的功能要求

我宣布公關消息的推文螢幕截圖。

我的提取要求在一天後合併,從此我擁有完整的提交存取權。不用說,我沒有濫用權力。目前為止,149 位貢獻者中也沒有人這麼做。

如今,Excalidraw 已成為可安裝的完整漸進式網頁應用程式,支援離線作業、提供令人驚豔的深色模式,而且還能透過 File System Access API 開啟及儲存檔案。

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

Lipis 談論他為何投入大量時間開發 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 的瀏覽器會將每次儲存作業視為下載,因此我每次變更都會產生多個檔案,檔名結尾會加上遞增數字,導致「下載」資料夾塞滿檔案。但儘管有這項缺點,我還是可以儲存檔案。

開啟檔案

那麼,秘訣是什麼?如果瀏覽器支援或不支援 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,我們在 Excalidraw 中使用這個函式。這個程式庫透過檔案系統存取 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 類型和副檔名後,下一步是以程式輔助方式點選輸入元素,顯示檔案開啟對話方塊。變更時 (也就是使用者選取一或多個檔案時),Promise 會解析。

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 標誌。

已淘汰的 Electron 版本上的 Lipis

我還沒提到的一項檔案功能是雙擊檔案。一般來說,當您連按兩下檔案時,系統會開啟與檔案 MIME 類型相關聯的應用程式。例如,.docx 的值可能是 Microsoft Word。

Excalidraw 過去有 Electron 版本的應用程式,支援這類檔案類型關聯,因此按兩下 .excalidraw 檔案時,系統會開啟 Excalidraw Electron 應用程式。您先前已認識 Lipis,他是 Excalidraw Electron 的建立者和淘汰者。我問他為什麼認為可以淘汰 Electron 版本:

自一開始,使用者就要求推出 Electron 應用程式,主要是因為他們想透過按兩下開啟檔案。我們也打算將應用程式上架至應用程式商店。與此同時,有人建議改為建立 PWA,因此我們就兩者都做了。幸好我們認識了 Project Fugu API,例如檔案系統存取權、剪貼簿存取權、檔案處理等。只要按一下滑鼠,即可在電腦或行動裝置上安裝應用程式,不必額外安裝 Electron。因此我們決定淘汰 Electron 版本,專注於開發網頁應用程式,並將其打造成最優質的 PWA。此外,我們現在還能將 PWA 發布到 Play 商店和 Microsoft Store!這太棒了!

可以說 Excalidraw for Electron 並非因為 Electron 不好而遭到淘汰,而是因為網頁已足夠好。這項功能非常實用!

檔案處理

我說「網路已變得夠好」,是因為即將推出的檔案處理等功能。

這是 macOS Big Sur 的一般安裝程序。現在請查看在 Excalidraw 檔案上按一下滑鼠右鍵時會發生什麼事。我可以選擇使用已安裝的 PWA Excalidraw 開啟檔案。當然,雙擊也可以,只是在螢幕截圖中示範效果較不顯著。

這項功能如何運作?首先,您必須讓作業系統瞭解應用程式可處理的檔案類型。我在網頁應用程式資訊清單中,透過名為 file_handlers 的新欄位執行這項操作。其值為物件陣列,包含動作和 accept 屬性。這項動作會決定作業系統啟動應用程式時使用的網址路徑,而 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 小畫家應用程式的網頁版。

這項功能的運作方式出乎意料地簡單。我只需要將畫布做為 Blob,然後將含有 Blob 的 ClipboardItem 傳遞至 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 分頁。接著,我可以在這個新分頁中開啟次要檔案,並在同一個應用程式視窗中獨立處理這些檔案。

分頁應用程式模式尚處於早期階段,因此一切都還在調整中。如有興趣,請務必參閱這篇文章,瞭解這項功能的最新狀態。

Closing

如要隨時掌握這項功能和其他功能的最新消息,請務必觀看我們的 Fugu API 追蹤器。我們非常期待能推動網路發展,讓您在平台上完成更多工作。祝 Excalidraw 越來越好,也祝您建構出各種精彩的應用程式。前往 excalidraw.com 開始建立。

我很期待今天介紹的 API 出現在您的應用程式中。我是 Tom,你可以在 Twitter 和網路上找到我,帳號是 @tomayac。感謝收看,祝您在 Google I/O 大會期間一切順心。