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

任何先進的技術都與魔法無異。除非你瞭解,我是 Google 開發人員關係團隊的 Thomas Steiner。在 Google I/O 大會的演講中,我將介紹一些新的 Fugu API,並說明這些 API 如何改善 Excalidraw PWA 中核心使用者歷程,因此你可以從這些構想中汲取靈感,並將這些靈感應用到自己的應用程式。

我如何開始使用 Excalidraw

我想先說一個故事Facebook 軟體工程師 Christopher Chedeau 在 2020 年 1 月 1 日在推文針對某個小型繪圖應用程式進行了這篇推文。使用這項工具,您可以繪製看起來像卡通和手繪的方塊和箭頭。第二天還可以繪製刪節號和文字,也可選取物件並四處移動。應用程式已於 1 月 3 日命名了 Excalidraw,而且就像每個好面的專案一樣,購買網域名稱是 Christopher 最先從事的策略之一。您現在可以使用顏色,並將整個繪圖匯出為 PNG。

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

1 月 15 日,Christopher 發布了一篇網誌文章,在 Twitter 和我的網站上獲得了大量關注。貼文寫著一些令人印象深刻的數據:

  • 12K 位活躍使用者
  • GitHub 上有 1,500 顆星
  • 26 位貢獻者

如果專案成立的時間只有兩週前,成效還不錯,但真正引起我興趣的是文章後半段的內容。Christopher 寫道,這次他嘗試了新做法:為所有提交合併要求的使用者提供無條件提交存取權。在閱讀網誌文章當天,我提出提取要求,將 File System Access API 支援功能新增至 Excalidraw,修正了某人提出的功能要求

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

我的提取要求在一天後合併,後來就擁有完整的修訂版本存取權。不用說,我並沒有濫用權力。到目前為止,149 位貢獻者也沒有其他貢獻者

Excalidraw 是開發成全面的漸進式網頁應用程式,不僅支援離線支援功能,還提供令人驚豔的深色模式,可以藉助 File System Access API 開啟及儲存檔案。

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

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

這就是「如何開啟 Excalidraw」故事的最終點 但在深入探索 Excalidraw 強大的功能之前 我很高興能介紹 PanayiotisPanayiotis Lipiridis 會在網際網路上簡稱為 lipis,以貢獻最多的是 Excalidraw。我問了 lipis 為何願意花這麼多時間投入 Excalidraw:

就像其他人在 Christopher 的推文中瞭解這項專案,我第一個貢獻是新增 Open Color 資料庫,這些顏色目前仍是 Excalidraw 的一部分。隨著專案規模擴大,我們收到許多要求,因此我接下來的重要貢獻是建構用於儲存繪圖的後端,方便使用者分享繪圖。但真正讓我貢獻的意義在於,曾經嘗試過 Excalidraw 的人會想找出再次使用它的藉口。

我完全同意嘴唇。有些人嘗試過 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 小型程式庫,我們在 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 標誌。

已淘汰的 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 商店!太棒了!

有人可能會說 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 物件含有稱為「檔案」的欄位,可讓我取得要使用的檔案控制代碼。我只在意第一個,並從這個檔案句柄取得 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,然後透過傳遞具有 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 API 無法解決我們現有用途的原因。所有 Project Fugu API 都是在公開環境下開發,因此每個人都可以參與其中,表達自己的意見!

蓋在外省的埃及開特車上

在這樣的情況下,我問自己最後一個問題,是他認為現有網路平台缺少了 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 大會的其他內容。