Skip to content

Commit 5b4f030

Browse files
enf0rc3claude
andcommitted
Share one copy-button module between the heading and the code block
Both had their own copy of the same fifty-five lines: the revert timer, the tooltip swap, the live region, and the delegated click. The only thing that differed was the string each one copies. copy-button.js takes a selector and a function that reads the text, so a caller is left with just that function. A button's own data-tooltip is its resting label, which keeps "Copy URL" on the heading and "Copy to clipboard" on the code block, and the two share one live region instead of one each. headers.js goes from 125 lines to 52, code-blocks.js from 327 to 256. copy-markdown.js stays as it is. It fetches the page over the network before writing, so it needs the execCommand fallback and cannot read its text synchronously, which is what keeps the clipboard write inside Safari's user activation. The heading button had no test. It has three now, covering both callers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5675661 commit 5b4f030

6 files changed

Lines changed: 189 additions & 163 deletions

File tree

src/plugins/shiki-code-block.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Wraps every highlighted block in the code block shell at build time, so the
22
// frame, header, label and language are on the page before any script runs.
3-
// code-blocks.js adds the behaviour: copying, collapsing, and folding a
3+
// code-blocks.js wires up what happens next: copying, collapsing, and folding a
44
// <details data-group> set into one block with a language menu.
55

66
const REST = 'Copy to clipboard';

src/scripts/modules/code-blocks.js

Lines changed: 12 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,13 @@
11
// @ts-check
22
import { qs, qsa } from './query.js';
3+
import { copyOnClick } from './copy-button.js';
34

4-
// The shell around each block is rendered at build time by
5-
// src/plugins/shiki-code-block.js. This adds the behaviour.
6-
7-
const REVERT_MS = 2000;
8-
9-
const REST = 'Copy to clipboard';
10-
const COPIED = 'Copied';
11-
const FAILED = 'Copy failed';
5+
// The shell around each block, its copy button included, is rendered at build
6+
// time by src/plugins/shiki-code-block.js. This wires up what happens next.
127

138
/** Taller than this and the block collapses until it is clicked. */
149
const COLLAPSE_HEIGHT = 500;
1510

16-
/** @type {WeakMap<HTMLElement, ReturnType<typeof setTimeout>>} */
17-
const timers = new WeakMap();
18-
19-
/** @type {HTMLElement | null} */
20-
let status = null;
21-
2211
/**
2312
* @param {string} tag
2413
* @param {string} className
@@ -31,77 +20,17 @@ function el(tag, className, text) {
3120
return node;
3221
}
3322

34-
/* Copying ---------------------------------------------------------------- */
35-
36-
/**
37-
* @param {HTMLElement} button
38-
*/
39-
async function copyCode(button) {
40-
const block = button.closest('.code-block');
41-
const code = block?.querySelector('.code-block__panel:not([hidden]) code');
42-
if (!code) return;
43-
44-
let message = COPIED;
45-
try {
46-
// textContent because a collapsed block clips its last lines, and innerText
47-
// returns only what is on screen.
48-
// Nothing may be awaited before this: Safari spends the click's user
49-
// activation on the first await, and the write then fails.
50-
await navigator.clipboard.writeText(code.textContent ?? '');
51-
} catch (error) {
52-
console.warn('[code-blocks] clipboard write failed', error);
53-
message = FAILED;
54-
}
55-
56-
showResult(button, message);
57-
announce(message);
58-
}
59-
6023
/**
6124
* @param {HTMLElement} button
62-
* @param {string} message
6325
*/
64-
function showResult(button, message) {
65-
button.dataset.tooltip = message;
66-
button.dataset.copied = '';
67-
68-
clearTimeout(timers.get(button));
69-
timers.set(
70-
button,
71-
setTimeout(() => {
72-
button.dataset.tooltip = REST;
73-
delete button.dataset.copied;
74-
timers.delete(button);
75-
}, REVERT_MS)
76-
);
77-
}
78-
79-
/**
80-
* @param {string} message
81-
*/
82-
function announce(message) {
83-
if (!status) {
84-
status = el('div', 'code-block-status');
85-
status.setAttribute('aria-live', 'polite');
86-
document.body.append(status);
87-
}
88-
89-
// Cleared first, then set on a later task, so copying twice in a row reads as
90-
// a change and is announced both times. Same as copy-markdown.js.
91-
const region = status;
92-
region.textContent = '';
93-
setTimeout(() => {
94-
region.textContent = message;
95-
}, 50);
96-
}
97-
98-
function addCopyListener() {
99-
document.addEventListener('click', (event) => {
100-
if (!(event.target instanceof Element)) return;
101-
102-
const button = event.target.closest('.code-block__copy');
103-
if (button instanceof HTMLElement) copyCode(button);
104-
});
26+
function visibleCode(button) {
27+
const code = button
28+
.closest('.code-block')
29+
?.querySelector('.code-block__panel:not([hidden]) code');
30+
31+
// textContent because a collapsed block clips its last lines, and innerText
32+
// returns only what is on screen.
33+
return code?.textContent ?? null;
10534
}
10635

10736
/* Language menu ---------------------------------------------------------- */
@@ -319,7 +248,7 @@ function measureAll() {
319248

320249
function enhanceCodeBlocks() {
321250
enhanceGroups();
322-
addCopyListener();
251+
copyOnClick('.code-block__copy', visibleCode);
323252
addCollapseListeners();
324253
measureAll();
325254
}

src/scripts/modules/copy-button.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// @ts-check
2+
3+
// Shared by the heading copy-URL button and the code block copy button. Both
4+
// swap their tooltip to a result, revert after a beat, and announce it.
5+
6+
const REVERT_MS = 2000;
7+
8+
const COPIED = 'Copied';
9+
const FAILED = 'Copy failed';
10+
11+
/** @type {WeakMap<HTMLElement, ReturnType<typeof setTimeout>>} */
12+
const timers = new WeakMap();
13+
14+
/** @type {WeakMap<HTMLElement, string>} */
15+
const restLabels = new WeakMap();
16+
17+
/** @type {HTMLElement | null} */
18+
let status = null;
19+
20+
/**
21+
* A button's own data-tooltip is its resting label, captured before the first
22+
* result overwrites it.
23+
*
24+
* @param {HTMLElement} button
25+
*/
26+
function restLabel(button) {
27+
if (!restLabels.has(button)) {
28+
restLabels.set(button, button.dataset.tooltip ?? '');
29+
}
30+
return restLabels.get(button) ?? '';
31+
}
32+
33+
/**
34+
* @param {HTMLElement} button
35+
* @param {string} message
36+
*/
37+
function showResult(button, message) {
38+
const rest = restLabel(button);
39+
40+
button.dataset.tooltip = message;
41+
button.dataset.copied = '';
42+
43+
clearTimeout(timers.get(button));
44+
timers.set(
45+
button,
46+
setTimeout(() => {
47+
button.dataset.tooltip = rest;
48+
delete button.dataset.copied;
49+
timers.delete(button);
50+
}, REVERT_MS)
51+
);
52+
}
53+
54+
/**
55+
* One region for the whole page, on the body so it cannot land inside a
56+
* heading's accessible name.
57+
*
58+
* @param {string} message
59+
*/
60+
function announce(message) {
61+
if (!status) {
62+
status = document.createElement('div');
63+
status.className = 'copy-status';
64+
status.setAttribute('aria-live', 'polite');
65+
document.body.append(status);
66+
}
67+
68+
// Cleared first, then set on a later task, so copying twice in a row reads as
69+
// a change and is announced both times. Same as copy-markdown.js.
70+
const region = status;
71+
region.textContent = '';
72+
setTimeout(() => {
73+
region.textContent = message;
74+
}, 50);
75+
}
76+
77+
/**
78+
* Delegated, so it covers buttons that are rendered at build time as well as
79+
* ones a module adds later.
80+
*
81+
* @param {string} selector
82+
* @param {(button: HTMLElement) => string | null} read the text to copy. Must
83+
* return synchronously: Safari spends the click's user activation on the
84+
* first await, and the clipboard write then fails.
85+
*/
86+
function copyOnClick(selector, read) {
87+
document.addEventListener('click', async (event) => {
88+
if (!(event.target instanceof Element)) return;
89+
90+
const button = event.target.closest(selector);
91+
if (!(button instanceof HTMLElement)) return;
92+
93+
const value = read(button);
94+
if (value === null) return;
95+
96+
let message = COPIED;
97+
try {
98+
await navigator.clipboard.writeText(value);
99+
} catch (error) {
100+
console.warn('[copy-button] clipboard write failed', error);
101+
message = FAILED;
102+
}
103+
104+
showResult(button, message);
105+
announce(message);
106+
});
107+
}
108+
109+
export { copyOnClick };

src/scripts/modules/headers.js

Lines changed: 5 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
11
// @ts-check
22
import { qsa } from './query.js';
3-
4-
const REVERT_MS = 2000;
3+
import { copyOnClick } from './copy-button.js';
54

65
const REST = 'Copy URL';
7-
const COPIED = 'Copied';
8-
const FAILED = 'Copy failed';
9-
10-
/** @type {HTMLElement | null} */
11-
let status = null;
12-
13-
/** @type {WeakMap<HTMLElement, ReturnType<typeof setTimeout>>} */
14-
const timers = new WeakMap();
156

167
/**
178
* Scoped to .page-content headings with an id: the feedback prompt and the
@@ -41,84 +32,21 @@ function addCopyButtons() {
4132
});
4233
}
4334

44-
function addCopyListener() {
45-
document.addEventListener('click', (event) => {
46-
if (!(event.target instanceof Element)) return;
47-
48-
const button = event.target.closest('.copy-heading-url');
49-
if (button instanceof HTMLElement) copyHeadingUrl(button);
50-
});
51-
}
52-
5335
/**
5436
* @param {HTMLElement} button
5537
*/
56-
async function copyHeadingUrl(button) {
38+
function headingUrl(button) {
5739
const id = button.closest('h2, h3, h4, h5, h6')?.id;
58-
if (!id) return;
40+
if (!id) return null;
5941

6042
const url = new URL(window.location.href);
6143
url.hash = id;
62-
63-
let message = COPIED;
64-
try {
65-
// Nothing may be awaited before this: Safari spends the click's user
66-
// activation on the first await, and the write then fails.
67-
await navigator.clipboard.writeText(url.toString());
68-
} catch (error) {
69-
console.warn('[headers] clipboard write failed', error);
70-
message = FAILED;
71-
}
72-
73-
showResult(button, message);
74-
announce(message);
75-
}
76-
77-
/**
78-
* @param {HTMLElement} button
79-
* @param {string} message
80-
*/
81-
function showResult(button, message) {
82-
button.dataset.tooltip = message;
83-
button.dataset.copied = '';
84-
85-
clearTimeout(timers.get(button));
86-
timers.set(
87-
button,
88-
setTimeout(() => {
89-
button.dataset.tooltip = REST;
90-
delete button.dataset.copied;
91-
timers.delete(button);
92-
}, REVERT_MS)
93-
);
94-
}
95-
96-
/**
97-
* The region is appended to the body rather than the heading, so it cannot end
98-
* up in a heading's accessible name.
99-
*
100-
* @param {string} message
101-
*/
102-
function announce(message) {
103-
if (!status) {
104-
status = document.createElement('div');
105-
status.className = 'copy-heading-url-status';
106-
status.setAttribute('aria-live', 'polite');
107-
document.body.append(status);
108-
}
109-
110-
// Cleared first, then set on a later task, so copying twice in a row reads as
111-
// a change and is announced both times. Same as copy-markdown.js.
112-
const region = status;
113-
region.textContent = '';
114-
setTimeout(() => {
115-
region.textContent = message;
116-
}, 50);
44+
return url.toString();
11745
}
11846

11947
function enhanceHeaders() {
12048
addCopyButtons();
121-
addCopyListener();
49+
copyOnClick('.copy-heading-url', headingUrl);
12250
}
12351

12452
export { enhanceHeaders };

src/styles/main.css

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1892,8 +1892,7 @@ html[data-theme='light'] .theme-switcher__icon--dark svg path {
18921892
/* Live regions for announcing the result of an action, such as copying a URL or
18931893
a code block. Read by screen readers, never shown. */
18941894
.octo-copy-md__sr-status,
1895-
.copy-heading-url-status,
1896-
.code-block-status {
1895+
.copy-status {
18971896
position: absolute;
18981897
width: 1px;
18991898
height: 1px;

0 commit comments

Comments
 (0)