Skip to content

Commit 01e2760

Browse files
authored
Custom code block copy button with animated green check (#37)
1 parent 53c18ab commit 01e2760

File tree

2 files changed

+202
-3
lines changed

2 files changed

+202
-3
lines changed

src/components/Head.astro

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,63 @@ const breadcrumbSchema = {
6060
)}
6161

6262
<Fragment set:html={`<script type="application/ld+json">${JSON.stringify(breadcrumbSchema)}</script>`} />
63+
64+
<!-- Custom code block copy button with animated green check -->
65+
<script>
66+
const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
67+
const checkIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="check-icon"><path d="M5 12l5 5L20 7"/></svg>`;
68+
69+
function initCodeBlocks() {
70+
// Custom copy buttons
71+
document.querySelectorAll('.expressive-code .copy button').forEach((button) => {
72+
if (button.dataset.customized) return;
73+
button.dataset.customized = 'true';
74+
75+
const code = button.dataset.code || '';
76+
77+
button.innerHTML = `
78+
<span class="copy-icon-wrapper">${copyIcon}</span>
79+
<span class="check-icon-wrapper">${checkIcon}</span>
80+
`;
81+
82+
button.addEventListener('click', async (e) => {
83+
e.preventDefault();
84+
e.stopPropagation();
85+
86+
try {
87+
await navigator.clipboard.writeText(code);
88+
button.classList.add('copied');
89+
setTimeout(() => button.classList.remove('copied'), 2000);
90+
} catch (err) {
91+
console.error('Failed to copy:', err);
92+
}
93+
}, { capture: true });
94+
});
95+
96+
// Mobile fade overlay - inject actual DOM element since pseudo-elements don't work with ExpressiveCode
97+
if (window.innerWidth <= 640) {
98+
document.querySelectorAll('.expressive-code .frame').forEach((frame) => {
99+
if (frame.querySelector('.code-fade-overlay')) return;
100+
101+
const overlay = document.createElement('div');
102+
overlay.className = 'code-fade-overlay';
103+
frame.appendChild(overlay);
104+
});
105+
}
106+
}
107+
108+
// Run on initial load and page transitions
109+
if (document.readyState === 'loading') {
110+
document.addEventListener('DOMContentLoaded', initCodeBlocks);
111+
} else {
112+
initCodeBlocks();
113+
}
114+
document.addEventListener('astro:page-load', initCodeBlocks);
115+
116+
// Re-check on resize for mobile overlay
117+
let resizeTimer;
118+
window.addEventListener('resize', () => {
119+
clearTimeout(resizeTimer);
120+
resizeTimer = setTimeout(initCodeBlocks, 100);
121+
});
122+
</script>

src/styles/custom.css

Lines changed: 142 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,9 +296,16 @@ pre[data-bash-prompt] > code {
296296
background: var(--sl-color-bg-sidebar);
297297
}
298298

299-
/* Dark mode - transparent sidebar to show green background */
300-
[data-theme='dark'] .sidebar-pane {
301-
background: transparent;
299+
/* Dark mode - transparent sidebar to show green background (desktop only) */
300+
@media (min-width: 50rem) {
301+
[data-theme='dark'] .sidebar-pane {
302+
background: transparent;
303+
}
304+
}
305+
306+
/* Mobile menu needs solid background when expanded */
307+
[data-theme='dark'] nav.sidebar[data-mobile-menu-expanded] {
308+
background: var(--sl-color-bg);
302309
}
303310

304311
.site-title {
@@ -311,6 +318,138 @@ pre[data-bash-prompt] > code {
311318
--ec-brdCol: var(--sl-color-hairline);
312319
}
313320

321+
/* Ensure frame has relative positioning for overlays */
322+
.expressive-code .frame {
323+
position: relative;
324+
}
325+
326+
/* Hide terminal header bar for CLI/bash blocks */
327+
.expressive-code .frame.is-terminal .header {
328+
display: none;
329+
}
330+
331+
.expressive-code .frame.is-terminal pre {
332+
border-top: var(--ec-brdWd) solid var(--ec-brdCol) !important;
333+
border-top-left-radius: calc(var(--ec-brdRad) + var(--ec-brdWd));
334+
border-top-right-radius: calc(var(--ec-brdRad) + var(--ec-brdWd));
335+
}
336+
337+
/* Reposition copy button for terminal blocks without header */
338+
.expressive-code .frame.is-terminal .copy {
339+
inset-block-start: calc(var(--ec-brdWd) + 0.4rem);
340+
}
341+
342+
/* Fade overlay - hidden by default, shown on mobile */
343+
.expressive-code .code-fade-overlay {
344+
display: none;
345+
}
346+
347+
/* Mobile: smaller copy button and fade overlay on code */
348+
@media (max-width: 640px) {
349+
/* Always show copy button on mobile (no hover state) */
350+
.expressive-code .copy {
351+
z-index: 2; /* Above fade overlay */
352+
}
353+
354+
.expressive-code .copy button {
355+
width: 2rem;
356+
height: 2rem;
357+
opacity: 1 !important;
358+
}
359+
360+
.expressive-code .copy button svg {
361+
width: 14px;
362+
height: 14px;
363+
}
364+
365+
/* Fade overlay - only first line height */
366+
.expressive-code .code-fade-overlay {
367+
display: block;
368+
position: absolute;
369+
top: 0;
370+
right: 0;
371+
height: 2.5rem; /* Approximately first line height */
372+
width: 8rem;
373+
background: linear-gradient(
374+
to right,
375+
transparent 0px,
376+
color-mix(in srgb, var(--code-background) 40%, transparent) 16px,
377+
color-mix(in srgb, var(--code-background) 70%, transparent) 32px,
378+
color-mix(in srgb, var(--code-background) 90%, transparent) 48px,
379+
var(--code-background) 56px
380+
);
381+
pointer-events: none;
382+
z-index: 1;
383+
}
384+
}
385+
386+
/* Custom copy button with animated green check */
387+
.expressive-code .copy button {
388+
position: relative;
389+
}
390+
391+
/* Hide the default ExpressiveCode copy icon (rendered via ::after) */
392+
.expressive-code .copy button[data-customized]::after {
393+
display: none !important;
394+
}
395+
396+
/* Also hide the inner div that ExpressiveCode uses for hover background */
397+
.expressive-code .copy button[data-customized] > div {
398+
display: none;
399+
}
400+
401+
.expressive-code .copy button .copy-icon-wrapper,
402+
.expressive-code .copy button .check-icon-wrapper {
403+
display: flex;
404+
align-items: center;
405+
justify-content: center;
406+
transition: opacity 0.15s ease, transform 0.2s ease;
407+
color: var(--sl-color-text); /* Prevent mobile browsers from applying blue tap color */
408+
}
409+
410+
.expressive-code .copy button .check-icon-wrapper {
411+
position: absolute;
412+
inset: 0;
413+
opacity: 0;
414+
transform: scale(0.5);
415+
color: oklch(0.723 0.191 145.579); /* Sprites green */
416+
}
417+
418+
/* Copied state */
419+
.expressive-code .copy button.copied .copy-icon-wrapper {
420+
opacity: 0;
421+
transform: scale(0.5);
422+
}
423+
424+
.expressive-code .copy button.copied .check-icon-wrapper {
425+
opacity: 1;
426+
transform: scale(1);
427+
}
428+
429+
/* Check icon stroke animation */
430+
.expressive-code .copy button .check-icon path {
431+
stroke-dasharray: 24;
432+
stroke-dashoffset: 24;
433+
transition: stroke-dashoffset 0.3s ease-out 0.1s;
434+
}
435+
436+
.expressive-code .copy button.copied .check-icon path {
437+
stroke-dashoffset: 0;
438+
}
439+
440+
/* Button pop animation */
441+
@keyframes copy-pop {
442+
0% { transform: scale(1); }
443+
50% { transform: scale(1.1); }
444+
100% { transform: scale(1); }
445+
}
446+
447+
/* Keep button visible during copied state even when not hovering */
448+
.expressive-code .copy button.copied {
449+
animation: copy-pop 0.2s ease-out;
450+
opacity: 1 !important;
451+
}
452+
314453
:not(pre) > code {
315454
background: var(--sl-color-bg-inline-code);
316455
padding: 0.2em 0.4em;

0 commit comments

Comments
 (0)