fix: code copy button

Fixes when copying a code block, the copy button was also copied, leading to copied text containing the word 'copy'.

Fix #3160
This commit is contained in:
George Cushen 2025-02-12 23:05:28 +00:00
commit ec0e30baa4

View file

@ -1,56 +1,165 @@
import {hugoEnvironment, i18n} from '@params'; import { hugoEnvironment, i18n } from '@params';
console.debug(`Environment: ${hugoEnvironment}`);
// Constants
const NOTIFICATION_DURATION = 2000; // milliseconds
const DEBOUNCE_DELAY = 300; // milliseconds
// Debug mode based on environment
const isDebugMode = hugoEnvironment === 'development';
/**
* Debounce function to prevent rapid clicking
* @param {Function} func - Function to debounce
* @param {number} wait - Wait time in milliseconds
* @returns {Function} Debounced function
*/
const debounce = (func, wait) => {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
/**
* Copies code to clipboard, excluding the copy button text
* @param {HTMLElement} button - The copy button element
* @param {HTMLElement} codeWrapper - The wrapper containing the code
* @throws {Error} When clipboard operations fail
*/
async function copyCodeToClipboard(button, codeWrapper) { async function copyCodeToClipboard(button, codeWrapper) {
const codeToCopy = codeWrapper.textContent; if (!button || !(button instanceof HTMLElement)) {
throw new Error('Invalid button element');
}
if (!codeWrapper || !(codeWrapper instanceof HTMLElement)) {
throw new Error('Invalid code wrapper element');
}
// Clone the wrapper to avoid modifying the displayed content
const tempWrapper = codeWrapper.cloneNode(true);
// Remove the copy button from the cloned wrapper
const copyButton = tempWrapper.querySelector('.copy-button');
if (copyButton) {
copyButton.remove();
}
const codeToCopy = tempWrapper.textContent?.trim() ?? '';
if (!codeToCopy) {
throw new Error('No code content found to copy');
}
try { try {
if ('clipboard' in navigator) { await navigator.clipboard.writeText(codeToCopy);
// Note: Clipboard API requires HTTPS or localhost
await navigator.clipboard.writeText(codeToCopy);
} else {
console.error('Failed to copy. Dead browser.')
}
} catch (_) {
console.error('Failed to copy. Check permissions...')
} finally {
copiedNotification(button); copiedNotification(button);
isDebugMode && console.debug('Code copied successfully');
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
console.error('Failed to copy:', errorMessage);
button.innerHTML = i18n['copyFailed'] || 'Failed';
setTimeout(() => {
button.innerHTML = i18n['copy'];
}, NOTIFICATION_DURATION);
throw err; // Re-throw for potential error boundary handling
} }
} }
/**
* Updates button text to show copied notification
* @param {HTMLElement} copyBtn - The copy button element
*/
function copiedNotification(copyBtn) { function copiedNotification(copyBtn) {
copyBtn.innerHTML = i18n['copied']; copyBtn.innerHTML = i18n['copied'];
copyBtn.disabled = true;
copyBtn.classList.add('copied');
setTimeout(() => { setTimeout(() => {
copyBtn.innerHTML = i18n['copy']; copyBtn.innerHTML = i18n['copy'];
}, 2000); copyBtn.disabled = false;
copyBtn.classList.remove('copied');
}, NOTIFICATION_DURATION);
} }
// Code block copy button /**
window.addEventListener("DOMContentLoaded", () => { * Creates a copy button element
document.querySelectorAll('pre > code').forEach((codeblock) => { * @returns {HTMLButtonElement} The created button
const container = codeblock.parentNode.parentNode; */
function createCopyButton() {
const copyBtn = document.createElement('button');
copyBtn.classList.add('copy-button');
copyBtn.innerHTML = i18n['copy'];
copyBtn.setAttribute('aria-label', i18n['copyLabel'] || 'Copy code to clipboard');
copyBtn.setAttribute('type', 'button'); // Explicit button type
return copyBtn;
}
// Create copy button /**
const copyBtn = document.createElement('button'); * Gets the appropriate wrapper for a code block
let classesToAdd = ['copy-button']; * @param {HTMLElement} codeblock - The code block element
copyBtn.classList.add(...classesToAdd); * @returns {HTMLElement} The wrapper element
copyBtn.innerHTML = i18n['copy']; */
function getCodeWrapper(codeblock) {
const container = codeblock.parentNode?.parentNode;
if (!container) {
throw new Error('Invalid code block structure');
}
// There are 3 kinds of code block wrappers in Hugo, handle them all. if (container.classList.contains('highlight')) {
let wrapper; return container;
if (container.classList.contains('highlight')) { }
// Parent when Hugo line numbers disabled
wrapper = container; const tableWrapper = container.closest('table');
} else if (codeblock.parentNode.parentNode.parentNode.parentNode.parentNode.nodeName === 'TABLE') { if (tableWrapper) {
// Parent when Hugo line numbers enabled return tableWrapper;
wrapper = codeblock.parentNode.parentNode.parentNode.parentNode.parentNode; }
} else {
// Parent when Hugo `highlight` class not applied to code block const preElement = codeblock.parentElement;
// Hugo only applies `highlight` class when a language is specified on the Markdown block if (preElement) {
// But we need the `highlight` style to be applied so that absolute button has relative block parent preElement.classList.add('highlight');
codeblock.parentElement.classList.add('highlight'); return preElement;
wrapper = codeblock.parentNode; }
}
copyBtn.addEventListener("click", () => copyCodeToClipboard(copyBtn, wrapper)); throw new Error('Could not determine code wrapper');
wrapper.appendChild(copyBtn); }
});
}); /**
* Initializes copy buttons for all code blocks
*/
function initializeCodeCopyButtons() {
try {
const codeBlocks = document.querySelectorAll('pre > code');
isDebugMode && console.debug(`Found ${codeBlocks.length} code blocks`);
codeBlocks.forEach((codeblock, index) => {
try {
const wrapper = getCodeWrapper(codeblock);
const copyBtn = createCopyButton();
// Use debounced version of copy function
const debouncedCopy = debounce(
() => copyCodeToClipboard(copyBtn, wrapper),
DEBOUNCE_DELAY
);
copyBtn.addEventListener('click', debouncedCopy);
wrapper.appendChild(copyBtn);
} catch (err) {
console.error(`Failed to initialize copy button for code block ${index}:`, err);
}
});
} catch (err) {
console.error('Failed to initialize code copy buttons:', err);
}
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', initializeCodeCopyButtons);
} else {
initializeCodeCopyButtons();
}