Skip to content

Commit f2e29bc

Browse files
authored
Code block fixed (#43)
* feat: add code-block for markdown * Refactor markdown rendering: streamline code block processing and improve inline code handling * Add MarkdownRenderer component with copy code functionality and enhance markdown rendering
1 parent d91bcf9 commit f2e29bc

File tree

4 files changed

+229
-3
lines changed

4 files changed

+229
-3
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React, { useEffect, useRef } from 'react';
2+
import { renderMarkdown } from '../../utils/mdparser-utils';
3+
import { initCodeCopy } from '../../utils/copy-code';
4+
5+
interface MarkdownRendererProps {
6+
markdown: string;
7+
className?: string;
8+
}
9+
10+
/**
11+
* Component that renders markdown content with copy code functionality
12+
*/
13+
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
14+
markdown,
15+
className = '',
16+
}) => {
17+
// Convert markdown to HTML
18+
const html = renderMarkdown(markdown);
19+
const contentRef = useRef<HTMLDivElement>(null);
20+
21+
// Initialize copy code functionality after component mounts and when content changes
22+
useEffect(() => {
23+
// Short delay to ensure DOM is fully rendered
24+
const timer = setTimeout(() => {
25+
initCodeCopy();
26+
}, 100);
27+
28+
return () => clearTimeout(timer);
29+
}, [html]);
30+
31+
return (
32+
<div
33+
ref={contentRef}
34+
className={`markdown-content ${className}`}
35+
dangerouslySetInnerHTML={{ __html: html }}
36+
/>
37+
);
38+
};
39+
40+
export default MarkdownRenderer;

src/main.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import React from 'react';
22
import ReactDOM from 'react-dom/client';
33
import App from './App';
44
import './styles/globals.css'; // Import global styles (including Tailwind)
5+
// Import copy code functionality to ensure it's initialized
6+
import './utils/copy-code';
57

68
ReactDOM.createRoot(document.getElementById('root')!).render(
79
<React.StrictMode>

src/utils/copy-code.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Copy code functionality for code blocks
3+
* This script adds click handlers to copy buttons in code blocks
4+
*/
5+
6+
/**
7+
* Initialize copy code functionality
8+
* This should be called after the DOM is loaded
9+
*/
10+
export function initCodeCopy(): void {
11+
// Remove any existing event listeners to prevent duplicates
12+
const existingButtons = document.querySelectorAll('.copy-code-btn');
13+
existingButtons.forEach((button) => {
14+
if (button instanceof HTMLElement) {
15+
button.removeEventListener('click', handleCopyClick);
16+
}
17+
});
18+
19+
// Find all copy buttons and add new event listeners
20+
const copyButtons = document.querySelectorAll('.copy-code-btn');
21+
22+
// Add click handler to each button
23+
copyButtons.forEach((button) => {
24+
if (button instanceof HTMLElement) {
25+
button.addEventListener('click', handleCopyClick);
26+
}
27+
});
28+
}
29+
30+
/**
31+
* Handle click on copy button
32+
*/
33+
function handleCopyClick(event: Event): void {
34+
event.preventDefault();
35+
event.stopPropagation();
36+
37+
const button = event.currentTarget as HTMLElement;
38+
const codeContent = button.getAttribute('data-code');
39+
40+
if (!codeContent) {
41+
console.error('No code content found to copy');
42+
return;
43+
}
44+
45+
// Copy to clipboard using both modern and fallback methods
46+
try {
47+
// Modern method
48+
navigator.clipboard
49+
.writeText(codeContent)
50+
.then(() => showSuccessMessage(button))
51+
.catch((err) => {
52+
console.error('Clipboard API failed:', err);
53+
fallbackCopy(codeContent, button);
54+
});
55+
} catch (err) {
56+
console.error('Clipboard API not supported:', err);
57+
fallbackCopy(codeContent, button);
58+
}
59+
}
60+
61+
/**
62+
* Fallback copy method using textarea
63+
*/
64+
function fallbackCopy(text: string, button: HTMLElement): void {
65+
// Create a temporary textarea element
66+
const textarea = document.createElement('textarea');
67+
textarea.value = text;
68+
69+
// Make the textarea out of viewport
70+
textarea.style.position = 'fixed';
71+
textarea.style.left = '-999999px';
72+
textarea.style.top = '-999999px';
73+
74+
document.body.appendChild(textarea);
75+
textarea.focus();
76+
textarea.select();
77+
78+
let success = false;
79+
try {
80+
// Execute the copy command
81+
success = document.execCommand('copy');
82+
} catch (err) {
83+
console.error('Fallback copy failed:', err);
84+
}
85+
86+
// Remove the textarea
87+
document.body.removeChild(textarea);
88+
89+
if (success) {
90+
showSuccessMessage(button);
91+
}
92+
}
93+
94+
/**
95+
* Show success message
96+
*/
97+
function showSuccessMessage(button: HTMLElement): void {
98+
// Show success message
99+
const successMessage = button.nextElementSibling;
100+
if (
101+
successMessage &&
102+
successMessage.classList.contains('copy-success-message')
103+
) {
104+
// Show success message
105+
successMessage.classList.remove('hidden');
106+
107+
// Hide after 2 seconds
108+
setTimeout(() => {
109+
successMessage.classList.add('hidden');
110+
}, 2000);
111+
}
112+
}
113+
114+
/**
115+
* Add event listener to initialize copy functionality when DOM is loaded
116+
* and also when DOM content changes (for dynamic content)
117+
*/
118+
if (typeof window !== 'undefined') {
119+
// Initialize on page load
120+
if (document.readyState === 'loading') {
121+
document.addEventListener('DOMContentLoaded', initCodeCopy);
122+
} else {
123+
initCodeCopy();
124+
}
125+
126+
// Re-initialize periodically to catch dynamically added code blocks
127+
setInterval(initCodeCopy, 2000);
128+
129+
// Also initialize when user interacts with the page
130+
document.addEventListener('click', () => {
131+
setTimeout(initCodeCopy, 100);
132+
});
133+
}

src/utils/mdparser-utils.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export function escapeHtml(unsafe: string): string {
2020
*/
2121
export const renderMarkdown = (markdown: string): string => {
2222
let html = markdown;
23+
const codeBlockPlaceholders: { [key: string]: string } = {};
24+
let codeBlockCounter = 0;
2325

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

34+
// Process code blocks first and replace them with placeholders
35+
html = html.replace(
36+
/```([\w-]*)\s*([\s\S]*?)```/gm,
37+
function (_match, _language, codeContent) {
38+
// Prepare the code content for display
39+
const escapedCode = escapeHtml(codeContent.trim());
40+
41+
// Store the original code content for copying (without HTML)
42+
const originalCode = codeContent.trim();
43+
44+
// Replace line breaks with <br> tags
45+
const formattedCode = escapedCode.replace(/\n/g, '<br>');
46+
47+
// Create the code block HTML with copy button
48+
const codeBlockHtml = `<div class="relative rounded-lg overflow-hidden shadow-lg bg-gray-900 my-1 group">
49+
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
50+
<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"
51+
data-code="${escapeHtml(originalCode).replace(/"/g, '&quot;')}"
52+
onclick="event.stopPropagation(); return false;"
53+
aria-label="Copy code">
54+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
55+
<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" />
56+
</svg>
57+
</button>
58+
<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">
59+
Copied!
60+
</span>
61+
</div>
62+
<div class="px-2 overflow-x-auto">
63+
<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>
64+
</div>
65+
</div>`;
66+
67+
// Create a unique placeholder token
68+
const placeholder = `CODE_BLOCK_PLACEHOLDER_${codeBlockCounter++}`;
69+
70+
// Store the code block HTML with its placeholder
71+
codeBlockPlaceholders[placeholder] = codeBlockHtml;
72+
73+
// Return the placeholder token
74+
return placeholder;
75+
},
76+
);
77+
3278
// Process blockquotes
3379
html = html.replace(/(^>.*(\n>.*)*)/gm, function (match) {
3480
const lines = match.split('\n');
@@ -159,9 +205,9 @@ export const renderMarkdown = (markdown: string): string => {
159205
'<a href="$2" class="text-blue-600 hover:underline transition-colors duration-200">$1</a>',
160206
);
161207

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

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

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

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

376+
// Finally, replace all code block placeholders with their actual HTML
377+
Object.keys(codeBlockPlaceholders).forEach((placeholder) => {
378+
html = html.replace(placeholder, codeBlockPlaceholders[placeholder]);
379+
});
380+
330381
return html;
331382
};

0 commit comments

Comments
 (0)