Xに画像とテキストを一緒に渡すシェアボタンの作り方【Web Share API】

Web Share APIで画像とテキストをまとめてOSの共有メニューへ渡す仕組み ウェブ制作

WebサイトやWebアプリで、プレビューに表示した画像を、テキストと一緒にXへシェアできるようにしたいことはありませんか。

今回は、シェアボタンから画像とテキストをまとめて渡す方法を、JavaScriptのコード付きで紹介します。

Web Share APIのnavigator.share()に、画像のFileと本文を渡します。 生成した画像を端末に保存して選び直す手間を省き、OSの共有メニューへ渡せます。

共有メニューでXを選ぶと、対応する環境では画像と本文をXへ渡せます。Next.jsやSSRは不要で、ブラウザのJavaScriptだけで実装できます。

// pngBlobは作成済みのPNG。共有ボタンのクリック処理内で実行します。
const file = new File([pngBlob], 'result.png', {
  type: 'image/png',
});

await navigator.share({
  files: [file],
  text: '12345点でした!\nhttps://yuua-drop.push.tokyo/',
});

これは共有処理の中心部分です。実際には画像を先に作り、画像付き共有への対応を確認してから呼び出します。

実装手順

1. 共有画像をPNGのBlobに変換する

CanvasやHTMLの画像化処理で、共有する画像を作ります。Canvasの場合は、描画を終えたcanvasからtoBlob()でPNGを取り出します。

// canvasは描画済みのCanvasです。
const pngBlob = await new Promise((resolve, reject) => {
  canvas.toBlob(blob => {
    if (blob) resolve(blob);
    else reject(new Error('PNGの生成に失敗しました'));
  }, 'image/png');
});

Blobは画像データ本体です。冒頭のコードのようにFileへ変換すると、ファイル名と形式を付けて共有できます。Canvasの画像書き出しの仕様

2. 共有ボタンを押す前に画像を作っておく

プレビューを開いた時点で画像を生成し、BlobやFileを保持しておきます。画像ができたらプレビューを表示して、共有ボタンを有効にします。

navigator.share()には、クリックやタップによる一時的な操作権限が必要です。画像生成や通信を長く待つと、その権限が切れる場合があります。共有ボタンでは、準備済みのFileを取り出してすぐに渡します。WebKitによる操作権限の解説

3. 画像付き共有への対応を確認する

const canShareImage =
  typeof navigator.share === 'function' &&
  typeof navigator.canShare === 'function' &&
  navigator.canShare({ files: [file] });

navigator.shareがあっても、画像ファイルを共有できるとは限りません。実際に渡すFileを指定して、navigator.canShare()で確認します。web.devの実装例

対応していれば、共有ボタンのクリック処理から冒頭のnavigator.share()を呼びます。非対応の場合は、プレビュー画像の保存と、Xの投稿画面へのリンクを案内します。

Xの投稿リンクとの違い

方法画像の渡し方ユーザーの操作
Web Share API画像ファイルと本文をOSへ渡す共有メニューでXを選ぶ
XのWeb Intent本文とURLを渡す。画像の添付処理は含まない保存した画像を手動で添付する

今回の方法は、Xの投稿リンクに画像添付用のパラメータを付けるものではありません。OSの共有機能を通じてアプリへ渡しています。URLのプレビューに表示されるカード画像と、投稿に添付する画像ファイルも別のものです。Web Intentによる本文とURLの共有

ゆうあどろっぷでの実装

ゆうあどろっぷでは、ゲームオーバー後の共有モーダルで次の処理を行っています。

  1. モーダルを開くと、1080×1350pxのCanvasに背景・キャラクター・スコアを描く。
  2. PNGのBlobを作り、メモリに保持する。
  3. URL.createObjectURL()でプレビュー画像を表示する。
  4. 「シェアする」を押したら、保持していたBlobをFileに変換して、本文と一緒に共有する。

HTMLの表示領域を撮影しているのではなく、共有画像をCanvasに描いています。「ピーク時の盤面」も、記録した座標・レベル・角度から描き直しています。完成画像をサーバーで生成・アップロードする処理は、この共有経路にはありません。

PNGファイルを受け取ったmacOSの共有メニュー
macOSの共有メニュー。PNGファイルが渡っている状態です。

主な実装箇所はlib/share/shareCard.tsとcomponents/game/ResultTicket/SharePreviewModal.tsxです。モーダルで画像を作り、共有処理ではそのBlobを使い回しています。

Next.jsで使う場合

App Routerでは、共有UIを'use client'のClient Componentにします。Canvasの生成はuseEffectや操作イベント内で行い、navigator.share()は共有ボタンのクリックから呼びます。サーバーでの描画中やファイルの先頭では、documentやnavigatorにアクセスしないようにします。Next.jsのClient Componentの説明

動作確認と注意点

  • HTTPSまたはlocalhostで動かします。スマートフォンからPCのLAN内IPへHTTPでアクセスする方法では、安全なコンテキストの条件を満たしません。
  • canShare()がtrueでも、Xが共有先に出ることや、画像と本文の両方を受け取ることは保証されません。対象の端末・ブラウザ・アプリで確認します。
  • AbortErrorは、キャンセルや共有先がない場合に返ります。そのあとにダウンロードやX起動を自動で続けないようにします。
  • share()の成功は、OSや共有先への受け渡しを意味します。Xへの投稿完了として扱わないようにします。

共有先ごとに受け取る項目や扱いは異なります。Web Share APIの仕様を確認し、非対応時の画像保存・手動添付も用意しておきます。

外部ドメインの画像をCanvasに描く場合は、配信元のCORS許可も必要です。許可がないと、toBlob()での書き出しがSecurityErrorになる場合があります。Canvasのセキュリティに関する仕様

以下のサンプルは、macOSのBraveでPNG生成、画像付き共有メニューの表示、共有を閉じた後の中止処理を確認しました。Xでの受け取りは環境によって異なるため、利用対象の実機でも確認してください。

動くサンプル

以下をshare-demo.htmlとして保存し、HTTPSまたはlocalhostの開発サーバーで開いてください。「プレビューを作る」→「画像とテキストを共有」の順に操作します。

HTML・JavaScriptのサンプル全体を表示
<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>画像とテキストの共有サンプル</title>
<style>
  body { font: 16px/1.7 system-ui; max-width: 640px; margin: 32px auto; padding: 16px; }
  img { display: block; max-width: 100%; height: auto; margin: 16px 0; }
  img[hidden], a[hidden] { display: none; }
  button, a { margin: 8px 12px 8px 0; }
</style>
<h1>画像とテキストを共有する</h1>
<button id="prepare" type="button">プレビューを作る</button>
<img id="preview" alt="共有するスコア画像" hidden>
<button id="share" type="button" disabled>画像とテキストを共有</button>
<a id="save" download="result.png" hidden>画像を保存</a>
<a id="intent" target="_blank" rel="noopener noreferrer">Xの投稿画面を開く(本文のみ)</a>
<p id="status" role="status">先にプレビューを作ってください。</p>
<script type="module">
const prepare = document.querySelector('#prepare');
const preview = document.querySelector('#preview');
const share = document.querySelector('#share');
const save = document.querySelector('#save');
const status = document.querySelector('#status');
const pageUrl = 'https://yuua-drop.push.tokyo/';
const text = '12345点でした! #ゆうあどろっぷ';
const params = new URLSearchParams({ text, url: pageUrl });
document.querySelector('#intent').href = `https://x.com/intent/tweet?${params}`;
let file = null;
let objectUrl = null;

prepare.addEventListener('click', async () => {
  prepare.disabled = share.disabled = true;
  status.textContent = '画像を作成しています。';
  try {
    const canvas = document.createElement('canvas');
    canvas.width = 1080;
    canvas.height = 1350;
    const ctx = canvas.getContext('2d');
    if (!ctx) throw new Error('Canvas 2D unavailable');
    ctx.fillStyle = '#27134f';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = '#fff';
    ctx.font = 'bold 72px sans-serif';
    ctx.fillText('SCORE 12345', 80, 220);
    const blob = await new Promise((resolve, reject) => {
      canvas.toBlob(b => b ? resolve(b) : reject(new Error('PNG failed')), 'image/png');
    });
    file = new File([blob], 'result.png', { type: 'image/png' });
    if (objectUrl) URL.revokeObjectURL(objectUrl);
    objectUrl = URL.createObjectURL(blob);
    preview.src = save.href = objectUrl;
    preview.hidden = save.hidden = false;
    canvas.width = canvas.height = 0;
    share.disabled = false;
    status.textContent = '画像を確認して、共有ボタンを押してください。';
  } catch (error) {
    console.error(error);
    status.textContent = '画像を作成できませんでした。もう一度お試しください。';
  } finally {
    prepare.disabled = false;
  }
});

share.addEventListener('click', async () => {
  if (!file) return;
  share.disabled = prepare.disabled = true;
  try {
    if (!navigator.share || !navigator.canShare?.({ files: [file] })) {
      status.textContent = '画像付き共有に対応していません。画像を保存して、投稿に添付してください。';
      return;
    }
    // このクリック処理では、share()の前に画像生成や通信をしない。
    await navigator.share({ files: [file], text: `${text}\n${pageUrl}` });
    status.textContent = '共有先で投稿内容を確認してください。';
  } catch (error) {
    if (error.name === 'AbortError') {
      status.textContent = '共有が中止されたか、共有先がありませんでした。';
      return;
    }
    console.error(error);
    status.textContent = '共有できませんでした。画像を保存して、投稿に添付できます。';
  } finally {
    share.disabled = prepare.disabled = false;
  }
});
window.addEventListener('pagehide', () => {
  if (objectUrl) URL.revokeObjectURL(objectUrl);
});
window.addEventListener('pageshow', event => {
  if (event.persisted && file) {
    objectUrl = URL.createObjectURL(file);
    preview.src = save.href = objectUrl;
  }
});
</script>
</html>

この方法を使っているゆうあどろっぷは、キャラクターを落として合体・進化させる、個人制作のブラウザパズルゲームです。非公式ファンゲームとして公開しています。ゲームオーバー後の共有モーダルで、実際の動きを確認できます。

タイトルとURLをコピーしました