使用 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);
}

參考資料


sharkHead
written by
sharkHead

後端工程師, PHP 基金會每月 5 鎂小額贊助人 稍微擅長 PHP、Python 與 Google Search,偶爾寫寫 TypeScript 對於逗號後面必須加空格有著絕對的堅持