Skip to content
Merged
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
40 changes: 40 additions & 0 deletions src/components/shared/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React, { useEffect, useRef } from 'react';
import { renderMarkdown } from '../../utils/mdparser-utils';
import { initCodeCopy } from '../../utils/copy-code';

interface MarkdownRendererProps {
markdown: string;
className?: string;
}

/**
* Component that renders markdown content with copy code functionality
*/
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
markdown,
className = '',
}) => {
// Convert markdown to HTML
const html = renderMarkdown(markdown);
const contentRef = useRef<HTMLDivElement>(null);

// Initialize copy code functionality after component mounts and when content changes
useEffect(() => {
// Short delay to ensure DOM is fully rendered
const timer = setTimeout(() => {
initCodeCopy();
}, 100);

return () => clearTimeout(timer);
}, [html]);

return (
<div
ref={contentRef}
className={`markdown-content ${className}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
};

export default MarkdownRenderer;
2 changes: 2 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/globals.css'; // Import global styles (including Tailwind)
// Import copy code functionality to ensure it's initialized
import './utils/copy-code';

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
Expand Down
133 changes: 133 additions & 0 deletions src/utils/copy-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Copy code functionality for code blocks
* This script adds click handlers to copy buttons in code blocks
*/

/**
* Initialize copy code functionality
* This should be called after the DOM is loaded
*/
export function initCodeCopy(): void {
// Remove any existing event listeners to prevent duplicates
const existingButtons = document.querySelectorAll('.copy-code-btn');
existingButtons.forEach((button) => {
if (button instanceof HTMLElement) {
button.removeEventListener('click', handleCopyClick);
}
});

// Find all copy buttons and add new event listeners
const copyButtons = document.querySelectorAll('.copy-code-btn');

// Add click handler to each button
copyButtons.forEach((button) => {
if (button instanceof HTMLElement) {
button.addEventListener('click', handleCopyClick);
}
});
}

/**
* Handle click on copy button
*/
function handleCopyClick(event: Event): void {
event.preventDefault();
event.stopPropagation();

const button = event.currentTarget as HTMLElement;
const codeContent = button.getAttribute('data-code');

if (!codeContent) {
console.error('No code content found to copy');
return;
}

// Copy to clipboard using both modern and fallback methods
try {
// Modern method
navigator.clipboard
.writeText(codeContent)
.then(() => showSuccessMessage(button))
.catch((err) => {
console.error('Clipboard API failed:', err);
fallbackCopy(codeContent, button);
});
} catch (err) {
console.error('Clipboard API not supported:', err);
fallbackCopy(codeContent, button);
}
}

/**
* Fallback copy method using textarea
*/
function fallbackCopy(text: string, button: HTMLElement): void {
// Create a temporary textarea element
const textarea = document.createElement('textarea');
textarea.value = text;

// Make the textarea out of viewport
textarea.style.position = 'fixed';
textarea.style.left = '-999999px';
textarea.style.top = '-999999px';

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

let success = false;
try {
// Execute the copy command
success = document.execCommand('copy');
} catch (err) {
console.error('Fallback copy failed:', err);
}

// Remove the textarea
document.body.removeChild(textarea);

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

/**
* Show success message
*/
function showSuccessMessage(button: HTMLElement): void {
// Show success message
const successMessage = button.nextElementSibling;
if (
successMessage &&
successMessage.classList.contains('copy-success-message')
) {
// Show success message
successMessage.classList.remove('hidden');

// Hide after 2 seconds
setTimeout(() => {
successMessage.classList.add('hidden');
}, 2000);
}
}

/**
* Add event listener to initialize copy functionality when DOM is loaded
* and also when DOM content changes (for dynamic content)
*/
if (typeof window !== 'undefined') {
// Initialize on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCodeCopy);
} else {
initCodeCopy();
}

// Re-initialize periodically to catch dynamically added code blocks
setInterval(initCodeCopy, 2000);

// Also initialize when user interacts with the page
document.addEventListener('click', () => {
setTimeout(initCodeCopy, 100);
});
}
57 changes: 54 additions & 3 deletions src/utils/mdparser-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export function escapeHtml(unsafe: string): string {
*/
export const renderMarkdown = (markdown: string): string => {
let html = markdown;
const codeBlockPlaceholders: { [key: string]: string } = {};
let codeBlockCounter = 0;

// Create IDs for headers to enable anchor links
const headerToId = (text: string) => {
Expand All @@ -29,6 +31,50 @@ export const renderMarkdown = (markdown: string): string => {
.replace(/(^-|-$)/g, '');
};

// Process code blocks first and replace them with placeholders
html = html.replace(
/```([\w-]*)\s*([\s\S]*?)```/gm,
function (_match, _language, codeContent) {
// Prepare the code content for display
const escapedCode = escapeHtml(codeContent.trim());

// Store the original code content for copying (without HTML)
const originalCode = codeContent.trim();

// Replace line breaks with <br> tags
const formattedCode = escapedCode.replace(/\n/g, '<br>');

// Create the code block HTML with copy button
const codeBlockHtml = `<div class="relative rounded-lg overflow-hidden shadow-lg bg-gray-900 my-1 group">
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<button class="copy-code-btn bg-gray-600 hover:bg-gray-700 text-white text-xs px-2 py-1 rounded shadow-sm flex items-center"
data-code="${escapeHtml(originalCode).replace(/"/g, '&quot;')}"
onclick="event.stopPropagation(); return false;"
aria-label="Copy code">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
<span class="copy-success-message hidden absolute right-10 top-[1px] bg-gray-600 text-white text-xs px-2 py-1 rounded shadow-sm">
Copied!
</span>
</div>
<div class="px-2 overflow-x-auto">
<pre class="px-3 py-7"><code class="font-mono text-sm text-gray-200 block whitespace-pre overflow-visible" style="line-height: 0.8;">${formattedCode}</code></pre>
</div>
</div>`;

// Create a unique placeholder token
const placeholder = `CODE_BLOCK_PLACEHOLDER_${codeBlockCounter++}`;

// Store the code block HTML with its placeholder
codeBlockPlaceholders[placeholder] = codeBlockHtml;

// Return the placeholder token
return placeholder;
},
);

// Process blockquotes
html = html.replace(/(^>.*(\n>.*)*)/gm, function (match) {
const lines = match.split('\n');
Expand Down Expand Up @@ -159,9 +205,9 @@ export const renderMarkdown = (markdown: string): string => {
'<a href="$2" class="text-blue-600 hover:underline transition-colors duration-200">$1</a>',
);

// Convert inline code
// Convert inline code - Make sure this comes after code blocks
html = html.replace(
/`(.*?)`/gim,
/`([^`]+)`/gim,
'<code class="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">$1</code>',
);

Expand Down Expand Up @@ -292,7 +338,7 @@ export const renderMarkdown = (markdown: string): string => {

// Convert paragraphs - do this last to avoid conflicts
html = html.replace(
/^(?!<[a-z]|\s*$)(.+)$/gim,
/^(?!<[a-z]|\s*$|CODE_BLOCK_PLACEHOLDER_)(.+)$/gim,
'<p class="my-4 text-gray-700 leading-relaxed">$1</p>',
);

Expand Down Expand Up @@ -327,5 +373,10 @@ export const renderMarkdown = (markdown: string): string => {
// Remove any remaining newlines that aren't already handled
html = html.replace(/([^>])\n([^<])/g, '$1 $2');

// Finally, replace all code block placeholders with their actual HTML
Object.keys(codeBlockPlaceholders).forEach((placeholder) => {
html = html.replace(placeholder, codeBlockPlaceholders[placeholder]);
});

return html;
};