352 lines
9.0 KiB
TypeScript
352 lines
9.0 KiB
TypeScript
import React from 'react';
|
|
|
|
interface TacticalMarkdownRendererProps {
|
|
content: string;
|
|
isChat?: boolean;
|
|
}
|
|
|
|
// Inline parser for bold, italic, inline-code, and tags
|
|
const parseInline = (text: string): React.ReactNode[] => {
|
|
if (!text) return [];
|
|
|
|
// Match bold (**text**), inline code (`text`), italic (*text* or _text_)
|
|
const tokens: React.ReactNode[] = [];
|
|
let remaining = text;
|
|
let key = 0;
|
|
|
|
while (remaining.length > 0) {
|
|
// Check for bold **...** or __...__
|
|
const boldMatch = remaining.match(/^(\*\*|__)(.*?)\1/);
|
|
if (boldMatch) {
|
|
tokens.push(
|
|
<strong
|
|
key={key++}
|
|
style={{
|
|
fontWeight: 800,
|
|
color: '#0f172a',
|
|
background: 'rgba(241, 245, 249, 0.6)',
|
|
padding: '1px 4px',
|
|
borderRadius: '4px',
|
|
margin: '0 1px'
|
|
}}
|
|
>
|
|
{parseInline(boldMatch[2])}
|
|
</strong>
|
|
);
|
|
remaining = remaining.slice(boldMatch[0].length);
|
|
continue;
|
|
}
|
|
|
|
// Check for inline code `...`
|
|
const codeMatch = remaining.match(/^`([^`]+)`/);
|
|
if (codeMatch) {
|
|
tokens.push(
|
|
<code
|
|
key={key++}
|
|
style={{
|
|
background: '#e0e7ff',
|
|
color: '#4338ca',
|
|
padding: '2px 6px',
|
|
borderRadius: '4px',
|
|
fontSize: '0.85em',
|
|
fontFamily: 'monospace',
|
|
fontWeight: 700,
|
|
direction: 'ltr',
|
|
display: 'inline-block'
|
|
}}
|
|
>
|
|
{codeMatch[1]}
|
|
</code>
|
|
);
|
|
remaining = remaining.slice(codeMatch[0].length);
|
|
continue;
|
|
}
|
|
|
|
// Check for italic *...* or _..._
|
|
const italicMatch = remaining.match(/^(\*|_)(.*?)\1/);
|
|
if (italicMatch) {
|
|
tokens.push(
|
|
<em key={key++} style={{ fontStyle: 'italic', color: '#475569' }}>
|
|
{parseInline(italicMatch[2])}
|
|
</em>
|
|
);
|
|
remaining = remaining.slice(italicMatch[0].length);
|
|
continue;
|
|
}
|
|
|
|
// Regular text until the next special character
|
|
const nextSpecial = remaining.search(/[\*_`]/);
|
|
if (nextSpecial === -1) {
|
|
tokens.push(remaining);
|
|
break;
|
|
} else if (nextSpecial === 0) {
|
|
// Stray character, consume it as plain text
|
|
tokens.push(remaining[0]);
|
|
remaining = remaining.slice(1);
|
|
} else {
|
|
tokens.push(remaining.slice(0, nextSpecial));
|
|
remaining = remaining.slice(nextSpecial);
|
|
}
|
|
}
|
|
|
|
return tokens;
|
|
};
|
|
|
|
export const TacticalMarkdownRenderer: React.FC<TacticalMarkdownRendererProps> = ({
|
|
content,
|
|
isChat = false
|
|
}) => {
|
|
if (!content) return null;
|
|
|
|
const lines = content.split('\n');
|
|
const elements: React.ReactNode[] = [];
|
|
let currentList: { type: 'ul' | 'ol'; items: React.ReactNode[] } | null = null;
|
|
let elementKey = 0;
|
|
|
|
const flushList = () => {
|
|
if (currentList) {
|
|
if (currentList.type === 'ul') {
|
|
elements.push(
|
|
<ul
|
|
key={elementKey++}
|
|
style={{
|
|
margin: '8px 0 12px 0',
|
|
paddingRight: isChat ? '16px' : '20px',
|
|
paddingLeft: 0,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: isChat ? '4px' : '6px',
|
|
listStyleType: 'disc'
|
|
}}
|
|
>
|
|
{currentList.items.map((item, idx) => (
|
|
<li key={idx} style={{ lineHeight: isChat ? 1.6 : 1.85, color: '#1e293b' }}>
|
|
{item}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
} else {
|
|
elements.push(
|
|
<ol
|
|
key={elementKey++}
|
|
style={{
|
|
margin: '8px 0 12px 0',
|
|
paddingRight: isChat ? '18px' : '24px',
|
|
paddingLeft: 0,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: isChat ? '4px' : '6px'
|
|
}}
|
|
>
|
|
{currentList.items.map((item, idx) => (
|
|
<li key={idx} style={{ lineHeight: isChat ? 1.6 : 1.85, color: '#1e293b', fontWeight: 600 }}>
|
|
<span style={{ fontWeight: 400 }}>{item}</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
);
|
|
}
|
|
currentList = null;
|
|
}
|
|
};
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const rawLine = lines[i];
|
|
const trimmed = rawLine.trim();
|
|
|
|
// Empty lines
|
|
if (!trimmed) {
|
|
flushList();
|
|
continue;
|
|
}
|
|
|
|
// Horizontal Rule (--- or *** or ___)
|
|
if (/^(\-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
|
flushList();
|
|
elements.push(
|
|
<hr
|
|
key={elementKey++}
|
|
style={{
|
|
border: 'none',
|
|
height: '1px',
|
|
background: 'linear-gradient(to left, transparent, #cbd5e1, transparent)',
|
|
margin: isChat ? '10px 0' : '16px 0'
|
|
}}
|
|
/>
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Headers
|
|
const h1Match = trimmed.match(/^#\s+(.+)$/);
|
|
if (h1Match) {
|
|
flushList();
|
|
elements.push(
|
|
<h1
|
|
key={elementKey++}
|
|
style={{
|
|
fontSize: isChat ? '1.1rem' : '1.35rem',
|
|
fontWeight: 900,
|
|
color: '#0f172a',
|
|
marginTop: isChat ? '10px' : '18px',
|
|
marginBottom: isChat ? '6px' : '10px',
|
|
borderBottom: '2px solid #e2e8f0',
|
|
paddingBottom: '6px'
|
|
}}
|
|
>
|
|
{parseInline(h1Match[1])}
|
|
</h1>
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const h2Match = trimmed.match(/^##\s+(.+)$/);
|
|
if (h2Match) {
|
|
flushList();
|
|
elements.push(
|
|
<h2
|
|
key={elementKey++}
|
|
style={{
|
|
fontSize: isChat ? '1rem' : '1.2rem',
|
|
fontWeight: 900,
|
|
color: '#1e293b',
|
|
marginTop: isChat ? '8px' : '16px',
|
|
marginBottom: isChat ? '4px' : '8px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 8
|
|
}}
|
|
>
|
|
<span style={{ width: 4, height: 16, background: '#9333ea', borderRadius: 2, display: 'inline-block' }}></span>
|
|
<span>{parseInline(h2Match[1])}</span>
|
|
</h2>
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const h3Match = trimmed.match(/^###\s+(.+)$/);
|
|
if (h3Match) {
|
|
flushList();
|
|
elements.push(
|
|
<h3
|
|
key={elementKey++}
|
|
style={{
|
|
fontSize: isChat ? '0.92rem' : '1.05rem',
|
|
fontWeight: 800,
|
|
color: '#334155',
|
|
marginTop: isChat ? '8px' : '14px',
|
|
marginBottom: isChat ? '4px' : '6px',
|
|
background: isChat ? 'rgba(241, 245, 249, 0.8)' : '#f8fafc',
|
|
padding: '4px 10px',
|
|
borderRadius: 6,
|
|
borderRight: '3px solid #6366f1'
|
|
}}
|
|
>
|
|
{parseInline(h3Match[1])}
|
|
</h3>
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const h4Match = trimmed.match(/^####\s+(.+)$/);
|
|
if (h4Match) {
|
|
flushList();
|
|
elements.push(
|
|
<h4
|
|
key={elementKey++}
|
|
style={{
|
|
fontSize: isChat ? '0.86rem' : '0.96rem',
|
|
fontWeight: 800,
|
|
color: '#475569',
|
|
marginTop: isChat ? '6px' : '10px',
|
|
marginBottom: '4px'
|
|
}}
|
|
>
|
|
{parseInline(h4Match[1])}
|
|
</h4>
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Unordered List Items (- or * or •)
|
|
const ulMatch = trimmed.match(/^[-*•]\s+(.+)$/);
|
|
if (ulMatch) {
|
|
if (!currentList || currentList.type !== 'ul') {
|
|
flushList();
|
|
currentList = { type: 'ul', items: [] };
|
|
}
|
|
currentList.items.push(parseInline(ulMatch[1]));
|
|
continue;
|
|
}
|
|
|
|
// Ordered List Items (1. , 2. )
|
|
const olMatch = trimmed.match(/^(\d+)\.\s+(.+)$/);
|
|
if (olMatch) {
|
|
if (!currentList || currentList.type !== 'ol') {
|
|
flushList();
|
|
currentList = { type: 'ol', items: [] };
|
|
}
|
|
currentList.items.push(parseInline(olMatch[2]));
|
|
continue;
|
|
}
|
|
|
|
// Blockquote (> ...)
|
|
const bqMatch = trimmed.match(/^>\s*(.+)$/);
|
|
if (bqMatch) {
|
|
flushList();
|
|
elements.push(
|
|
<blockquote
|
|
key={elementKey++}
|
|
style={{
|
|
margin: '8px 0',
|
|
padding: '6px 14px',
|
|
background: '#f8fafc',
|
|
borderRight: '4px solid #0071e3',
|
|
color: '#334155',
|
|
fontSize: isChat ? '0.84rem' : '0.9rem',
|
|
lineHeight: 1.7,
|
|
borderRadius: '0 8px 8px 0'
|
|
}}
|
|
>
|
|
{parseInline(bqMatch[1])}
|
|
</blockquote>
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Regular Paragraph
|
|
flushList();
|
|
elements.push(
|
|
<p
|
|
key={elementKey++}
|
|
style={{
|
|
margin: isChat ? '4px 0 6px 0' : '6px 0 10px 0',
|
|
lineHeight: isChat ? 1.7 : 1.9,
|
|
color: '#1e293b',
|
|
fontSize: isChat ? '0.86rem' : '0.92rem'
|
|
}}
|
|
>
|
|
{parseInline(trimmed)}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
flushList();
|
|
|
|
return (
|
|
<div
|
|
className="tactical-markdown-content"
|
|
style={{
|
|
direction: 'rtl',
|
|
textAlign: 'right',
|
|
fontFamily: 'inherit',
|
|
color: '#1e293b',
|
|
wordBreak: 'break-word'
|
|
}}
|
|
>
|
|
{elements}
|
|
</div>
|
|
);
|
|
};
|