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

どれほど発達した技術であっても、魔法と見分けがつかないほどです。理解できなければ。私は Google のデベロッパー リレーションズを担当している Thomas Steiner です。この Google I/O の講演では、新しい Fugu API のいくつかと、それらが Excalidraw PWA の主要なユーザー ジャーニーをどのように改善するかについて見ていきます。これらのアイデアからインスピレーションを得て、ご自身のアプリに適用できるようにしてください。

Excalidraw に至った経緯

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

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

1 月 15 日、Christopher はブログ投稿を公開し、私の方を含む Twitter で大きな注目を集めました。この投稿は、いくつかの驚くべき統計から始まりました。

  • ユニーク アクティブ ユーザー数 1.2 万人
  • GitHub で星 1,500 個
  • 26 人の投稿者

わずか 2 週間前に始まったプロジェクトでは、決して悪くありません。でも 本当に興味を引かれたのは この投稿の後半にありましたChristopher 氏は、今回、pull リクエストを獲得したすべての人に無条件の commit アクセス権を付与するという、新しいことを試したと書いています。ブログ投稿を読んだその日に、File System Access API のサポートを Excalidraw に追加する pull リクエストが届き、誰かが提出した機能リクエストを修正しました。

自分の PR を発表するツイートのスクリーンショット。

1 日後に pull リクエストが統合され、そこから完全な commit アクセス権が付与されました。言うまでもなく 私は自分の力を悪用したわけではなく149 組のコントリビューターのうち、これまでのところ誰も行っていません。

現在、Excalidraw はインストール可能な本格的なインストール可能なプログレッシブ ウェブアプリで、オフライン サポートと優れたダークモードを備えています。また、File System Access API を使用してファイルを開くことも保存することもできます。

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

リピスが語る、なぜ多くの時間を Excalidraw に費やしているのか

「Excalidraw の経緯」はこれで終わりですが Excalidraw の優れた機能を紹介する前にPanayiotis 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」を赤にしましょう。ここでもう一度 [Save] をクリックすると、以前と同じファイルに変更内容が保存されます。確認のため、キャンバスをクリアしてファイルを再度開いてください。このように 変更済みの赤と青のロゴが再び表示されています

ファイル操作

現在 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() の呼び出しです。この関数は、複数のファイルが選択されているかどうかに応じて、ファイルの配列または 1 つのファイルを返します。後は、ファイル オブジェクトにファイル ハンドルを設定して、再度取得できるようにします。

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 属性(目的のファイル名を値に、blob URL を 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 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 で実装されているもう一つのシステム統合に、Web Share Target API があります。これは、ファイルアプリの Downloads フォルダにあります。2 つのファイルが表示されます。1 つは、説明のない名前 untitled とタイムスタンプです。内容を確認するため、その他アイコンをクリックして共有すると、表示されるオプションの一つが Excalidraw です。このアイコンをタップすると、ファイルに再び I/O ロゴが含まれていることがわかります。

非推奨の Electron バージョンに対する Lipis

まだ説明していないファイルでできることの 1 つは、ファイルをダブルクリックすることです。ファイルをダブルクリックすると通常、そのファイルの MIME タイプに関連付けられたアプリが開きます。たとえば .docx の場合は Microsoft Word です。

Excalidraw には、このようなファイル形式の関連付けをサポートする Electron バージョンのアプリが以前存在していたため、.excalidraw ファイルをダブルクリックすると Excalidraw Electron アプリが開くようになりました。皆さんもご存じの Lipis は Excalidraw Electron の創設者であり、その廃止者でもあります。私は、Electron バージョンのサポートを終了可能だと感じた理由を彼に尋ねました。

当初から Electron アプリの要望が多かったのは、主にダブルクリックでファイルを開くことが目的だったからです。また、アプリをアプリストアで公開することも目指していました。それと並行して、代わりに PWA を作成するよう提案されたため、両方を行いました。ファイル システム アクセス、クリップボード アクセス、ファイル処理などの Project Fugu API を紹介しました。1 回クリックするだけで、Electron の余分な重みなしで、パソコンやモバイルにアプリをインストールできます。Electron バージョンのサポート終了は、ウェブアプリだけに集中し、可能な限り最良の PWA にするという簡単な決断でした。さらに、Google Play ストアと Microsoft ストアに PWA を公開できるようになりました。すごい!

Excalidraw for Electron のサポートは終了したのは Electron がまったく良くないからではなく、ウェブが十分に機能しているからだと言えます。いいね!

ファイル処理

私が「ウェブは十分に機能している」と言うのは、近日公開予定のファイル処理のような機能によるものです。

これは通常の macOS Big Sur インストールです。では Excalidraw ファイルを右クリックすると どうなるか見てみましょうインストール済みの PWA である Excalidraw で開くことができます。もちろん、ダブルクリックでも機能しますが、スクリーンキャストでデモを行うのはそれほど劇的ではありません。

その仕組みは?まず、アプリケーションが処理できるファイル形式をオペレーティング システムに認識させます。そのためには、ウェブアプリ マニフェストの file_handlers という新しいフィールドを使用します。値は、アクションと accept プロパティを持つオブジェクトの配列です。アクションによって、オペレーティング システムがアプリを起動する URL パスが決まります。受け入れるオブジェクトは、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 のもう 1 つの便利な機能は、クリップボードの統合です。描画全体または一部をクリップボードにコピーしたり、必要に応じて透かしを追加したりして、他のアプリに貼り付けることができます。ちなみに、これは Windows 95 のペイント アプリのウェブ バージョンです。

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

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

セッション URL の共有

Excalidraw にはコラボレーション モードがあることをご存じですか?異なるユーザーが同じドキュメントを 共同編集できます新しいセッションを開始するには、[ライブ コラボレーション] ボタンをクリックしてセッションを開始します。Excalidraw が組み込まれている Web Share API のおかげで、セッション URL を共同編集者と簡単に共有できます。

リアルタイムでのコラボレーション

Google Pixelbook、Google Pixel 3a スマートフォン、iPad Pro で Google I/O ロゴを使って、ローカルでコラボレーション セッションをシミュレートしました。あるデバイスで行った変更は 他のすべてのデバイスにも反映されます

カーソルの動きもわかります。Google Pixelbook のカーソルはトラックパッドで制御されるため、ゆっくりと動きます。ただし、指でタップしてこれらのデバイスを操作しているため、Google Pixel 3a のカーソルと iPad Pro のタブレットのカーソルは動きます。

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

リアルタイムのコラボレーションを改善するために、アイドル状態の検出システムも稼働しています。iPad Pro を使用すると、カーソルに緑色のドットが表示されます。別のブラウザタブまたはアプリに切り替えるとドットが黒くなります。また、Excalidraw アプリを開いているけれども何もしていないと、カーソルがアイドル状態になり、3 つの ZZ で示されます。

パブリケーションの熱心な読者は、アイドル状態の検出が 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 統合が多数見られました。ファイル システムファイル処理クリップボードウェブ共有ウェブ共有ターゲット。しかし、もう一つ重要なことがあります。これまでは一度に 1 つしか 編集できませんでしたが今はそうではありません。Excalidraw のタブ型アプリモードの 初期バージョンを初めてお使いいただけます次のように表示されます。

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

タブ付きアプリケーション モードは初期段階にあり、すべてが不変の段階にあるわけではありません。この機能の現状については、こちらの記事をご覧ください。

結び

この機能やその他の機能の最新情報については、Fugu API トラッカーをご覧ください。ウェブの進化と利便性の向上に 大きな期待を寄せています進化を続ける Excalidraw と皆さんが作成するすばらしいアプリのすべてを紹介しますexcalidraw.com で作成してみましょう。

本日お見せした API のいくつかが皆さんのアプリに登場するのを楽しみにしています。Tom と申します。Twitter やインターネットで @tomayac にお問い合わせください。ご視聴いただきありがとうございました。引き続き Google I/O をお楽しみください。