ClipboardItem + Blobでリッチテキストをコピーする実装と、HTTP環境の罠

ClipboardItem + Blobでリッチテキストをコピーする実装と、HTTP環境の罠

ClipboardItemとBlobを使ってtext/htmlとtext/plainを同時にクリップボードに書き込むリッチテキストコピーの実装方法と、HTTP環境でnavigator.clipboardが動作しないSecure Contextの罠、およびexecCommandを使ったフォールバック実装を解説します。
2026.06.21

はじめに

社内向けWebアプリで「LLMが生成したMarkdownテキストをワンクリックでコピーし、メールクライアントに書式付きで貼り付けたい」という要件がありました。

navigator.clipboard.writeText() ではプレーンテキストしかコピーできません。Outlookなどに貼り付けると **太字**### 見出し がそのまま記号として表示されてしまいます。

そこで ClipboardItemBlob を使って text/htmltext/plain の両方をクリップボードに書き込む実装をしたのですが、本番環境(HTTP)にデプロイしたらコピーボタンが完全に無反応になるという罠にハマりました。

この記事では、Blobを使ったリッチテキストコピーの実装方法と、HTTP環境でのフォールバック対応をまとめます。

前提・環境

  • React 19 / Next.js 15(App Router)
  • ブラウザ: Chrome 130+, Firefox 130+, Edge 130+
  • 本番環境: 社内ネットワーク上のHTTPアクセス(http://10.x.x.x/

やりたいこと

Markdownテキストをコピーしたとき、貼り付け先に応じて適切な形式で貼り付けられるようにしたい。

貼り付け先 期待する挙動
Outlook / Teams 太字・見出し・リストなどの書式が反映される
メモ帳 / ターミナル Markdown記号(**, ###)が除去されたプレーンテキスト

つまり、1回のコピー操作で text/htmltext/plain の両方をクリップボードに格納する必要があります。

text/htmltext/plain の両方を格納する」と書きましたが、そもそもクリップボードにMIMEタイプという概念があることは意外と知られていません。

MIMEタイプ(Multipurpose Internet Mail Extensions)は、データの形式を示す識別子です。HTTPのレスポンスヘッダ Content-Type でよく見かけますが、OSのクリップボードも同じ仕組みでデータを管理しています。

クリップボードは単なるテキストの入れ物ではなく、複数のMIMEタイプをキーとしたデータの集合です。例えばWebページからテキストをコピーすると、クリップボードには以下のようなデータが同時に格納されます。

MIMEタイプ 内容
text/plain 書式なしのプレーンテキスト
text/html HTMLタグ付きのリッチテキスト

貼り付け先のアプリケーションが対応するMIMEタイプを選択して使います。Outlookは text/html を優先的に読み取り、メモ帳は text/plain を読み取ります。

Clipboard Inspector で実際に確認する

Clipboard Inspector を使うと、クリップボードに格納されているMIMEタイプとデータを視覚的に確認できます。

SCR-20260620-uenz

例えばこの記事の一部をコピーしてClipboard Inspectorに貼り付けると、text/plaintext/html の両方が格納されていることが確認できます。今回の実装では、これと同じことをプログラムから行います。

ClipboardItem + Blob によるリッチテキストコピー

なぜ Blob が必要なのか

navigator.clipboard.writeText() はプレーンテキスト専用です。複数のMIMEタイプを同時に書き込むには navigator.clipboard.write() を使い、ClipboardItem に各MIMEタイプの Blob を渡します。

await navigator.clipboard.write([
  new ClipboardItem({
    "text/plain": new Blob([plainText], { type: "text/plain" }),
    "text/html":  new Blob([htmlText],  { type: "text/html" }),
  }),
]);

ブラウザはクリップボードの各MIMEタイプをバイナリデータとして管理しています。Blob(Binary Large Object)はそのバイナリデータを表現するためのオブジェクトであり、ClipboardItem のコンストラクタが各MIMEタイプに対して Blob を要求する設計になっています。

Markdown → HTML → クリーンなプレーンテキスト

実装の流れは3ステップです。

Step 1: Markdown → HTML変換

react-markdownreact-dom/serverrenderToStaticMarkup を使って、MarkdownをセマンティックなHTMLに変換します。

import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { renderToStaticMarkup } from "react-dom/server";

const rendered = renderToStaticMarkup(
  React.createElement(ReactMarkdown, { remarkPlugins: [remarkGfm] }, markdown),
);
const htmlText = `<div style="font-family:sans-serif;line-height:1.6">${rendered}</div>`;

Step 2: HTMLからクリーンなプレーンテキストを抽出

text/plain 側には、Markdown記号を除去したテキストが欲しいです。ここで strip-markdown のようなライブラリを使う方法もありますが、ブラウザのレイアウトエンジンを活用するシンプルな方法があります。

const tempDiv = document.createElement("div");
tempDiv.innerHTML = htmlText;
tempDiv.style.cssText = "position:fixed;opacity:0;pointer-events:none";
document.body.appendChild(tempDiv);
const cleanText = tempDiv.innerText;
document.body.removeChild(tempDiv);

innerText はブラウザのレイアウト結果に基づいてテキストを返すため、<h1><strong> などのHTMLタグは自動的に除去され、ブロック要素の境界には適切な改行が挿入されます。DOMに追加するのは innerText がレイアウト情報を必要とするためです(textContent はレイアウト不要ですがブロック要素間の改行を無視します)。

Step 3: ClipboardItem で書き込み

await navigator.clipboard.write([
  new ClipboardItem({
    "text/plain": new Blob([cleanText], { type: "text/plain" }),
    "text/html":  new Blob([htmlText],  { type: "text/html" }),
  }),
]);

これでOutlookに貼り付ければ書式付き、メモ帳に貼り付ければプレーンテキスト、という挙動になります。

HTTP環境の罠

localhost では動くのに本番で動かない

開発環境(http://localhost:3000)では問題なく動作していたこの実装が、本番環境(http://10.x.x.x/)にデプロイしたところ、コピーボタンを押しても何も起きないという報告を受けました。

エラーメッセージも出ない。ボタンの見た目も変わらない。完全なサイレント失敗です。

原因: Secure Context

navigator.clipboard API は Secure Context でのみ利用可能です。Secure Contextとは、以下のいずれかを満たす環境です。

  • https:// でアクセスしている
  • localhost / 127.0.0.1 でアクセスしている

ここが罠でした。localhost はHTTPであってもSecure Contextとして扱われます。これはブラウザが開発者の利便性のために設けている特例です。

つまり開発者が http://localhost:3000 で動作確認している限り、navigator.clipboard は正常に動作し、HTTP環境での問題は発見できません。本番がIPアドレス直打ちの http://10.x.x.x/ でアクセスされる環境だと、navigator.clipboardundefined になり、何も起きなくなります。

サイレント失敗の原因

当初の実装は以下のような構造でした。

// 当初の実装(問題あり)
try {
  await navigator.clipboard.write([
    new ClipboardItem({ ... }),
  ]);
} catch {
  // ClipboardItem未対応ブラウザ向けフォールバック
  await navigator.clipboard.writeText(cleanText);
}

HTTP環境では navigator.clipboard 自体が undefined になるため、write() が例外を投げ、catch ブロックの writeText() も同様に例外を投げます。この2つ目の例外がさらに外側に伝播し、結果的にUI上では何のフィードバックもないまま失敗していました。

フォールバック実装

window.isSecureContext による分岐

window.isSecureContext は、現在のコンテキストがSecure Contextかどうかを返すboolean値です。これを使って、環境に応じた分岐を実装します。

async function handleCopy() {
  // MarkdownからHTML・プレーンテキストを準備(前述の処理)
  const htmlText = /* ... */;
  const cleanText = /* ... */;

  try {
    if (window.isSecureContext) {
      // HTTPS / localhost — モダンClipboard API
      try {
        await navigator.clipboard.write([
          new ClipboardItem({
            "text/plain": new Blob([cleanText], { type: "text/plain" }),
            "text/html":  new Blob([htmlText],  { type: "text/html" }),
          }),
        ]);
      } catch {
        // ClipboardItem未対応ブラウザ → プレーンテキストのみ
        await navigator.clipboard.writeText(cleanText);
      }
    } else {
      // HTTP環境 — execCommand フォールバック(プレーンテキストのみ)
      const ta = document.createElement("textarea");
      ta.value = cleanText;
      ta.style.cssText = "position:fixed;opacity:0;pointer-events:none";
      document.body.appendChild(ta);
      ta.focus();
      ta.select();
      document.execCommand("copy");
      document.body.removeChild(ta);
    }
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    setCopyError(msg);
    setTimeout(() => setCopyError(null), 3000);
  }
}

フォールバックのポイント

document.execCommand("copy") は非推奨(deprecated)ですが、全主要ブラウザで動作し続けており、非Secure Contextでクリップボードに書き込める唯一の手段です。

実装上の注意点。

  • textarea を使う。input では改行を含むテキストが正しく選択できない
  • position:fixed; opacity:0; pointer-events:none で非表示にする。display:none だと select() が効かない
  • focus()select()execCommand("copy") の順で呼ぶ
  • コピー後すぐにDOMから削除する

HTTP環境では text/html のコピーはできないため、プレーンテキストのみのコピーになります。これは仕様上の制限であり、書式付きコピーが必要な場合はHTTPS化が必須です。

判定フローのまとめ

clipboard-blob-rich-text-http-fallback-flow

動作確認

環境 コピー方式 貼り付け結果
https:// or localhost ClipboardItem + Blob Outlook: 書式あり / メモ帳: プレーンテキスト
https://(ClipboardItem未対応) writeText フォールバック プレーンテキストのみ
http://10.x.x.x/ execCommand フォールバック プレーンテキストのみ
execCommand も失敗 エラーメッセージ表示 ボタンが赤くなり3秒間エラー内容を表示

HTTP環境でのフォールバックを手元で確認するには、http://192.168.x.x:3000 のようにLAN IPでアクセスすれば非Secure Contextになります。

まとめ

  • ClipboardItem + Blob を使えば、text/htmltext/plain を同時にクリップボードに書き込める
  • navigator.clipboard API は Secure Context 専用localhost はHTTPでもSecure Context扱いされる特例がある
  • この特例のせいで、開発環境では問題に気付けない。HTTP本番環境でサイレント失敗する
  • window.isSecureContext で事前判定し、HTTP環境では document.execCommand("copy") にフォールバックする
  • innerText はブラウザのレイアウトエンジンを利用して、HTMLからMarkdown記号を除去したクリーンなプレーンテキストを取得できる

Clipboard APIを使う際は、デプロイ先のプロトコルを必ず確認しましょう。特に社内ネットワークやVPN経由のHTTPアクセスは見落としがちです。

参考

この記事をシェアする

関連記事