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

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

我如何進入 Excalidraw?

我想先說故事,2020 年 1 月 1 日,Facebook 軟體工程師 Christopher Chedeau發表了 Twitter 推文,說明他開始著手處理的小型繪圖應用程式。利用這項工具,您可以畫出讓人覺得卡通和手繪的方塊和箭頭。隔天,您可以繪製刪節號和文字,還可以選取物件並移動物件。到了 1 月 3 日,這款應用程式的名稱是 Excalidraw。此外,就像每個優質專案一樣,購買網域名稱是 Christopher 最初採取的其中一項行動。現在,您可以使用顏色,並將整個繪圖匯出為 PNG 檔案。

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

1 月 15 日,Christopher 發表了一篇網誌文章,在 Twitter 上獲得許多關注,包括我本人。該貼文提供了幾個令人驚豔的統計資料:

  • 1.2 萬人不重複活躍使用者
  • GitHub 上有 15,000 顆星星
  • 26 位協作者

專案才剛開始兩週,還不成問題。但真正引起我興趣的一點是 文章還沒提到Christopher 撰寫了他這次嘗試的新做法:讓所有發出提取要求且無條件修訂存取的所有人。我在閱讀網誌文章當天提出提取要求,為 Excalidraw 新增了 File System Access API 支援,進而修正某人提出的功能要求

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

我的提取要求是在一天後合併,而且我在那之後擁有完整的修訂版本存取權。不用說,我完全沒有濫用我的力量而且目前也沒有 149 位貢獻者的身影。

目前,Excalidraw 是功能完整的安裝漸進式網頁應用程式,提供離線支援、令人驚豔的深色模式,而且是可透過 File System Access API 開啟及儲存檔案的功能。

以今日狀態執行的 Excalidraw PWA 螢幕截圖。

講述他為何將大量時間投入 Excalidraw

因此,「我跑到 Excalidraw」的故事結束了,但在我深入探索 Excalidraw 出色的功能之前,我很榮幸能介紹《Panaayiotis》。在網路上簡稱為「lipis」的 Panayiotis Lipiridis,是 Excalidraw 帶來的貢獻最多。我詢問 Lipis 的動機是什麼,他花太多時間去執行 Excalidraw:

和其他我一樣,透過 Christopher 的 Twitter 推文也瞭解這項專案,我第一項貢獻是新增「Open Color Library」,也就是現在仍屬於 Excalidraw 手機的顏色。隨著這個專案持續擴大,收到了許多要求,我接下來的主要任務是建構後端來儲存繪圖,讓使用者能夠分享這些內容。但真正促使我做出貢獻的原因是,試過 Excalidraw 的人希望找到理由來再次使用。

我完全同意謊言。只要是試過 Excalidraw 的小公司,都在尋找合適的證據,以便再次使用。

Excalidraw 實際運作情形

現在我想示範如何實際運用 Excalidraw。我不是位優秀的藝術家,但 Google I/O 標誌的介面很簡潔,現在就來試試看。方塊為「i」,線條可以是斜線,「o」則是圓形。我按住 Shift 鍵,讓我產生一個完美的圓圈。我要稍微拉一下斜線 看起來好看現在「i」和「o」的色彩。藍色代表很好也可以採用不同的填滿樣式?都採用實心凸顯設計,或建議採用交叉銷售?不,哈木看起來很棒。這並不是完美的,但這是 Excalidraw 的概念, 所以我把它儲存起來

我按一下「儲存」圖示,並在檔案儲存對話方塊中輸入檔案名稱。在 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 中使用的小型程式庫。這個程式庫透過 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 類型和擴充功能後,下一步是透過程式輔助方式按一下輸入元素,讓檔案開啟的對話方塊顯示。設定變更時,當使用者選取一或多個檔案時,Pro 代表就會解決問題。

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

針對我尚未談到的檔案,您可以做 doubleclick 檔案。產生 DoubleClick 檔案時會發生的問題,通常是與檔案的 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!那是相當大的!

可能表示 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 物件含有名為「file」的欄位,以便取得檔案控制代碼陣列。我只關心第一個函式,而來自這個檔案處理代碼,會取得一個 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 也提供協作模式嗎?不同的人可以共同合作 同一份文件。如要開始新的課程,我先點選「Live collaboration」按鈕,然後開始練習。借助 Excalidraw 整合的 Web Share API,我可以輕鬆與協作者分享工作階段網址。

即時協作

我在 Pixelbook、我的 Pixel 3a 手機和 iPad Pro 上編輯 Google I/O 標誌,在本機模擬協作課程。您可以看到我在其中一個裝置上進行的變更,會反映在所有其他裝置上。

甚至可以看到所有遊標四處移動。Pixelbook 的遊標由觸控板控制,因此移動方式不穩定,但 Pixel 3a 手機的遊標和 iPad Pro 的平板電腦遊標會跳起來,因為我用手指輕觸控制這些裝置。

查看協作者狀態

為了改善即時協作體驗,甚至也會啟動閒置偵測系統。使用 iPad Pro 時,iPad Pro 的遊標會顯示綠點。切換到其他瀏覽器分頁或應用程式時,點變成黑色。當我開啟 Excalidraw 應用程式時,只是沒有執行任何動作,遊標會顯示為閒置狀態,並以三個 zZZ 符號表示。

許多出版品的熱心讀者都會認為,透過 Idle Detection API 實現閒置偵測 (這是一項在 Project Fugu 開發的早期提案)。提前爆雷:不是。雖然我們以 Excalidraw 這個 API 為基礎進行實作,但最後,我們決定採取更傳統的做法,也就是透過測量指標移動和頁面瀏覽權限的情況。

在 WICG 閒置偵測存放區上提供的閒置偵測意見回饋螢幕截圖。

我們收到意見回饋,說明 Idle Detection API 無法解決應用實例的原因。所有 Project Fugu API 都是以公開的形式開發,因此每個人都能參與互動,並表達自己的看法!

嘴巴抑制 Excalidraw 物品的謊言

談到這個,我再問最後一個問題,說明他認為網路平台缺少 Excalidraw 解決方案缺少的內容:

File System Access API 很好,不過您知道嗎?這幾天我關注的多數檔案 都會保存在 Dropbox 或 Google 雲端硬碟,而不是硬碟中我希望 File System Access API 包含可供 Dropbox 或 Google 等遠端檔案系統供應商整合的抽象層,以便開發人員進行程式碼整合。這樣一來,使用者便可放鬆,確認檔案對於 信任的雲端服務供應商安全無虞

我完全同意謊言,我也住在雲端。我們希望很快就能實施這項做法。

分頁式應用程式模式

太厲害了!在 Excalidraw 中,我們看到許多很棒的 API 整合功能。檔案系統檔案處理剪貼簿網路共用區網路共用目標。不過還有一件事直到現在,我一次只能編輯一份文件然而今非昔比。歡迎首次使用 Excalidraw 中的分頁式應用程式模式首次使用。看起來會像這樣。

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

分頁式應用程式模式尚處於早期階段,並非所有功能都能完美無缺。如果您有興趣,請務必參閱我的文章,瞭解這項功能的目前狀態。

正在關閉

請務必觀看 Fugu API 追蹤工具,隨時掌握此功能和其他功能的相關資訊。我們超期待推動網路發展 讓你在平台上完成更多工作讓我們來著手改善 Excalidraw 技術,並協助您建構所有令人驚豔的應用程式。如要開始建立內容,請前往 excalidraw.com

我等不及想看到今天在應用程式中彈出的 API。我是 Tom,在 Twitter 和網際網路上大部分會顯示為 @tomayac。感謝您的收看,也喜歡接下來的 Google I/O 大會。