コードブロックにCopyボタンを付ける
WordPressプラグインを作った
(Chiilabo Code Block Copy)

自分のサイトでメモしたコードを後でコピーしたいと思うことがあります。
ChatGPTやClaudeのコードブロックには当たり前のようにCopyボタンが付いていて、それがとても便利です。

WordPressのGutenbergには標準でコードブロック(core/code)がありますが、コピーボタンは付いていません。
そこで、フロントエンドだけで動くシンプルなプラグインを作りました。

関連記事

1. なぜ作ったのか

既存のプラグインも探しましたが、自分の環境では少し重たかったり、機能が多すぎたりしました。
必要なのは「コードブロックにCopyボタンを付ける」だけです。

もう一つ、自分のサイトではhighlight.jsを使ってシンタックスハイライトを表示しています1
これと共存できることが前提でした。
highlight.jsはコードブロックのDOM構造を書き換えるため、その後にボタンを追加する必要があります。

2. 設計の考え方

プラグインの設計では、次の点を意識しました。

2.1. 既存の仕組みを壊さない

Gutenbergやhighlight.jsが作ったDOM構造には一切手を加えず、ボタンだけを追加する方針です。具体的には、pre.wp-block-code 要素を見つけたら、その中に新しい要素を挿入します。

function attachCopyButton(preEl) {
  const toolbar = document.createElement('div');
  toolbar.className = 'ccb-toolbar';
  
  const button = document.createElement('button');
  button.className = 'ccb-copy-button';
  button.textContent = 'Copy';
  
  toolbar.appendChild(button);
  preEl.appendChild(toolbar);
}
Code language: JavaScript (javascript)

ツールバー(ccb-toolbar)を作り、その中にボタンを配置します。こうすることで、既存のコード表示には影響を与えません。

2.2. コピー対象は「見たまま」

コピーする文字列は、ユーザーが画面で見ているそのままです。HTMLタグや装飾は含めません。

function getCopyText(preEl) {
  return preEl.innerText || preEl.textContent || '';
}
Code language: JavaScript (javascript)

innerText を優先的に使うのは、これがブラウザで実際に表示されている文字列を返すからです2
もし取得できなければ textContent にフォールバックします。

テスト環境では innerText が空になることがあったので、このフォールバックを入れました。

2.3. Clipboard APIとフォールバック

コピー処理には、モダンなClipboard APIを使います。

async function copyText(text) {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch (err) {
    return copyTextFallback(text);
  }
}
Code language: JavaScript (javascript)

ただし、Clipboard APIが使えない環境もあります。
HTTPSでないページや、古いブラウザです。
そういう場合は document.execCommand('copy') を使う古い方法にフォールバックします3

function copyTextFallback(text) {
  const textarea = document.createElement('textarea');
  textarea.value = text;
  textarea.style.position = 'fixed';
  textarea.style.opacity = '0';
  document.body.appendChild(textarea);
  textarea.select();
  
  let success = false;
  try {
    success = document.execCommand('copy');
  } catch (err) {
    success = false;
  }
  
  document.body.removeChild(textarea);
  return success;
}
Code language: JavaScript (javascript)

一時的に textarea を作ってコピーし、すぐに削除します。
見た目には何も変わりませんが、ユーザーの環境で確実に動くようにするための工夫です。

2.4. ボタンの状態管理

コピーボタンをクリックすると、ボタンの文字が Copy から Copied に変わります。
失敗したら Failed です。

function setButtonState(btn, state) {
  if (state === 'copied') {
    btn.textContent = 'Copied';
    btn.classList.add('ccb-copied');
    setTimeout(() => {
      btn.textContent = 'Copy';
      btn.classList.remove('ccb-copied');
    }, 2000);
  } else if (state === 'failed') {
    btn.textContent = 'Failed';
    btn.classList.add('ccb-failed');
    setTimeout(() => {
      btn.textContent = 'Copy';
      btn.classList.remove('ccb-failed');
    }, 2000);
  }
}
Code language: JavaScript (javascript)

2秒後に元に戻るようにしています。
これはユーザーに結果を伝えつつ、次のコピー操作に備えるためです。

3. CSSの工夫

ボタンは右上に固定で配置します。

CSSの工夫 pre.wp-block-code position: relative function example() { return “Hello”; } position: absolute Copy 右上固定配置 半透明背景 テーマと調和
.wp-block-code {
  position: relative;
}

.ccb-toolbar {
  position: absolute;
  top: 0;
  right: 0;
  padding: 4px;
}
Code language: CSS (css)

pre.wp-block-code を相対配置にして、その中でツールバーを絶対配置します。
こうすることで、コードブロックごとに独立したボタンが表示されます。

ボタンの背景は半透明のグレーにしました。

.ccb-copy-button {
  background: rgba(128, 128, 128, 0.2);
  border: 1px solid rgba(128, 128, 128, 0.3);
  border-radius: 4px;
  padding: 4px 8px;
  cursor: pointer;
}
Code language: CSS (css)

これにより、黒背景のコードブロックでも白背景のコードブロックでも、それなりに馴染みます。
完璧ではありませんが、テーマの色を壊さない程度には調和します。

3.1. 管理画面での設定

バージョン0.0.3から、管理画面で追加クラスを設定できるようにしました。

add_settings_field(
  'chiilabo_code_block_copy_toolbar_class',
  'ツールバーの追加クラス',
  function () {
    $options = chiilabo_code_block_copy_get_options();
    printf(
      '<input type="text" name="%1$s[toolbar_class]" value="%2$s" class="regular-text" />',
      esc_attr( CHII_CODE_BLOCK_COPY_OPTION_KEY ),
      esc_attr( $options['toolbar_class'] )
    );
  },
  'chiilabo_code_block_copy_settings',
  'chiilabo_code_block_copy_section'
);
Code language: PHP (php)

ここで指定したクラスは、JavaScriptに渡されて動的に追加されます。

const settings = window.CHII_CODE_BLOCK_COPY_SETTINGS || {};
if (settings.toolbarClass) {
  toolbar.className += ' ' + settings.toolbarClass;
}
Code language: JavaScript (javascript)

これにより、テーマ側で定義したスタイルを柔軟に適用できます。たとえば、ダークモード対応のクラスを追加したり、特定のデザインシステムに合わせたりすることが可能です。

4. テストの導入(Jest)

JavaScriptの挙動をテストするために、Jestを使いました4

test('should copy text from code block', async () => {
  const pre = document.createElement('pre');
  pre.className = 'wp-block-code';
  pre.textContent = 'const x = 1;';
  document.body.appendChild(pre);
  
  attachCopyButton(pre);
  const button = pre.querySelector('.ccb-copy-button');
  button.click();
  
  await flushPromises();
  
  expect(navigator.clipboard.writeText).toHaveBeenCalledWith('const x = 1;');
});
Code language: JavaScript (javascript)

flushPromises はPromiseの解決を待つためのヘルパー関数です。
最初はタイムアウトが発生しましたが、フェイクタイマー環境での実装を調整して解決しました。

4.1. 実装で工夫した点

プラグインの実装では、いくつかの細かい工夫をしています。

まず、ボタンを追加するタイミングです。
DOMContentLoadedイベントで起動し、そのタイミングで存在する pre.wp-block-code にのみボタンを付けます。
動的にコードブロックが追加される場合には対応していませんが、通常の記事ページではこれで十分だと判断しました。

次に、コピー対象の文字列取得です。
innerTexttextContent の両方を試すことで、ブラウザやテスト環境の違いを吸収しています。
実際のブラウザでは innerText が正しく動きますが、Jest環境では空になることがあったためです。

クラス名のサニタイズも入れました。

function chiilabo_code_block_copy_sanitize_class_list( $value ) {
  $value = sanitize_text_field( $value );
  $value = trim( $value );
  if ( $value === '' ) {
    return '';
  }

  $parts  = preg_split( '/\s+/', $value );
  $clean  = array();
  foreach ( $parts as $part ) {
    if ( preg_match( '/^[A-Za-z0-9_-]+$/', $part ) ) {
      $clean[] = $part;
    }
  }
  return implode( ' ', $clean );
}
Code language: PHP (php)

管理画面で入力された追加クラスは、英数字とハイフン、アンダースコアのみを許可します。
これにより、意図しないHTMLやJavaScriptが注入されるリスクを減らしています。

5. 残った課題

このプラグインにも限界があります。

たとえば、highlight.jsの拡張機能で行番号をDOM要素として追加している場合、その行番号もコピーされてしまいます。
innerText は表示されているすべてのテキストを取得するため、行番号が視覚的に表示されていればそれも含まれるからです。

これを回避するには、行番号が含まれないようにDOM構造を工夫するか、コピー対象を細かく制御する必要があります。ただし、そこまで複雑にすると「シンプル」という設計思想から外れるため、現状は割り切っています。

もう一つ、動的に追加されるコードブロックには対応していません。SPAのように、ページ遷移なしでコンテンツが追加される場合です。これに対応するには、MutationObserverを使ってDOMの変化を監視する必要がありますが、パフォーマンスへの影響を考えると慎重になります。

  1. highlight.jsは、Webページ上のコードブロックに自動でシンタックスハイライトを適用するJavaScriptライブラリです。200以上のプログラミング言語に対応し、言語の自動検出機能も備えています。WordPressでは複数のプラグインが提供されています。 – highlight.js公式サイト
  2. innerTextはCSSスタイルを考慮してレンダリングされたテキストを返すのに対し、textContentはDOM内のすべてのテキストをそのまま返します。innerTextは非表示要素を無視しますが、textContentは非表示要素も含めます。 – HTMLElement: innerText – MDN
  3. Clipboard APIはセキュアコンテキスト(HTTPS)でのみ動作します。document.execCommand('copy')は非推奨(deprecated)ですが、Clipboard APIが使えない環境での後方互換性のために残されています。 – Clipboard API – MDN
  4. JestはFacebookが開発したJavaScriptテストフレームワークで、特にReactアプリケーションのテストに広く使われています。モック機能、スナップショットテスト、カバレッジレポートなどを標準で提供しています。 – Jest公式ドキュメント