Skip to content

Commit 3803330

Browse files
committed
Add copy buttons
1 parent a799520 commit 3803330

5 files changed

Lines changed: 243 additions & 20 deletions

File tree

src/_includes/layout.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ export default (
4848
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
4949
<meta name="theme-color" content="#0e0f0f" media="(prefers-color-scheme: dark)" />
5050
<link rel="icon" href="/favicon.ico" sizes="any" />
51-
<link rel="icon" href="/logo.svg" type="image/svg+xml" />
5251
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
5352
<link rel="manifest" href="/site.webmanifest" />
5453
<link rel="stylesheet" href="/reset.css" />

src/_includes/style/code-copy.scss

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
.code-copy-wrap {
2+
position: relative;
3+
max-width: 100%;
4+
margin: 1em 0;
5+
6+
.content & > pre {
7+
margin: 0;
8+
padding-top: 3.2rem;
9+
}
10+
}
11+
12+
.code-copy-button {
13+
display: inline-flex;
14+
position: absolute;
15+
z-index: 1;
16+
top: 0.55rem;
17+
right: 0.55rem;
18+
gap: 0.35rem;
19+
align-items: center;
20+
justify-content: center;
21+
min-width: 4.9rem;
22+
min-height: 1.9rem;
23+
padding: 0.35rem 0.55rem;
24+
border: 1px solid var(--border);
25+
border-radius: 0.45rem;
26+
background: var(--surface);
27+
color: var(--muted);
28+
cursor: pointer;
29+
font-family: inherit;
30+
font-size: 0.7rem;
31+
font-weight: 600;
32+
line-height: 1;
33+
white-space: nowrap;
34+
transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
35+
36+
&:hover {
37+
border-color: var(--kr);
38+
background: var(--db);
39+
color: var(--fg);
40+
}
41+
42+
&[data-copy-state="success"] {
43+
border-color: var(--kr);
44+
color: var(--kr);
45+
}
46+
}
47+
48+
.code-copy-icon {
49+
width: 0.85rem;
50+
height: 0.85rem;
51+
fill: none;
52+
stroke: currentColor;
53+
stroke-linecap: round;
54+
stroke-linejoin: round;
55+
stroke-width: 1.4;
56+
}
57+
58+
.code-copy-icon-check {
59+
display: none;
60+
}
61+
62+
[data-copy-state="success"] {
63+
.code-copy-icon-copy {
64+
display: none;
65+
}
66+
67+
.code-copy-icon-check {
68+
display: initial;
69+
}
70+
}
71+
72+
.code-copy-status {
73+
position: absolute;
74+
width: 1px;
75+
height: 1px;
76+
padding: 0;
77+
overflow: hidden;
78+
clip: rect(0, 0, 0, 0);
79+
clip-path: inset(50%);
80+
border: 0;
81+
white-space: nowrap;
82+
}
83+
84+
.code-group.code-copy-group {
85+
position: relative;
86+
87+
> .code-group-header {
88+
margin-right: 5.9rem;
89+
}
90+
91+
> .code-copy-button {
92+
top: 0.35rem;
93+
right: 0.45rem;
94+
}
95+
}
96+
97+
@media (prefers-reduced-motion: reduce) {
98+
.code-copy-button {
99+
transition: none;
100+
}
101+
}

src/static/logo.svg

Lines changed: 0 additions & 19 deletions
This file was deleted.

src/static/main.js

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,144 @@ document.addEventListener("click", (e) => {
3030
}
3131
}
3232
});
33+
34+
function initCodeCopyButtons() {
35+
const resetTimers = new WeakMap();
36+
37+
function codeText(source) {
38+
const copy = source.cloneNode(true);
39+
40+
copy.querySelectorAll("[data-copy-ignore]").forEach((element) => element.remove());
41+
if (source.matches(".language-shell")) {
42+
copy.querySelectorAll('span[style*="user-select: none"]').forEach((prompt) => prompt.remove());
43+
}
44+
45+
return copy.textContent.replace(/\r?\n$/, "");
46+
}
47+
48+
function fallbackCopy(text) {
49+
const textarea = document.createElement("textarea");
50+
const activeElement = document.activeElement;
51+
52+
textarea.value = text;
53+
textarea.readOnly = true;
54+
textarea.setAttribute("aria-hidden", "true");
55+
textarea.style.position = "fixed";
56+
textarea.style.opacity = "0";
57+
textarea.style.pointerEvents = "none";
58+
document.body.append(textarea);
59+
textarea.select();
60+
textarea.setSelectionRange(0, text.length);
61+
62+
let copied = false;
63+
try {
64+
copied = document.execCommand("copy");
65+
} finally {
66+
textarea.remove();
67+
if (activeElement instanceof HTMLElement) {
68+
activeElement.focus({ preventScroll: true });
69+
}
70+
}
71+
72+
if (!copied) throw new Error("The browser refused the copy command.");
73+
}
74+
75+
async function copyText(text) {
76+
if (globalThis.isSecureContext && navigator.clipboard?.writeText) {
77+
try {
78+
await navigator.clipboard.writeText(text);
79+
return;
80+
} catch {
81+
// The fallback also works when clipboard permission has been denied.
82+
}
83+
}
84+
85+
fallbackCopy(text);
86+
}
87+
88+
function makeCopyControl(getSource) {
89+
const button = document.createElement("button");
90+
const status = document.createElement("span");
91+
92+
button.type = "button";
93+
button.className = "code-copy-button";
94+
button.setAttribute("aria-label", "Copy code to clipboard");
95+
button.dataset.copyState = "idle";
96+
button.innerHTML = `
97+
<svg class="code-copy-icon" viewBox="0 0 16 16" aria-hidden="true">
98+
<path class="code-copy-icon-copy" d="M5.5 4.5v-2h8v8h-2m-9-5h8v8h-8z" />
99+
<path class="code-copy-icon-check" d="m3 8.5 3 3 7-7" />
100+
</svg>
101+
<span class="code-copy-label">Copy</span>
102+
`;
103+
104+
status.className = "code-copy-status";
105+
status.setAttribute("role", "status");
106+
status.setAttribute("aria-live", "polite");
107+
108+
button.addEventListener("click", async () => {
109+
const label = button.querySelector(".code-copy-label");
110+
const source = getSource();
111+
if (!label || !source) return;
112+
113+
const oldTimer = resetTimers.get(button);
114+
if (oldTimer) clearTimeout(oldTimer);
115+
button.dataset.copyState = "idle";
116+
label.textContent = "Copy";
117+
status.textContent = "";
118+
119+
try {
120+
await copyText(codeText(source));
121+
button.dataset.copyState = "success";
122+
label.textContent = "Copied";
123+
status.textContent = "Code copied to clipboard.";
124+
} catch {
125+
button.dataset.copyState = "error";
126+
label.textContent = "Try again";
127+
status.textContent = "Could not copy code to the clipboard.";
128+
}
129+
130+
resetTimers.set(
131+
button,
132+
setTimeout(() => {
133+
button.dataset.copyState = "idle";
134+
label.textContent = "Copy";
135+
status.textContent = "";
136+
}, 2000),
137+
);
138+
});
139+
140+
return { button, status };
141+
}
142+
143+
const codeGroups = new Set(document.querySelectorAll(".content .code-group"));
144+
for (const group of codeGroups) {
145+
if (group.classList.contains("code-copy-group")) continue;
146+
147+
const { button, status } = makeCopyControl(() => {
148+
const activeBlock = [...group.querySelectorAll(":scope > pre")].find((pre) => !pre.hidden);
149+
return activeBlock?.querySelector(":scope > code") || activeBlock;
150+
});
151+
152+
group.classList.add("code-copy-group");
153+
group.append(button, status);
154+
}
155+
156+
for (const pre of document.querySelectorAll(".content pre")) {
157+
if (pre.closest(".code-group") || pre.closest(".code-copy-wrap")) continue;
158+
159+
const source = pre.querySelector(":scope > code") || pre;
160+
const wrapper = document.createElement("div");
161+
const { button, status } = makeCopyControl(() => source);
162+
163+
wrapper.className = "code-copy-wrap";
164+
pre.before(wrapper);
165+
wrapper.append(pre, button, status);
166+
}
167+
}
168+
169+
if (document.readyState === "loading") {
170+
document.addEventListener("DOMContentLoaded", initCodeCopyButtons, { once: true });
171+
} else {
172+
initCodeCopyButtons();
173+
}

src/style.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
@use "style/walkthrough";
88

99
@use "style/code-group"; // !important
10+
@use "style/code-copy";
1011

1112
// misc
1213
ul li li {

0 commit comments

Comments
 (0)