Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
51 changes: 13 additions & 38 deletions src/utils/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import { h } from 'hastscript';
import { initCodeCopy } from '@/utils/copy-code';

// Type definitions
interface MarkdownRendererProps {
Expand Down Expand Up @@ -226,36 +227,6 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
}
}, []);

const handleCopyClick = useCallback(async (e: MouseEvent) => {
const button = (e.target as HTMLElement).closest(
'.copy-code-btn',
) as HTMLButtonElement | null;
if (!button) return;

const code = button.getAttribute('data-code') || '';

try {
await navigator.clipboard.writeText(code);
const successMessage = button.nextElementSibling as HTMLElement;
if (successMessage) {
successMessage.classList.remove('hidden');
successMessage.classList.add('flex');
setTimeout(() => {
successMessage.classList.add('hidden');
successMessage.classList.remove('flex');
}, 2000);
}
} catch {
const textArea = document.createElement('textarea');
textArea.value = code;
textArea.style.cssText = 'position:fixed;opacity:0;';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
}, []);

const handleAnchorClick = useCallback((e: MouseEvent) => {
const target = e.target as HTMLElement;

Expand All @@ -281,13 +252,11 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
}, []);

useEffect(() => {
document.addEventListener('click', handleCopyClick);
document.addEventListener('click', handleAnchorClick);
return () => {
document.removeEventListener('click', handleCopyClick);
document.removeEventListener('click', handleAnchorClick);
};
}, [handleCopyClick, handleAnchorClick]);
}, [handleAnchorClick]);

useEffect(() => {
if (setZoomableImages) {
Expand All @@ -296,6 +265,15 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
}
}, [processedContent, setZoomableImages]);

// Initialize copy functionality when content changes
useEffect(() => {
const timeoutId = setTimeout(() => {
initCodeCopy();
}, 100); // Small delay to ensure DOM is updated

return () => clearTimeout(timeoutId);
}, [processedContent]);

useEffect(() => {
scrollToAnchor();
const handlePopState = () => {
Expand Down Expand Up @@ -403,9 +381,9 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
{language}
</span>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center">
<button
className="copy-code-btn bg-gray-200 hover:bg-gray-300 text-gray-700 text-xs px-4 py-2 rounded-lg transition-all duration-200 flex items-center space-x-2 hover:scale-105 shadow-sm hover:shadow-md"
className="copy-code-btn bg-gray-200 hover:bg-gray-300 text-gray-700 text-xs px-4 py-2 rounded-lg transition-all duration-200 flex items-center space-x-2 hover:scale-105 shadow-sm hover:shadow-md"
data-code={codeText}
aria-label="Copy code to clipboard"
>
Expand All @@ -424,9 +402,6 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
</svg>
<span className="font-medium">Copy</span>
</button>
<div className="copy-success-message hidden items-center space-x-2 bg-green-100 text-green-800 text-xs px-4 py-2 rounded-lg border border-green-200">
<span className="font-medium">Copied!</span>
</div>
</div>
</div>
<div className="overflow-x-auto">
Expand Down
83 changes: 60 additions & 23 deletions src/utils/copy-code.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* Copy code functionality for code blocks
* Simplified and optimized version
* Copy code functionality for code blocks with user-friendly feedback
*/

/**
* Handle click on copy button with unified clipboard handling
* @param event - The click event from the copy button
*/
function handleCopyClick(event: Event): void {
event.preventDefault();
Expand All @@ -13,16 +13,18 @@ function handleCopyClick(event: Event): void {
const button = event.currentTarget as HTMLElement;
const codeContent = button.getAttribute('data-code');

if (!codeContent) {
console.error('No code content found to copy');
if (!codeContent || codeContent.trim() === '') {
showErrorMessage(button);
return;
}

copyToClipboard(codeContent, button);
}

/**
* Unified clipboard copy with fallback
* Copy text to clipboard with fallback for older browsers
* @param text - The text content to copy to clipboard
* @param button - The copy button element for showing feedback
*/
async function copyToClipboard(
text: string,
Expand All @@ -37,30 +39,72 @@ async function copyToClipboard(
textarea.value = text;
textarea.style.cssText =
'position:fixed;left:-999999px;top:-999999px;opacity:0;';

document.body.appendChild(textarea);
textarea.select();

const success = document.execCommand('copy');
document.body.removeChild(textarea);

if (success) showSuccessMessage(button);
if (success) {
showSuccessMessage(button);
} else {
showErrorMessage(button);
}
}
}

/**
* Show success message
* Show success feedback by transforming button to green "Copied!" state
* @param button - The copy button element to show success feedback in
*/
function showSuccessMessage(button: HTMLElement): void {
const successMessage = button.nextElementSibling as HTMLElement;
if (successMessage?.classList.contains('copy-success-message')) {
successMessage.classList.remove('hidden');
setTimeout(() => successMessage.classList.add('hidden'), 2000);
}
const originalContent = button.innerHTML;
const originalClasses = button.className;

button.className =
'copy-code-btn bg-green-100 text-green-800 text-xs px-4 py-2 rounded-lg transition-all duration-200 flex items-center space-x-2 shadow-sm border border-green-200';
button.innerHTML = `
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<span class="font-medium">Copied!</span>
`;
button.setAttribute('disabled', 'true');

setTimeout(() => {
button.className = originalClasses;
button.innerHTML = originalContent;
button.removeAttribute('disabled');
}, 2000);
}

/**
* Show error feedback by transforming button to red "Failed!" state
* @param button - The copy button element to show error feedback in
*/
function showErrorMessage(button: HTMLElement): void {
const originalContent = button.innerHTML;
const originalClasses = button.className;

button.className =
'copy-code-btn bg-red-100 text-red-800 text-xs px-4 py-2 rounded-lg transition-all duration-200 flex items-center space-x-2 shadow-sm border border-red-200';
button.innerHTML = `
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span class="font-medium">Failed!</span>
`;
button.setAttribute('disabled', 'true');

setTimeout(() => {
button.className = originalClasses;
button.innerHTML = originalContent;
button.removeAttribute('disabled');
}, 2500);
}

/**
* Initialize copy code functionality
* Initialize copy functionality for all copy buttons on the page
* Attaches click event handlers to elements with 'copy-code-btn' class
*/
export function initCodeCopy(): void {
document.querySelectorAll('.copy-code-btn').forEach((button) => {
Expand All @@ -71,18 +115,11 @@ export function initCodeCopy(): void {
});
}

// Auto-initialize when available
// Auto-initialize
if (typeof window !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCodeCopy);
} else {
initCodeCopy();
}

// Re-initialize for dynamic content
let timeoutId: number;
document.addEventListener('click', () => {
clearTimeout(timeoutId);
timeoutId = window.setTimeout(initCodeCopy, 100);
});
}