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

十分に発達した科学技術は、魔法と見分けがつかない。理解できなければ。Thomas Steiner と申します。Google のデベロッパー リレーションズに勤務しています。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 で大きな注目を集めたブログ投稿を投稿しました(私の投稿を含む)。この投稿では、

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

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

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

1 日後に pull リクエストがマージされ、そこから完全な commit アクセス権が手に入りました。私の力を悪用したわけでもないのにまた、これまでに 149 人のコントリビューターが確認しましたが、他の誰もこの問題に遭遇していません。

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

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

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

以上が「Excalidraw の開発に至った経緯」でした。Excalidraw の優れた機能について詳しく説明する前に、Panayiotis をご紹介します。Panayiotis Lipiridis はインターネット上では単に lipis と呼ばれており、Excalidraw に最も多くの貢献をしています。私は lipis に、エクスカリドローに多くの時間を費やす動機になった理由を尋ねました。

他の皆様と同じく、このプロジェクトについて知ったのは Christopher のツイートでした。私が最初に貢献したのは、Open Color ライブラリの追加でした。この色は現在も Excalidraw の一部です。プロジェクトが拡大し、リクエストも多数発生する中、私の次の取り組みは、図形描画を保存するためのバックエンドを構築して、ユーザーが図形描画を共有できるようにすることでした。私が貢献する動機となっているのは、Excalidraw を試した人が、もう一度使う口実を探していることです。

私はリピに全面的に同意します。Excalidraw を試した人は誰でも、もう一度使う言い訳を模索しています。

Excalidraw の使用例

Excalidraw の実際の使い方をご紹介します。私は優れたアーティストではありませんが、Google I/O のロゴはシンプルなので、試しに描いてみます。ボックスは“i”、線はスラッシュ、“o”は円です。Shift キーを押しながら操作すると、完璧な円が描画されます。スラッシュを少し動かして 見栄えを良くします「i」と「o」に色を付けます。青色は良好な状況を表しています。別の塗りつぶしスタイルに変更するなど、すべてソリッドか、クロスハッチか?いや、ハチュレはすごくいいね。完璧ではありませんが エクスカリドローのアイデアなので保存しておきます

保存アイコンをクリックし、ファイル保存ダイアログでファイル名を入力します。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 という小さなライブラリに含まれています。この関数は 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 タイプと拡張子のネゴシエーションが完了したら、次に、プログラムによって入力要素をクリックして、ファイルを開くダイアログが表示されるようにします。変更時、つまりユーザーが 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 フォルダに配置されます。

ドラッグ&ドロップ

デスクトップで利用できるシステム統合機能で、私が特に気に入っているのはドラッグ&ドロップです。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

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

Excalidraw には、このようなファイル形式の関連付けをサポートするアプリの Electron バージョンがありました。そのため、.excalidraw ファイルをダブルクリックすると、Excalidraw Electron アプリが開いていました。Lipis は Excalidraw Electron の作成者であり、廃止者でもあります。Electron バージョンを非推奨にできる理由について、彼に尋ねました。

Electron アプリを当初から求めていたのは、ファイルをダブルクリックして開いたかったためです。また、アプリストアにも公開する予定でした。同時に、代わりに PWA を作成する方法も提案されたため、両方の方法を試しました。幸いなことに ファイルシステムアクセス、クリップボード アクセス、ファイル処理などの Project Fugu API も紹介しました1 回クリックするだけで、Electron の負担をかけずにアプリをデスクトップまたはモバイルにインストールできます。そのため、Electron バージョンを非推奨にして、ウェブアプリに集中し、可能な限り優れた PWA にすることを決定しました。さらに、PWA を Google Play ストアと Microsoft Store に公開できるようになりました。すごい!

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 の Paint アプリのウェブ版です。

仕組みは驚くほど簡単です。必要なのは、blob としてのキャンバスだけです。この blob を含む 1 要素配列を blob とともに 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 で表されています。

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

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

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

エクスカリドローの妨げとなっている理由についてリピス

それについて、私は Excalidraw の妨げになっているウェブ プラットフォームに欠けていると思われるものについて、最後にもう 1 つ 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 の残りのセッションをお楽しみください。