Skip to content

Commit ec0e30b

Browse files
committed
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
1 parent 1dbdd06 commit ec0e30b

File tree

1 file changed

+150
-41
lines changed

1 file changed

+150
-41
lines changed
+150-41
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,165 @@
1-
import {hugoEnvironment, i18n} from '@params';
2-
console.debug(`Environment: ${hugoEnvironment}`);
1+
import { hugoEnvironment, i18n } from '@params';
32

3+
// Constants
4+
const NOTIFICATION_DURATION = 2000; // milliseconds
5+
const DEBOUNCE_DELAY = 300; // milliseconds
6+
7+
// Debug mode based on environment
8+
const isDebugMode = hugoEnvironment === 'development';
9+
10+
/**
11+
* Debounce function to prevent rapid clicking
12+
* @param {Function} func - Function to debounce
13+
* @param {number} wait - Wait time in milliseconds
14+
* @returns {Function} Debounced function
15+
*/
16+
const debounce = (func, wait) => {
17+
let timeout;
18+
return function executedFunction(...args) {
19+
const later = () => {
20+
clearTimeout(timeout);
21+
func(...args);
22+
};
23+
clearTimeout(timeout);
24+
timeout = setTimeout(later, wait);
25+
};
26+
};
27+
28+
/**
29+
* Copies code to clipboard, excluding the copy button text
30+
* @param {HTMLElement} button - The copy button element
31+
* @param {HTMLElement} codeWrapper - The wrapper containing the code
32+
* @throws {Error} When clipboard operations fail
33+
*/
434
async function copyCodeToClipboard(button, codeWrapper) {
5-
const codeToCopy = codeWrapper.textContent;
35+
if (!button || !(button instanceof HTMLElement)) {
36+
throw new Error('Invalid button element');
37+
}
38+
if (!codeWrapper || !(codeWrapper instanceof HTMLElement)) {
39+
throw new Error('Invalid code wrapper element');
40+
}
41+
42+
// Clone the wrapper to avoid modifying the displayed content
43+
const tempWrapper = codeWrapper.cloneNode(true);
44+
45+
// Remove the copy button from the cloned wrapper
46+
const copyButton = tempWrapper.querySelector('.copy-button');
47+
if (copyButton) {
48+
copyButton.remove();
49+
}
50+
51+
const codeToCopy = tempWrapper.textContent?.trim() ?? '';
52+
53+
if (!codeToCopy) {
54+
throw new Error('No code content found to copy');
55+
}
56+
657
try {
7-
if ('clipboard' in navigator) {
8-
// Note: Clipboard API requires HTTPS or localhost
9-
await navigator.clipboard.writeText(codeToCopy);
10-
} else {
11-
console.error('Failed to copy. Dead browser.')
12-
}
13-
} catch (_) {
14-
console.error('Failed to copy. Check permissions...')
15-
} finally {
58+
await navigator.clipboard.writeText(codeToCopy);
1659
copiedNotification(button);
60+
isDebugMode && console.debug('Code copied successfully');
61+
} catch (err) {
62+
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
63+
console.error('Failed to copy:', errorMessage);
64+
button.innerHTML = i18n['copyFailed'] || 'Failed';
65+
setTimeout(() => {
66+
button.innerHTML = i18n['copy'];
67+
}, NOTIFICATION_DURATION);
68+
throw err; // Re-throw for potential error boundary handling
1769
}
1870
}
1971

72+
/**
73+
* Updates button text to show copied notification
74+
* @param {HTMLElement} copyBtn - The copy button element
75+
*/
2076
function copiedNotification(copyBtn) {
2177
copyBtn.innerHTML = i18n['copied'];
78+
copyBtn.disabled = true;
79+
copyBtn.classList.add('copied');
80+
2281
setTimeout(() => {
2382
copyBtn.innerHTML = i18n['copy'];
24-
}, 2000);
83+
copyBtn.disabled = false;
84+
copyBtn.classList.remove('copied');
85+
}, NOTIFICATION_DURATION);
2586
}
2687

27-
// Code block copy button
28-
window.addEventListener("DOMContentLoaded", () => {
29-
document.querySelectorAll('pre > code').forEach((codeblock) => {
30-
const container = codeblock.parentNode.parentNode;
88+
/**
89+
* Creates a copy button element
90+
* @returns {HTMLButtonElement} The created button
91+
*/
92+
function createCopyButton() {
93+
const copyBtn = document.createElement('button');
94+
copyBtn.classList.add('copy-button');
95+
copyBtn.innerHTML = i18n['copy'];
96+
copyBtn.setAttribute('aria-label', i18n['copyLabel'] || 'Copy code to clipboard');
97+
copyBtn.setAttribute('type', 'button'); // Explicit button type
98+
return copyBtn;
99+
}
31100

32-
// Create copy button
33-
const copyBtn = document.createElement('button');
34-
let classesToAdd = ['copy-button'];
35-
copyBtn.classList.add(...classesToAdd);
36-
copyBtn.innerHTML = i18n['copy'];
101+
/**
102+
* Gets the appropriate wrapper for a code block
103+
* @param {HTMLElement} codeblock - The code block element
104+
* @returns {HTMLElement} The wrapper element
105+
*/
106+
function getCodeWrapper(codeblock) {
107+
const container = codeblock.parentNode?.parentNode;
108+
if (!container) {
109+
throw new Error('Invalid code block structure');
110+
}
37111

38-
// There are 3 kinds of code block wrappers in Hugo, handle them all.
39-
let wrapper;
40-
if (container.classList.contains('highlight')) {
41-
// Parent when Hugo line numbers disabled
42-
wrapper = container;
43-
} else if (codeblock.parentNode.parentNode.parentNode.parentNode.parentNode.nodeName === 'TABLE') {
44-
// Parent when Hugo line numbers enabled
45-
wrapper = codeblock.parentNode.parentNode.parentNode.parentNode.parentNode;
46-
} else {
47-
// Parent when Hugo `highlight` class not applied to code block
48-
// Hugo only applies `highlight` class when a language is specified on the Markdown block
49-
// But we need the `highlight` style to be applied so that absolute button has relative block parent
50-
codeblock.parentElement.classList.add('highlight');
51-
wrapper = codeblock.parentNode;
52-
}
53-
copyBtn.addEventListener("click", () => copyCodeToClipboard(copyBtn, wrapper));
54-
wrapper.appendChild(copyBtn);
55-
});
56-
});
112+
if (container.classList.contains('highlight')) {
113+
return container;
114+
}
115+
116+
const tableWrapper = container.closest('table');
117+
if (tableWrapper) {
118+
return tableWrapper;
119+
}
120+
121+
const preElement = codeblock.parentElement;
122+
if (preElement) {
123+
preElement.classList.add('highlight');
124+
return preElement;
125+
}
126+
127+
throw new Error('Could not determine code wrapper');
128+
}
129+
130+
/**
131+
* Initializes copy buttons for all code blocks
132+
*/
133+
function initializeCodeCopyButtons() {
134+
try {
135+
const codeBlocks = document.querySelectorAll('pre > code');
136+
isDebugMode && console.debug(`Found ${codeBlocks.length} code blocks`);
137+
138+
codeBlocks.forEach((codeblock, index) => {
139+
try {
140+
const wrapper = getCodeWrapper(codeblock);
141+
const copyBtn = createCopyButton();
142+
143+
// Use debounced version of copy function
144+
const debouncedCopy = debounce(
145+
() => copyCodeToClipboard(copyBtn, wrapper),
146+
DEBOUNCE_DELAY
147+
);
148+
149+
copyBtn.addEventListener('click', debouncedCopy);
150+
wrapper.appendChild(copyBtn);
151+
} catch (err) {
152+
console.error(`Failed to initialize copy button for code block ${index}:`, err);
153+
}
154+
});
155+
} catch (err) {
156+
console.error('Failed to initialize code copy buttons:', err);
157+
}
158+
}
159+
160+
// Initialize when DOM is ready
161+
if (document.readyState === 'loading') {
162+
window.addEventListener('DOMContentLoaded', initializeCodeCopyButtons);
163+
} else {
164+
initializeCodeCopyButtons();
165+
}

0 commit comments

Comments
 (0)