Clipboard APIを使用して、リッチテキストコピーを実装する方法

リッチテキストをコピーできるボタン作ってって言われたけど、そんなん実装してるの見たこと無い。
できないでしょ。。

しんぺー

ぼくも出来ないだろうな〜と思いながら調べたらできるみたいです。
あまりサンプルや記事も少く実装するのに時間がかかったので、今回はより実務に近い形のサンプルを用意しました。

クリップボードにリッチテキストをコピーする

クリップボードにリッチテキストをコピーすることは可能です。

シンプルなサンプル

navigator.clipboardを使用して実装しています。

Copy Rich Textのボタンをクリックすると、クリップボードにリッチテキストとしてコピーできます。
貼り付けるとリッチテキストになります(リンク付きのテキスト)。

See the Pen Copy Rich Text by shimpei (@shimpei) on CodePen.

navigator.clipboardからdocument.execCommand メソッド(非推奨)に移行しました。

以前はdocument.execCommand メソッドを使用して実装していたようですが、このメソッドは非推奨になり、navigator.clipboardでの実装が推奨されています。

完成形のイメージ

下記は画像ですが、Codepenの方で左のアイコンをクリックするとリッチテキストでコピーされます。

WordPressに埋め込んだCodePenだとうまく動作しないことがあるので、CodePenで動作確認してみてください。

See the Pen CopyableRichText|ant-design by shimpei (@shimpei) on CodePen.

React、Next.js、Ant Designを使用したコードの例

簡単なサンプルや紹介記事はありましたが、いざ実務で使うとなるとうまく実装できずに困ったので、実務に近いかたちのサンプルを用意しました。
ちょっと実力不足で一つ一つ解説できないですが、おそらくこのコードを読み解こうとするような熱心なあなたであればきっと理解し、あなたの環境で実装できるかと思います。
僕はおよそこんな感じで実装しました。

blobのタイプをtype: 'text/html'にしています。これでクリップボードに指定したタイプでコピーできます。
blobはMIME タイプを指定できるので、多分画像とかもコピーできると思います。

Blob.type一覧

テキスト系:
text/plain: プレーンテキスト
text/html: HTML ドキュメント
text/css: CSS スタイルシート
text/javascript: JavaScript コード
application/json: JSON データ


画像系:
image/jpeg: JPEG 画像
image/png: PNG 画像
image/gif: GIF 画像
image/svg+xml: SVG ベクター画像


音声系:
audio/mpeg: MP3 オーディオ
audio/wav: WAV オーディオ
audio/ogg: Ogg Vorbis オーディオ


動画系:
video/mp4: MP4 動画
video/webm: WebM 動画
video/ogg: Ogg Theora 動画


アプリケーション系:
application/pdf: PDF ドキュメント
application/zip: ZIP アーカイブ
application/octet-stream: 未知のバイナリデータ

const { createRoot } = ReactDOM;
const { LinkOutlined, SnippetsOutlined, CheckOutlined } = icons;
const { Typography, Avatar, List, Radio, Space } = antd;
const { Paragraph, Text } = Typography;

const data = [
  {
    title: 'ページトップへ戻るボタンの作り方【JavaScript】',
    postUrl: "https://sinpe-pgm.com/pagetop-button-js/",
    description: "JavaScriptで作るにはどうしたらいいんだろう?"
  },
  {
    title: '【コピペOK】複数のモーダルを同じクラス名で設置する方法',
    postUrl: "https://sinpe-pgm.com/multiple-modals/",
    description: "複数のモーダルを実装するの難しい。これまで2~3個だったから#modal01、#modal02とかしていたけど、今回10個以上あるから全部にid割り振ってられないよ、、"
  },
  //  略...
];

const firstItem = data[0];
const url = firstItem.postUrl;
const name = firstItem.title;
const encodedUrl = encodeURI(url);

const handleCopyToClipboard = async (richText) => {
  const blob = new Blob([richText], { type: 'text/html' });
  const blobPlain = new Blob([richText], { type: 'text/plain' });

  const item = [new ClipboardItem({ 'text/html': blob, 'text/plain': blobPlain })];

  try {
    await navigator.clipboard.write(item);
    message.success('コピーされました');
  } catch (error) {
    console.error('Error copying rich text to clipboard:', error);
  }
};

const App = () => {
  const handleCopyToClipboardRichText = (postUrl, title) => {
    const richText = `<a href="${postUrl}" target="_blank">${title}</a>`;
    handleCopyToClipboard(richText);
  };

  const renderItem = (item, index) => (
    <List.Item>
      <List.Item.Meta
        avatar={<Avatar src={`https://api.dicebear.com/7.x/miniavs/svg?seed=${index}`} />}
        title={<a target="_blank" href={item.postUrl}>{item.title}</a>}
        description={item.description}
      />
      <div className="icon-wrap">
        <Paragraph
          className="icon"
          copyable={{
            text: item.postUrl,
            icon: [<LinkOutlined key="copy-icon" />, <CheckOutlined key="copied-icon" />],
            tooltips: ['テキスト', 'clicked!!'],
          }}
        ></Paragraph>
        <Paragraph
          className="icon"
          copyable={{
            icon: [<SnippetsOutlined key="copy-icon" />, <CheckOutlined key="copied-icon" />],
            tooltips: ['リッチテキスト', 'clicked!!'],
            onCopy: () => {
              handleCopyToClipboardRichText(item.postUrl, item.title);
            },
          }}
        ></Paragraph>
      </div>
    </List.Item>
  );

  return (
    <>
      <Typography.Title level={2}>リッチテキストをクリップボードにコピーするサンプル</Typography.Title>
      <Space
        direction="vertical"
        style={{
          marginBottom: '20px',
        }}
        size="middle"
      >
      </Space>
      <List
        pagination={{
          defaultPageSize: 5
        }}
        dataSource={data}
        renderItem={renderItem}
      />
    </>
  );
};

const ComponentDemo = App;

createRoot(mountNode).render(<ComponentDemo />);

URLが日本語の場合にURLエンコーディングする

URLに日本語が入っていたりしてエンコーディングしたい場合は下記のように実装します。

const encodedUrl = encodeURI(url);
しんぺー

うまく実装できたたつもりでもエラーはあります。
テスト段階では日本語にURLなんて使わないですが、実際に利用するとそういった変なURLがあったりして、そのせいでエラーになったりします。
URLのエンコードは簡単なので実装しておきましょう♪

最後に

解説は以上です。
説明は少なかったですがCodePenのサンプルは実践に近いものを作成しましたので、必要があれば読み解いてみてください。
最後までありがとうございました!