Skip to content

Commit d36cb1c

Browse files
committed
fix(docs[spa]): Fix prompt copy buttons surviving SPA navigation
why: Prompt copy buttons broke after SPA navigation due to three compounding issues: (1) addCopyButtons() only created buttons for code blocks, not prompt blocks, (2) it cloned an existing button as template which failed when landing on pages without code blocks (e.g. index.html), (3) prompt-copy.js used direct event binding destroyed by DOM swaps. what: - spa-nav.js: Replace clone-based button creation with inline HTML template matching sphinx-copybutton's exact output (SVG + classes) - spa-nav.js: Add prompt block button creation alongside code blocks - prompt-copy.js: Switch from direct binding to document-level event delegation (capture phase) — survives DOM swaps - prompt-copy.js: Detect prompt buttons by checking data-clipboard-target points into .admonition.prompt, not by button DOM ancestry (buttons are siblings, not descendants, of the prompt div) - prompt-copy.js: Match sphinx-copybutton's exact success feedback (green checkmark SVG swap + staggered tooltip/class timeouts) - Add console.warn for error paths instead of silent returns
1 parent ee213d1 commit d36cb1c

2 files changed

Lines changed: 116 additions & 52 deletions

File tree

docs/_static/js/prompt-copy.js

Lines changed: 70 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,19 @@
44
* sphinx-copybutton uses innerText which strips HTML. This script
55
* intercepts copy on .admonition.prompt buttons and reconstructs
66
* inline markdown (backtick-wrapping <code> elements) before copying.
7+
*
8+
* Uses event delegation on document (capture phase) so it survives
9+
* SPA navigation DOM swaps without re-initialization.
710
*/
811
(function () {
12+
// Same green checkmark SVG that sphinx-copybutton uses (copybutton.js:61-65)
13+
var iconCheck =
14+
'<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-check" width="44" height="44" viewBox="0 0 24 24" stroke-width="2" stroke="#22863a" fill="none" stroke-linecap="round" stroke-linejoin="round">' +
15+
"<title>Copied!</title>" +
16+
'<path stroke="none" d="M0 0h24v24H0z" fill="none"/>' +
17+
'<path d="M5 12l5 5l10 -10" />' +
18+
"</svg>";
19+
920
function toMarkdown(el) {
1021
var text = "";
1122
for (var i = 0; i < el.childNodes.length; i++) {
@@ -23,41 +34,65 @@
2334
return text;
2435
}
2536

26-
function init() {
27-
var buttons = document.querySelectorAll(
28-
"div.admonition.prompt button.copybtn"
29-
);
30-
buttons.forEach(function (btn) {
31-
btn.addEventListener(
32-
"click",
33-
function (e) {
34-
e.stopImmediatePropagation();
35-
e.preventDefault();
36-
37-
var targetId = btn.getAttribute("data-clipboard-target");
38-
var target = document.querySelector(targetId);
39-
if (!target) return;
40-
41-
var markdown = toMarkdown(target);
42-
navigator.clipboard.writeText(markdown).then(function () {
43-
btn.setAttribute("data-tooltip", "Copied!");
44-
btn.classList.add("success");
45-
setTimeout(function () {
46-
btn.setAttribute("data-tooltip", "Copy");
47-
btn.classList.remove("success");
48-
}, 2000);
49-
});
50-
},
51-
true
52-
);
53-
});
37+
// Match sphinx-copybutton's exact feedback:
38+
// icon swap + tooltip + .success class with staggered timeouts
39+
function showCopySuccess(btn) {
40+
var savedIcon = btn.innerHTML;
41+
btn.innerHTML = iconCheck;
42+
btn.setAttribute("data-tooltip", "Copied!");
43+
btn.classList.add("success");
44+
setTimeout(function () {
45+
btn.classList.remove("success");
46+
}, 1500);
47+
setTimeout(function () {
48+
btn.innerHTML = savedIcon;
49+
btn.setAttribute("data-tooltip", "Copy");
50+
}, 2000);
5451
}
5552

56-
if (document.readyState === "loading") {
57-
document.addEventListener("DOMContentLoaded", function () {
58-
setTimeout(init, 100);
59-
});
60-
} else {
61-
setTimeout(init, 100);
62-
}
53+
// Single delegated listener on document (capture phase).
54+
// Runs before ClipboardJS's bubble-phase delegation.
55+
// Detects prompt buttons by checking where their data-clipboard-target
56+
// points, not by button ancestry (buttons are siblings of the prompt
57+
// div, not descendants — inserted by insertAdjacentHTML('afterend')).
58+
document.addEventListener(
59+
"click",
60+
function (e) {
61+
var btn = e.target.closest(".copybtn");
62+
if (!btn) return;
63+
64+
var targetId = btn.getAttribute("data-clipboard-target");
65+
if (!targetId) {
66+
console.warn("prompt-copy: copybtn has no data-clipboard-target");
67+
return;
68+
}
69+
70+
var target = document.querySelector(targetId);
71+
if (!target) {
72+
console.warn("prompt-copy: target not found:", targetId);
73+
return;
74+
}
75+
76+
// Only intercept if the target is inside a prompt admonition
77+
if (!target.closest("div.admonition.prompt")) return;
78+
79+
e.stopImmediatePropagation();
80+
e.preventDefault();
81+
82+
var markdown = toMarkdown(target);
83+
navigator.clipboard.writeText(markdown).then(
84+
function () {
85+
showCopySuccess(btn);
86+
},
87+
function (err) {
88+
console.warn("prompt-copy: clipboard write failed:", err);
89+
btn.setAttribute("data-tooltip", "Failed to copy");
90+
setTimeout(function () {
91+
btn.setAttribute("data-tooltip", "Copy");
92+
}, 2000);
93+
}
94+
);
95+
},
96+
true
97+
);
6398
})();

docs/_static/js/spa-nav.js

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,27 +26,58 @@
2626
}
2727

2828
// --- Copy button injection ---
29-
30-
var copyBtnTemplate = null;
31-
32-
function captureCopyIcon() {
33-
var btn = document.querySelector(".copybtn");
34-
if (btn) copyBtnTemplate = btn.cloneNode(true);
29+
//
30+
// Matches sphinx-copybutton's button HTML and selector so buttons work
31+
// identically after SPA navigation. Uses an inline template instead of
32+
// cloning an existing button — the initial page may have no code blocks
33+
// (e.g. index.html), leaving nothing to clone.
34+
35+
var iconCopy =
36+
'<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-copy" ' +
37+
'width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="#000000" ' +
38+
'fill="none" stroke-linecap="round" stroke-linejoin="round">' +
39+
"<title>Copy to clipboard</title>" +
40+
'<path stroke="none" d="M0 0h24v24H0z" fill="none"/>' +
41+
'<rect x="8" y="8" width="12" height="12" rx="2" />' +
42+
'<path d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2" />' +
43+
"</svg>";
44+
45+
function makeCopyButton(targetId) {
46+
return (
47+
'<button class="copybtn o-tooltip--left" data-tooltip="Copy" ' +
48+
'data-clipboard-target="#' +
49+
targetId +
50+
'">' +
51+
iconCopy +
52+
"</button>"
53+
);
3554
}
3655

3756
function addCopyButtons() {
38-
if (!copyBtnTemplate) captureCopyIcon();
39-
if (!copyBtnTemplate) return;
57+
// Code blocks
4058
var cells = document.querySelectorAll("div.highlight pre");
4159
cells.forEach(function (cell, i) {
42-
cell.id = "codecell" + i;
60+
var id = "codecell" + i;
61+
cell.id = id;
4362
var next = cell.nextElementSibling;
4463
if (next && next.classList.contains("copybtn")) {
45-
next.setAttribute("data-clipboard-target", "#codecell" + i);
64+
next.setAttribute("data-clipboard-target", "#" + id);
65+
} else {
66+
cell.insertAdjacentHTML("afterend", makeCopyButton(id));
67+
}
68+
});
69+
// Prompt blocks — match sphinx-copybutton's copybutton_selector
70+
var prompts = document.querySelectorAll(
71+
"div.admonition.prompt > p:last-child"
72+
);
73+
prompts.forEach(function (p, i) {
74+
var id = "promptcell" + i;
75+
p.id = id;
76+
var next = p.nextElementSibling;
77+
if (next && next.classList.contains("copybtn")) {
78+
next.setAttribute("data-clipboard-target", "#" + id);
4679
} else {
47-
var btn = copyBtnTemplate.cloneNode(true);
48-
btn.setAttribute("data-clipboard-target", "#codecell" + i);
49-
cell.insertAdjacentElement("afterend", btn);
80+
p.insertAdjacentHTML("afterend", makeCopyButton(id));
5081
}
5182
});
5283
}
@@ -247,8 +278,6 @@
247278

248279
// --- Init ---
249280

250-
// Copy buttons are injected by copybutton.js on DOMContentLoaded.
251-
// This defer script runs before DOMContentLoaded, so our handler
252-
// fires after copybutton's handler (registration order preserved).
253-
document.addEventListener("DOMContentLoaded", captureCopyIcon);
281+
// No DOMContentLoaded setup needed for copy buttons — addCopyButtons()
282+
// creates buttons from an inline template, independent of the initial page.
254283
})();

0 commit comments

Comments
 (0)