Skip to content

Commit e0438b9

Browse files
committed
feat: Add copy button to highlighted code lines
1 parent 074644f commit e0438b9

File tree

5 files changed

+303
-2
lines changed

5 files changed

+303
-2
lines changed

src/components/codeBlock/code-blocks.module.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
}
7676

7777
:global(.highlight-line) {
78-
background-color: rgba(239, 239, 239, 0.06);
78+
// background-color: rgba(239, 239, 239, 0.06);
7979
/* Set highlight bg color */
8080
border-left: 4px solid var(--brandPink);
8181
}

src/components/codeBlock/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {Clipboard} from 'react-feather';
55

66
import styles from './code-blocks.module.scss';
77

8+
import {makeHighlightBlocks} from '../codeHighlights'
89
import {makeKeywordsClickable} from '../codeKeywords';
910

1011
export interface CodeBlockProps {
@@ -57,7 +58,7 @@ export function CodeBlock({filename, language, children}: CodeBlockProps) {
5758
<div className={styles.copied} style={{opacity: showCopied ? 1 : 0}}>
5859
Copied
5960
</div>
60-
<div ref={codeRef}>{makeKeywordsClickable(children)}</div>
61+
<div ref={codeRef}>{makeKeywordsClickable(makeHighlightBlocks(children, language))}</div>
6162
</div>
6263
);
6364
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
'use client';
2+
3+
import {Children, cloneElement, ReactElement, useEffect, useRef, useState} from 'react';
4+
import {Clipboard} from 'react-feather';
5+
import styled from '@emotion/styled';
6+
7+
import {cleanCodeSnippet, useCleanSnippetInClipboard} from '../codeBlock';
8+
9+
type ChildrenItem = ReturnType<typeof Children.toArray>[number] | React.ReactNode;
10+
11+
export function makeHighlightBlocks(
12+
children: React.ReactNode,
13+
language: string | undefined
14+
) {
15+
const items = Children.toArray(children);
16+
17+
let highlightedLineElements: ReactElement[] = [];
18+
const highlightElementGroupCounter = 0;
19+
20+
return items.reduce((arr: ChildrenItem[], child) => {
21+
if (typeof child !== 'object') {
22+
arr.push(child);
23+
return arr;
24+
}
25+
26+
const element = child as ReactElement;
27+
const classes = element.props.className;
28+
29+
const isCodeLine = classes && classes.includes('code-line');
30+
if (!isCodeLine) {
31+
const updatedChild = cloneElement(
32+
child as ReactElement,
33+
{},
34+
makeHighlightBlocks((child as ReactElement).props.children, language)
35+
);
36+
arr.push(updatedChild);
37+
return arr;
38+
}
39+
40+
const isHighlightedLine = isCodeLine && classes.includes('highlight-line');
41+
42+
if (isHighlightedLine) {
43+
highlightedLineElements.push(element);
44+
} else {
45+
if (highlightedLineElements.length > 0) {
46+
arr.push(
47+
<HighlightBlock groupId={highlightElementGroupCounter} language={language}>
48+
{...highlightedLineElements}
49+
</HighlightBlock>
50+
);
51+
highlightedLineElements = [];
52+
}
53+
arr.push(child);
54+
}
55+
56+
return arr;
57+
}, [] as ChildrenItem[]);
58+
}
59+
60+
export function HighlightBlock({
61+
children,
62+
groupId,
63+
language,
64+
}: {
65+
children: React.ReactNode;
66+
groupId: number;
67+
language: string | undefined;
68+
}) {
69+
const codeRef = useRef<HTMLDivElement>(null);
70+
71+
useCleanSnippetInClipboard(codeRef, {language});
72+
73+
// Show the copy button after js has loaded
74+
// otherwise the copy button will not work
75+
const [showCopyButton, setShowCopyButton] = useState(false);
76+
useEffect(() => {
77+
setShowCopyButton(true);
78+
}, []);
79+
80+
async function copyCodeOnClick() {
81+
if (codeRef.current === null) {
82+
return;
83+
}
84+
85+
const code = cleanCodeSnippet(codeRef.current.innerText, {language});
86+
87+
try {
88+
await navigator.clipboard.writeText(code);
89+
} catch (error) {
90+
// eslint-disable-next-line no-console
91+
console.error('Failed to copy:', error);
92+
}
93+
}
94+
95+
return (
96+
<HighlightBlockContainer key={`highlight-block-${groupId}`}>
97+
<CodeLinesContainer ref={codeRef}>{children}</CodeLinesContainer>
98+
<ClipBoardContainer onClick={copyCodeOnClick}>
99+
{showCopyButton && <Clipboard size={14} opacity={70} />}
100+
</ClipBoardContainer>
101+
</HighlightBlockContainer>
102+
);
103+
}
104+
105+
const HighlightBlockContainer = styled('div')`
106+
display: flex;
107+
flex-direction: row;
108+
justify-content: space-between;
109+
align-items: stretch;
110+
float: left;
111+
min-width: 100%;
112+
box-sizing: border-box;
113+
background-color: rgba(239, 239, 239, 0.06);
114+
`;
115+
116+
const CodeLinesContainer = styled('div')`
117+
padding: 8px 0;
118+
width: calc(100% - 48px);
119+
`;
120+
121+
const ClipBoardContainer = styled('div')`
122+
width: 48px;
123+
display: flex;
124+
justify-content: center;
125+
align-items: center;
126+
cursor: pointer;
127+
:hover {
128+
background-color: rgba(239, 239, 239, 0.1);
129+
}
130+
:active {
131+
background-color: rgba(239, 239, 239, 0.15);
132+
}
133+
transition: background-color 150ms linear;
134+
`;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {makeHighlightBlocks} from './codeHighlights';
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
'use client';
2+
3+
import {ArrowDown} from 'react-feather';
4+
import styled from '@emotion/styled';
5+
import {motion} from 'framer-motion';
6+
7+
export const PositionWrapper = styled('div')`
8+
z-index: 100;
9+
`;
10+
11+
export const Arrow = styled('div')`
12+
position: absolute;
13+
width: 10px;
14+
height: 5px;
15+
margin-top: -10px;
16+
17+
&::before {
18+
content: '';
19+
display: block;
20+
border: 5px solid transparent;
21+
}
22+
23+
&[data-placement*='bottom'] {
24+
&::before {
25+
border-bottom-color: #fff;
26+
}
27+
}
28+
29+
&[data-placement*='top'] {
30+
bottom: -5px;
31+
&::before {
32+
border-top-color: #fff;
33+
}
34+
}
35+
`;
36+
37+
export const Dropdown = styled('div')<{dark: boolean}>`
38+
font-family:
39+
'Rubik',
40+
-apple-system,
41+
BlinkMacSystemFont,
42+
'Segoe UI';
43+
overflow: hidden;
44+
border-radius: 3px;
45+
background: ${p => (p.dark ? 'var(--gray-4)' : '#fff')};
46+
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
47+
`;
48+
49+
export const Selections = styled('div')`
50+
overflow: scroll;
51+
overscroll-behavior: contain;
52+
max-height: 210px;
53+
min-width: 300px;
54+
`;
55+
56+
export const DropdownHeader = styled('div')`
57+
font-family:
58+
'Rubik',
59+
-apple-system,
60+
BlinkMacSystemFont,
61+
'Segoe UI';
62+
padding: 6px 8px;
63+
font-size: 0.875rem;
64+
color: #80708f;
65+
border-bottom: 1px solid #dbd6e1;
66+
`;
67+
68+
export const ItemButton = styled('button')<{dark: boolean; isActive: boolean}>`
69+
font-family:
70+
'Rubik',
71+
-apple-system,
72+
BlinkMacSystemFont,
73+
'Segoe UI';
74+
font-size: 0.85rem;
75+
text-align: left;
76+
padding: 6px 8px;
77+
cursor: pointer;
78+
display: block;
79+
width: 100%;
80+
background: none;
81+
border: none;
82+
outline: none;
83+
84+
&:not(:last-child) {
85+
border-bottom: 1px solid var(--border-color);
86+
}
87+
88+
${p =>
89+
p.isActive
90+
? `
91+
background-color: #6C5FC7;
92+
color: #EBE6EF;
93+
`
94+
: `
95+
96+
97+
&:focus {
98+
outline: none;
99+
background-color: ${p.dark ? 'var(--gray-a4)' : 'var(--accent-purple-light)'};
100+
}
101+
&:hover,
102+
&.active {
103+
background-color: ${p.dark ? 'var(--gray-a4)' : 'var(--accent-purple-light)'};
104+
}
105+
`}
106+
`;
107+
108+
export const KeywordDropdown = styled('span')`
109+
border-radius: 3px;
110+
margin: 0 2px;
111+
padding: 0 4px;
112+
z-index: -1;
113+
cursor: pointer;
114+
background: #382f5c;
115+
transition: background 200ms ease-in-out;
116+
117+
&:focus {
118+
outline: none;
119+
}
120+
121+
&:focus,
122+
&:hover {
123+
background: #1d1127;
124+
}
125+
`;
126+
127+
export const KeywordIndicator = styled(ArrowDown, {
128+
shouldForwardProp: p => p !== 'isOpen',
129+
})<{
130+
isOpen: boolean;
131+
}>`
132+
user-select: none;
133+
margin-right: 2px;
134+
transition: transform 200ms ease-in-out;
135+
transform: rotate(${p => (p.isOpen ? '180deg' : '0')});
136+
stroke-width: 3px;
137+
position: relative;
138+
top: -1px;
139+
`;
140+
141+
export const KeywordSpan = styled(motion.span)`
142+
grid-row: 1;
143+
grid-column: 1;
144+
`;
145+
146+
export const KeywordSearchInput = styled('input')<{dark: boolean}>`
147+
border-width: 1.5px;
148+
border-style: solid;
149+
border-color: ${p => (p.dark ? '' : 'var(--desatPurple12)')};
150+
border-radius: 0.25rem;
151+
width: 280px;
152+
-webkit-appearance: none;
153+
appearance: none;
154+
padding: 0.25rem 0.75rem;
155+
line-height: 1.8;
156+
border-radius: 0.25rem;
157+
outline: none;
158+
margin: 10px;
159+
160+
&:focus {
161+
border-color: var(--accent-purple);
162+
box-shadow: 0 0 0 0.2rem
163+
${p => (p.dark ? 'var(--gray-a4)' : 'var(--accent-purple-light)')};
164+
}
165+
`;

0 commit comments

Comments
 (0)