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

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

Excalidraw を使うようになった経緯

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

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

1 月 15 日に Christopher が公開したブログ投稿は、Twitter で大きな注目を集めました。投稿は、次のような印象的な統計から始まっています。

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

開始からわずか 2 週間で、これは悪くありません。しかし、私の興味を本当に刺激したのは、投稿のさらに下の方に書かれていたことでした。Christopher は、今回は新しい試みとして、プルリクエストを送信したすべてのユーザーに無条件のコミット権限を付与したと書いています。ブログ投稿を読んだその日に、Excalidraw に File System Access API のサポートを追加し、誰かが提出した機能リクエストを修正するプルリクエストをアップロードしました。

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

pull リクエストは翌日にマージされ、それ以降はコミットへのフルアクセス権が付与されました。言うまでもなく、私は権力を濫用していません。また、これまでの 149 人の投稿者も同様です。

現在、Excalidraw は、オフライン サポート、美しいダークモード、そして File System Access API のおかげでファイルのオープンと保存が可能な、本格的なインストール可能なプログレッシブ ウェブアプリです。

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

Lipis が Excalidraw に多くの時間を費やしている理由

これで、私の「Excalidraw に出会った経緯」の話は終わりですが、Excalidraw の素晴らしい機能を紹介する前に、Panayiotis を紹介します。インターネットでは lipis として知られている Panayiotis Lipiridis は、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 をサポートしていないブラウザでは、保存操作ごとにダウンロードが行われるため、変更を行うたびにファイル名に連番が付いた複数のファイルがダウンロード フォルダに保存されます。ただし、このデメリットがあっても、ファイルを保存することはできます。

ファイルを開く

では、その秘訣とは何でしょうか?ファイル システム アクセス 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);
};

Excalidraw で使用する browser-fs-access という小さなライブラリから提供される fileOpen() 関数です。このライブラリは、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() に渡します。これは、上の 2 つの段落で説明したものです。ファイルを開く、保存する、上書き保存する、ドラッグする、ドロップするなど、ファイルに対してさまざまな操作を行うことができます。同僚の 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 フォルダのファイル アプリを開いています。2 つのファイルが表示されます。そのうちの 1 つは、untitled という名前とタイムスタンプが付いています。内容を確認するには、3 つのドットをクリックして共有し、表示されたオプションの 1 つである Excalidraw を選択します。アイコンをタップすると、ファイルに I/O ロゴのみが含まれていることがわかります。

非推奨の Electron バージョンの Lipis

ファイルに対してまだ説明していない操作の 1 つは、ダブルクリックです。通常、ファイルをダブルクリックすると、そのファイルの 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 ストアに公開できるようになりました。これは大きな変化です。

Excalidraw for Electron が非推奨になったのは、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"]
      }
    }
  ]
}

次のステップは、アプリケーションの起動時にファイルを処理することです。これは 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 を含む 1 要素の配列を navigator.clipboard.write() 関数に渡すことで、クリップボードに書き込みます。ClipboardItemクリップボード 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 が表示され、アイドル状態であることが示されます。

このブログをよくお読みいただいている方は、アイドル状態の検出は Project Fugu のコンテキストで取り組まれている初期段階の提案である Idle Detection API を通じて実現されるとお考えになるかもしれません。ネタバレ注意: 違います。Excalidraw にはこの API に基づく実装がありましたが、最終的にはポインタの移動とページの可視性を測定する、より従来のアプローチを採用することにしました。

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

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

Excalidraw の妨げになっているものについて

話は変わりますが、Excalidraw の妨げになっているウェブ プラットフォームの欠点について、lipis に最後の質問をしました。

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

lipis さんに完全に同意します。私もクラウドに住んでいます。この機能がすぐに実装されることを願っています。

タブ付きアプリケーション モード

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

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

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

結びの言葉

この機能やその他の機能の最新情報を入手するには、Fugu API トラッカーをご覧ください。ウェブをさらに発展させ、このプラットフォームでより多くのことができるようにしたいと考えています。Excalidraw のさらなる改善と、皆様が構築する素晴らしいアプリケーションに期待しています。excalidraw.com にアクセスして、作成を開始します。

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