
ClipboardItem + Blobでリッチテキストをコピーする実装と、HTTP環境の罠
はじめに
社内向けWebアプリで「LLMが生成したMarkdownテキストをワンクリックでコピーし、メールクライアントに書式付きで貼り付けたい」という要件がありました。
navigator.clipboard.writeText() ではプレーンテキストしかコピーできません。Outlookなどに貼り付けると **太字** や ### 見出し がそのまま記号として表示されてしまいます。
そこで ClipboardItem と Blob を使って text/html と text/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/html と text/plain の両方をクリップボードに格納する必要があります。
「text/html と text/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タイプとデータを視覚的に確認できます。

例えばこの記事の一部をコピーしてClipboard Inspectorに貼り付けると、text/plain と text/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-markdown と react-dom/server の renderToStaticMarkup を使って、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.clipboard が undefined になり、何も起きなくなります。
サイレント失敗の原因
当初の実装は以下のような構造でした。
// 当初の実装(問題あり)
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化が必須です。
判定フローのまとめ

動作確認
| 環境 | コピー方式 | 貼り付け結果 |
|---|---|---|
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/htmlとtext/plainを同時にクリップボードに書き込めるnavigator.clipboardAPI は Secure Context 専用。localhostはHTTPでもSecure Context扱いされる特例がある- この特例のせいで、開発環境では問題に気付けない。HTTP本番環境でサイレント失敗する
window.isSecureContextで事前判定し、HTTP環境ではdocument.execCommand("copy")にフォールバックするinnerTextはブラウザのレイアウトエンジンを利用して、HTMLからMarkdown記号を除去したクリーンなプレーンテキストを取得できる
Clipboard APIを使う際は、デプロイ先のプロトコルを必ず確認しましょう。特に社内ネットワークやVPN経由のHTTPアクセスは見落としがちです。





