Files
maps-saas/apps/web/src/components/TacticalMarkdownRenderer.tsx
T

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>
);
};