Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(code-block): Do not copy diff markers #12900

Merged
merged 2 commits into from
Mar 4, 2025
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 112 additions & 18 deletions src/components/codeBlock/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import {useEffect, useRef, useState} from 'react';
import {RefObject, useEffect, useRef, useState} from 'react';
import {Clipboard} from 'react-feather';

import styles from './code-blocks.module.scss';
Expand All @@ -18,38 +18,33 @@ export function CodeBlock({filename, language, children}: CodeBlockProps) {
const [showCopied, setShowCopied] = useState(false);
const codeRef = useRef<HTMLDivElement>(null);

const {copyCodeOnClick} = useCopyCodeCleaner(codeRef, {
cleanDiffMarkers: true,
language,
});

// Show the copy button after js has loaded
// otherwise the copy button will not work
const [showCopyButton, setShowCopyButton] = useState(false);
useEffect(() => {
setShowCopyButton(true);
}, []);

async function copyCode() {
if (codeRef.current === null) {
return;
}
const handleCopyOnClick = async () => {
const success = await copyCodeOnClick();

let code = codeRef.current.innerText.replace(/\n\n/g, '\n');

// don't copy leading prompt for bash
if (language === 'bash' || language === 'shell') {
const match = code.match(/^\$\s*/);
if (match) {
code = code.substring(match[0].length);
}
if (success) {
setShowCopied(true);
setTimeout(() => setShowCopied(false), 1200);
}
await navigator.clipboard.writeText(code);
setShowCopied(true);
setTimeout(() => setShowCopied(false), 1200);
}
};

return (
<div className={styles['code-block']}>
<div className={styles['code-actions']}>
<code className={styles.filename}>{filename}</code>
{showCopyButton && (
<button className={styles.copy} onClick={() => copyCode()}>
<button className={styles.copy} onClick={handleCopyOnClick}>
<Clipboard size={16} />
</button>
)}
Expand All @@ -61,3 +56,102 @@ export function CodeBlock({filename, language, children}: CodeBlockProps) {
</div>
);
}

interface CleanCopyOptions {
cleanBashPrompt?: boolean;
cleanDiffMarkers?: boolean;
language?: string;
}

const REGEX = {
DIFF_MARKERS: /^[+\-](?:\s|(?=\S))/gm, // Matches diff markers (+ or -) at the beginning of lines, with or without spaces
BASH_PROMPT: /^\$\s*/, // Matches bash prompt ($ followed by a space)
CONSECUTIVE_NEWLINES: /\n\n/g, // Matches consecutive newlines
};

/**
* A custom hook that handles cleaning text when copying from code blocks
* @param codeRef - Reference to the code element
* @param options - Configuration options for cleaning
*/
export function useCopyCodeCleaner(
codeRef: RefObject<HTMLElement>,
options: CleanCopyOptions = {}
) {
const {cleanDiffMarkers = true, cleanBashPrompt = true, language = ''} = options;

/**
* Effect, which cleans the snippet when the user manually copies it to their clipboard
*/
useEffect(() => {
const handleUserCopyEvent = (event: ClipboardEvent) => {
if (!codeRef.current || !event.clipboardData) return;

const selection = window.getSelection()?.toString() || '';

if (selection) {
let cleanedText = selection;

if (cleanDiffMarkers) {
cleanedText = cleanedText.replace(REGEX.DIFF_MARKERS, '');
}

if (cleanBashPrompt && (language === 'bash' || language === 'shell')) {
const match = cleanedText.match(REGEX.BASH_PROMPT);
if (match) {
cleanedText = cleanedText.substring(match[0].length);
}
}

event.clipboardData.setData('text/plain', cleanedText);
event.preventDefault();
}
};

const codeElement = codeRef.current;
if (codeElement) {
codeElement.addEventListener('copy', handleUserCopyEvent as EventListener);
}

return () => {
if (codeElement) {
codeElement.removeEventListener('copy', handleUserCopyEvent as EventListener);
}
};
}, [codeRef, cleanDiffMarkers, language, cleanBashPrompt]);

/**
* Function for copying code when clicking on "copy code".
*
* @returns Whether code was copied successfully
*/
const copyCodeOnClick = async (): Promise<boolean> => {
if (codeRef.current === null) {
return false;
}

let code = codeRef.current.innerText.replace(REGEX.CONSECUTIVE_NEWLINES, '\n');

if (cleanBashPrompt && (language === 'bash' || language === 'shell')) {
const match = code.match(REGEX.BASH_PROMPT);
if (match) {
code = code.substring(match[0].length);
}
}

if (cleanDiffMarkers) {
code = code.replace(REGEX.DIFF_MARKERS, '');
}

try {
await navigator.clipboard.writeText(code);
return true;
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to copy:', error);
return false;
}
};

return {copyCodeOnClick};
}
Loading