Excalidraw と Fugu: コア ユーザー ジャーニーの改善

十分に発達した科学技術は、魔法と見分けがつかない。理解している場合は別です。Thomas Steiner と申します。Google のデベロッパー リレーションズに勤務しています。Google I/O での講演の書き起こしとして、新しい Fugu API のいくつかと、それらが Excalidraw PWA のコア ユーザー ジャーニーをどのように改善するかについて説明します。これらのアイデアからヒントを得て、ご自身のアプリに適用してください。

Excalidraw の開発に至った経緯

ストーリーから始めます。2020 年 1 月 1 日、Facebook のソフトウェア エンジニアである Christopher Chedeau は、開発を始めた小さな描画アプリについてツイートしました。このツールを使用すると、手描き風のボックスや矢印を描画できます。翌日には、楕円やテキストを描画したり、オブジェクトを選択して移動したりすることもできます。1 月 3 日、アプリは Excalidraw という名前になりました。他の優れた副業プロジェクトと同様に、Christopher はまずドメイン名を購入しました。これで、色を使用して、描画全体を PNG としてエクスポートできるようになりました。

長方形、矢印、楕円、テキストをサポートしていることを示す、Excalidraw プロトタイプ アプリケーションのスクリーンショット。

1 月 15 日、Christopher がブログ投稿を公開し、私も含め Twitter で多くの注目を集めました。投稿は、次のような印象的な統計情報から始まります。

  • 12,000 人のユニーク アクティブ ユーザー
  • GitHub で 1,500 個のスター
  • 26 人の投稿者

たった 2 週間前に始まったプロジェクトとしては、悪くない数字です。しかし、私の興味を本当にそそったのは、投稿のさらに下にあったことです。Christopher は、今回は新しいことを試みたと書いています。プルリクエストを送信したすべての人に無条件の commit アクセス権を付与しました。ブログ投稿を読んだその日、Excalidraw に File System Access API のサポートを追加し、誰かが提出した機能リクエストを修正するプルリクエストを作成しました。

PR を発表したツイートのスクリーンショット。

1 日後に pull リクエストが統合され、それ以降は完全な commit アクセス権が付与されました。言うまでもなく、私は権力を乱用していません。また、これまでに 149 人のコントリビューターが確認しましたが、他の誰もこの問題に遭遇していません。

現在、Excalidraw は、オフライン サポート、美しいダークモード、File System Access API によるファイルの開閉と保存機能を備えた、インストール可能な本格的なプログレッシブ ウェブアプリです。

現在の状態の Excalidraw PWA のスクリーンショット。

Lipis が Excalidraw に多くの時間を費やす理由

以上が「Excalidraw の開発に至った経緯」でした。Excalidraw の優れた機能について詳しく説明する前に、Panayiotis をご紹介します。Panayiotis Lipiridis(インターネットでは lipis という名前で知られている)は、Excalidraw に最も多くの貢献をしたコントリビューターです。lipis に、Excalidraw にこれほど多くの時間を費やす動機を尋ねてみました。

他の皆様と同じく、このプロジェクトについて知ったのはクリストファーのツイートからです。私が最初に貢献したのは、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() 関数は、Excalidraw で使用している browser-fs-access という私が作成した小さなライブラリから取得したものです。このライブラリは、以前のフォールバックを使用して 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 タイプと拡張子をネゴシエートしたら、次は、入力要素をプログラムでクリックしてファイル開きダイアログを表示します。変更時(ユーザーが 1 つ以上のファイルを選択したとき)に、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 属性と、href 属性値として blob URL を持つアンカー要素を作成するだけです。

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 URL を取り消す必要があります。これは単なるダウンロードであるため、ファイル保存ダイアログは表示されず、すべてのファイルがデフォルトの Downloads フォルダに保存されます。

ドラッグ&ドロップ

デスクトップで利用できるシステム統合機能の 1 つに、ドラッグ&ドロップがあります。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 で利用可能なシステム インテグレーションのもう 1 つは、Web Share Target API によるものです。ファイル アプリの Downloads フォルダに移動します。2 つのファイルが表示されます。1 つはわかりにくい名前 untitled で、タイムスタンプが付いています。内容を確認するには、その他アイコンをクリックして共有します。表示されるオプションの 1 つが「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 を Google Play ストアと Microsoft Store に公開できるようになりました。大きな変化です。

Electron 版 Excalidraw が非推奨になったのは、Electron が悪いからではなく、ウェブが十分に優秀になったからだと言えるでしょう。気に入りました

ファイル処理

「ウェブが十分に成熟した」というのは、今後リリースされるファイル処理などの機能が理由です。

これは通常の macOS Big Sur のインストールです。次に、Excalidraw ファイルを右クリックするとどうなるかを確認します。インストール済みの PWA である Excalidraw で開くこともできます。もちろん、ダブルクリックでも機能しますが、スクリーンキャストではわかりにくいです。

では、その仕組みについて説明します。最初のステップは、アプリが処理できるファイル形式をオペレーティング システムに知らせることです。これは、ウェブアプリ マニフェストの file_handlers という新しいフィールドで設定します。値は、アクションと accept プロパティを持つオブジェクトの配列です。アクションは、オペレーティング システムがアプリを起動する URL パスを決定します。accept オブジェクトは、MIME タイプと関連するファイル拡張子の Key-Value ペアです。

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

次のステップは、アプリケーションの起動時にファイルを処理することです。これは、setConsumer() を呼び出してコンシューマを設定する必要がある launchQueue インターフェースで発生します。この関数のパラメータは、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 のもう 1 つの便利な機能は、クリップボードとの連携です。描画全体または一部をクリップボードにコピーし、必要に応じてウォーターマークを追加して、別のアプリに貼り付けることができます。ちなみに、これは Windows 95 のペイント アプリのウェブ版です。

仕組みは驚くほどシンプルです。必要なのは、キャンバスを blob として取得することです。次に、blob を含む ClipboardItem を含む 1 要素の配列を 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);
    }
  });
};

他のユーザーとのコラボレーション

セッション URL の共有

Excalidraw にはコラボレーション モードがあることをご存じですか?複数のユーザーが同じドキュメントで共同編集できます。新しいセッションを開始するには、ライブコラボレーション ボタンをクリックしてセッションを開始します。Excalidraw に統合されている Web Share API により、セッション URL を共同編集者と簡単に共有できます。

ライブ コラボレーション

Google Pixelbook、Google Pixel 3a、iPad Pro で Google I/O ロゴを編集して、ローカルでコラボレーション セッションをシミュレートしました。1 つのデバイスで行った変更が、他のすべてのデバイスに反映されていることがわかります。

すべてのカーソルが動いていることも確認できます。Google Pixelbook のカーソルはトラックパッドで操作されるため、安定して移動しますが、Google Pixel 3a のスマートフォンのカーソルと iPad Pro のタブレットのカーソルは、指でタップして操作するため、飛び跳ねます。

共同編集者のステータスを確認する

リアルタイム コラボレーションの利便性を高めるために、アイドル状態検出システムも実行されています。iPad Pro のカーソルを使用すると、緑色のドットが表示されます。別のブラウザのタブまたはアプリに切り替えると、ドットが黒くなります。また、Excalidraw アプリで何も操作していない場合、カーソルはアイドル状態として表示され、3 つの zZZ で示されます。

Google の公開情報をよく読んでいる方なら、アイドル状態の検出は Idle Detection API によって実現されると思われるかもしれません。これは、Project Fugu のコンテキストで開発されている初期段階のプロポーザルです。ネタバレ注意: Excalidraw ではこの API に基づく実装を行っていましたが、最終的には、ポインタの移動とページの可視性を測定する従来のアプローチに変更することにしました。

WICG アイドル検出リポジトリに提出されたアイドル検出のフィードバックのスクリーンショット。

Idle Detection API がユースケースを解決しなかった理由について、フィードバックを送信しました。Project Fugu の API はすべてオープンソースで開発されているため、誰でも参加して意見を述べることができます。

Excalidraw の開発が遅れている理由について、Lipis が説明します

ついでに、Excalidraw の普及を妨げているウェブ プラットフォームに欠けているものについて、Lipis に最後の質問を投げかけました。

File System Access API は優れた API ですが、最近、重要なファイルのほとんどはハードディスクではなく、Dropbox や Google ドライブに保存しています。File System Access API に、Dropbox や Google などのリモート ファイル システム プロバイダが統合し、デベロッパーがコードを記述できる抽象化レイヤが含まれていたらと思います。ユーザーは、信頼できるクラウド プロバイダにファイルが安全に保存されていることを確認して安心できます。

lipis 様のご意見にまったく賛成です。私もクラウドで生活しています。近日中に実装される予定です。

タブ形式のアプリモード

結果を確認しましょう。Excalidraw では、多くの優れた API 統合が確認されています。ファイル システムファイル処理クリップボードウェブ共有ウェブ共有のターゲット。ただし、もう 1 つ注意点があります。これまでは、一度に編集できるドキュメントは 1 つのみでした。今はそうではありません。Excalidraw でタブ付きアプリケーション モードの早期バージョンを初めてお試しください。次のように表示されます。

インストール済みの Excalidraw PWA で既存のファイルを開き、スタンドアロン モードで実行しています。スタンドアロン ウィンドウで新しいタブを開きます。これは通常のブラウザタブではなく、PWA タブです。この新しいタブで、2 番目のファイルを開き、同じアプリ ウィンドウから独立して作業できます。

タブ形式のアプリモードは初期段階にあり、すべてが確定しているわけではありません。ご興味をお持ちの場合は、こちらの記事でこの機能の最新状況をご確認ください。

結びの言葉

この機能やその他の機能の最新情報については、Fugu API トラッカーをご覧ください。ウェブを前進させ、プラットフォームでできることをさらに広げられること、大変うれしく思います。今後も進化を続ける Excalidraw と、皆さんが構築する素晴らしいアプリの今後にご期待ください。excalidraw.com で作成を開始しましょう。

本日ご紹介した API が皆さんのアプリに実装されるのを楽しみにしています。Tom と申します。Twitter やインターネットでは @tomayac という名前で活動しています。ご視聴ありがとうございました。Google I/O の残りのプログラムをお楽しみください。