使用 TypeScript 寫一個複製程式碼的按鈕
前陣子突然想到,部落格文章中的程式碼區塊 (Code block) 還沒有按鍵複製的功能。
雖然程式碼區塊高亮工具 Prism.js 有提供複製程式碼的套件,不過感覺這個功能並不會太難寫 (菜雞的謎之自信),因此決定自己用 TypeScript 來寫一個複製按鍵,順便當作一次練習 TypeScript 的機會。
因為部落格文章的編輯是使用 CKEditor 5,其產出的程式碼區塊 HTML 如下。
<pre class="language-php" tabindex="0">
<code class="language-php">程式碼都在這</code>
</pre>
接下來,我們要在所有的 <pre>
標籤中新增一個複製按鈕,並監聽按鈕的 click 事件,當按鈕被點擊,會將 <code>
標籤的 innerText 複製到剪貼簿 (clipboard)。此外,我還希望按鈕可以有下方的效果。
- 固定 (fixed) 在程式碼區塊的右上角,不會隨著橫向卷軸移動自己的位置。
- 滑鼠在程式碼區塊時 (hover),按鈕才會出現。
因為部落格的 CSS 框架是使用 Tailwind CSS,因此按鈕的樣式就直接使用 Tailwind CSS 提供的 utility class name。
首先要讓按鈕可以 fixed 在程式碼區塊中,因為 fixed 預設是依照視窗 (viewport) 來設定位置,如果想要讓其依照父元素而不是視窗,需要讓父元素的 transform 樣式不為 none。
在下方的程式碼中,我們先幫每個 <pre>
標籤加上一個父元素 <div>
,這個父元素會使用兩個 Tailwind 提供的 class name, translate-x-0
與 group
。
translate-x-0
可以讓 transfrom 樣式不為 none,但實際上沒有任何效果。
group
可以用來搭配按鈕的 group-hover
,以此利用透明度變化可以讓按鈕在滑鼠移動至程式碼區塊時才顯現。
let preTags: HTMLCollectionOf<HTMLPreElement> = document.getElementsByTagName('pre');
// add copy button to all pre tags
for (let i = 0, preTagsLength = preTags.length; i < preTagsLength; i++) {
// to make the copy button fixed in the container, we wrap it in the container
let wrapper: HTMLDivElement = document.createElement('div');
// add 'translate-x-0' to make wrapper be a container
// make sure the copy button won't fixed in viewport but container
wrapper.classList.add('group', 'translate-x-0');
// set the wrapper as sibling of the pre tag
preTags[i].parentNode?.insertBefore(wrapper, preTags[i]);
// set element as child of wrapper
wrapper.appendChild(preTags[i]);
}
再來要新增按鈕,宣告一個 buttonClassList
陣列存放按鈕會使用到的 class name ,並設定一個 click 事件,當按鈕被點擊時,就會複製程式碼。
let preTags: HTMLCollectionOf<HTMLPreElement> = document.getElementsByTagName('pre');
// use Tailwind CSS class names
let buttonClassList: string[] = [
'fixed', 'top-2', 'right-2',
'h-8', 'w-8', 'flex', 'justify-center', 'items-center',
'text-gray-50', 'bg-blue-400', 'rounded-md', 'text-lg',
'hover:bg-blue-500', 'active:bg-blue-400',
'opacity-0', 'group-hover:opacity-100', 'transition-all', 'duration-200'
];
// add copy button to all pre tags
for (let i = 0, preTagsLength = preTags.length; i < preTagsLength; i++) {
// ... 可以忽略了
// create copy button
let copyButton: HTMLButtonElement = document.createElement('button');
copyButton.classList.add(...buttonClassList);
copyButton.innerHTML = '<i class="bi bi-clipboard"></i>';
// when copy button is clicked, copy code to clipboard
copyButton.addEventListener('click', function (this: HTMLButtonElement) {
let code = preTags[i].getElementsByTagName('code')[0];
// copy code to clipboard
let codeText: string = code.innerText;
navigator.clipboard.writeText(codeText)
.then(
() => console.log('Copied to clipboard'),
() => console.log('Failed to copy to clipboard')
);
// change button icon to "Copied!" for 2 seconds
this.innerHTML = '<i class="bi bi-clipboard-check"></i>';
setTimeout(function (this: HTMLButtonElement) {
this.innerHTML = '<i class="bi bi-clipboard"></i>';
}.bind(this), 2000);
});
wrapper.appendChild(copyButton);
}
此次程式碼有部分是使用 Github Copilot 自動產生,只要打好英文註解,Copilot 就會使用 AI 自動生成程式碼 (所以說英文真的很重要)。
在點擊按鍵複製程式碼這一段,Github Copliot 原本是幫我生成下方的程式碼。
// copy code to clipboard
let code = this.parentElement.getElementsByTagName('code')[0];
let codeText: string = code.innerText;
let textArea: HTMLTextAreaElement = document.createElement('textarea');
textArea.value = codeText;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
textArea.remove();
複製的方式是生成一個 <textarea>
標籤,將其 value 設定為 <code>
的 inner text,之後再進行選取並複製,複製之後就會將 <textarea>
標籤刪除。
複製程式碼用的是 document.execCommand('copy')
,雖然這個方式也可以成功運作,但這個方法將來有可能被捨棄。因此建議改為使用 Clipboard API。
因為安全性問題,瀏覽器要求必須掛上 https 才能使用 Clipboard API,localhost 域名則沒有此限制。
最後完整的程式碼如下。
let preTags: HTMLCollectionOf<HTMLPreElement> = document.getElementsByTagName('pre');
// use Tailwind CSS class names
let buttonClassList: string[] = [
'fixed', 'top-2', 'right-2',
'h-8', 'w-8', 'flex', 'justify-center', 'items-center',
'text-gray-50', 'bg-blue-400', 'rounded-md', 'text-lg',
'hover:bg-blue-500', 'active:bg-blue-400',
'opacity-0', 'group-hover:opacity-100', 'transition-all', 'duration-200'
];
// add copy button to all pre tags
for (let i = 0, preTagsLength = preTags.length; i < preTagsLength; i++) {
// to make the copy button fixed in the container, we wrap it in the container
let wrapper: HTMLDivElement = document.createElement('div');
// add 'translate-x-0' to make wrapper be a container
// make sure the copy button won't fixed in viewport but container
wrapper.classList.add('group', 'translate-x-0');
// set the wrapper as sibling of the pre tag
preTags[i].parentNode?.insertBefore(wrapper, preTags[i]);
// set element as child of wrapper
wrapper.appendChild(preTags[i]);
// create copy button
let copyButton: HTMLButtonElement = document.createElement('button');
copyButton.classList.add(...buttonClassList);
copyButton.innerHTML = '<i class="bi bi-clipboard"></i>';
// when copy button is clicked, copy code to clipboard
copyButton.addEventListener('click', function (this: HTMLButtonElement) {
let code = preTags[i].getElementsByTagName('code')[0];
// copy code to clipboard
let codeText: string = code.innerText;
navigator.clipboard.writeText(codeText)
.then(
() => console.log('Copied to clipboard'),
() => console.log('Failed to copy to clipboard')
);
// change button icon to "Copied!" for 2 seconds
this.innerHTML = '<i class="bi bi-clipboard-check"></i>';
setTimeout(function (this: HTMLButtonElement) {
this.innerHTML = '<i class="bi bi-clipboard"></i>';
}.bind(this), 2000);
});
wrapper.appendChild(copyButton);
}