自分のサイトでメモしたコードを後でコピーしたいと思うことがあります。
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の工夫
ボタンは右上に固定で配置します。
.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 にのみボタンを付けます。
動的にコードブロックが追加される場合には対応していませんが、通常の記事ページではこれで十分だと判断しました。
次に、コピー対象の文字列取得です。innerText と textContent の両方を試すことで、ブラウザやテスト環境の違いを吸収しています。
実際のブラウザでは 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の変化を監視する必要がありますが、パフォーマンスへの影響を考えると慎重になります。
- highlight.jsは、Webページ上のコードブロックに自動でシンタックスハイライトを適用するJavaScriptライブラリです。200以上のプログラミング言語に対応し、言語の自動検出機能も備えています。WordPressでは複数のプラグインが提供されています。 – highlight.js公式サイト
innerTextはCSSスタイルを考慮してレンダリングされたテキストを返すのに対し、textContentはDOM内のすべてのテキストをそのまま返します。innerTextは非表示要素を無視しますが、textContentは非表示要素も含めます。 – HTMLElement: innerText – MDN- Clipboard APIはセキュアコンテキスト(HTTPS)でのみ動作します。
document.execCommand('copy')は非推奨(deprecated)ですが、Clipboard APIが使えない環境での後方互換性のために残されています。 – Clipboard API – MDN - JestはFacebookが開発したJavaScriptテストフレームワークで、特にReactアプリケーションのテストに広く使われています。モック機能、スナップショットテスト、カバレッジレポートなどを標準で提供しています。 – Jest公式ドキュメント