500 lines
16 KiB
TypeScript
500 lines
16 KiB
TypeScript
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||
import { markdownService } from '../services/markdownService';
|
||
import {
|
||
MarkdownParseResult,
|
||
MarkdownNode,
|
||
OutlineItem,
|
||
LinkInfo,
|
||
ValidationResult,
|
||
MarkdownParserConfig,
|
||
ValidationSeverity,
|
||
MarkdownNodeType,
|
||
} from '../types/markdown';
|
||
|
||
/**
|
||
* Markdown解析渲染器属性
|
||
*/
|
||
interface MarkdownParserRendererProps {
|
||
/** Markdown文本内容 */
|
||
content: string;
|
||
/** 解析器配置 */
|
||
config?: MarkdownParserConfig;
|
||
/** 是否显示大纲 */
|
||
showOutline?: boolean;
|
||
/** 是否显示链接列表 */
|
||
showLinks?: boolean;
|
||
/** 是否显示验证结果 */
|
||
showValidation?: boolean;
|
||
/** 是否显示位置信息 */
|
||
showPositionInfo?: boolean;
|
||
/** 是否启用实时解析 */
|
||
enableRealTimeParsing?: boolean;
|
||
/** 自定义样式类名 */
|
||
className?: string;
|
||
/** 点击节点时的回调 */
|
||
onNodeClick?: (node: MarkdownNode) => void;
|
||
/** 解析完成时的回调 */
|
||
onParseComplete?: (result: MarkdownParseResult) => void;
|
||
}
|
||
|
||
/**
|
||
* 基于Tree-sitter的Markdown解析渲染器组件
|
||
* 支持原文引用、位置信息显示和交互式节点选择
|
||
*/
|
||
export const MarkdownParserRenderer: React.FC<MarkdownParserRendererProps> = ({
|
||
content,
|
||
config,
|
||
showOutline = false,
|
||
showLinks = false,
|
||
showValidation = false,
|
||
showPositionInfo = false,
|
||
enableRealTimeParsing = false,
|
||
className = '',
|
||
onNodeClick,
|
||
onParseComplete,
|
||
}) => {
|
||
const [parseResult, setParseResult] = useState<MarkdownParseResult | null>(null);
|
||
const [outline, setOutline] = useState<OutlineItem[]>([]);
|
||
const [links, setLinks] = useState<LinkInfo[]>([]);
|
||
const [validation, setValidation] = useState<ValidationResult | null>(null);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [selectedNode, setSelectedNode] = useState<MarkdownNode | null>(null);
|
||
|
||
/**
|
||
* 解析Markdown内容
|
||
*/
|
||
const parseContent = useCallback(async () => {
|
||
if (!content.trim()) {
|
||
setParseResult(null);
|
||
setOutline([]);
|
||
setLinks([]);
|
||
setValidation(null);
|
||
return;
|
||
}
|
||
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
// 解析主要内容
|
||
const result = await markdownService.parseMarkdown(content, config);
|
||
setParseResult(result);
|
||
onParseComplete?.(result);
|
||
|
||
// 提取大纲
|
||
if (showOutline) {
|
||
const outlineItems = await markdownService.extractOutline(content);
|
||
setOutline(outlineItems);
|
||
}
|
||
|
||
// 提取链接
|
||
if (showLinks) {
|
||
const linkItems = await markdownService.extractLinks(content);
|
||
setLinks(linkItems);
|
||
}
|
||
|
||
// 验证文档
|
||
if (showValidation) {
|
||
const validationResult = await markdownService.validateMarkdown(content);
|
||
setValidation(validationResult);
|
||
}
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [content, config, showOutline, showLinks, showValidation, onParseComplete]);
|
||
|
||
/**
|
||
* 实时解析效果
|
||
*/
|
||
useEffect(() => {
|
||
if (enableRealTimeParsing) {
|
||
const timeoutId = setTimeout(parseContent, 300); // 防抖
|
||
return () => clearTimeout(timeoutId);
|
||
}
|
||
}, [content, enableRealTimeParsing, parseContent]);
|
||
|
||
/**
|
||
* 初始解析
|
||
*/
|
||
useEffect(() => {
|
||
if (!enableRealTimeParsing) {
|
||
parseContent();
|
||
}
|
||
}, [parseContent, enableRealTimeParsing]);
|
||
|
||
/**
|
||
* 渲染单个节点
|
||
*/
|
||
const renderNode = useCallback((node: MarkdownNode, depth: number = 0): React.ReactNode => {
|
||
const handleNodeClick = () => {
|
||
setSelectedNode(node);
|
||
onNodeClick?.(node);
|
||
};
|
||
|
||
const isSelected = selectedNode === node;
|
||
const nodeClasses = `
|
||
markdown-node
|
||
markdown-node-${node.node_type.toLowerCase()}
|
||
${isSelected ? 'selected' : ''}
|
||
${showPositionInfo ? 'with-position' : ''}
|
||
cursor-pointer hover:bg-gray-100 transition-colors
|
||
`.trim();
|
||
|
||
const positionInfo = showPositionInfo ? (
|
||
<span className="position-info text-xs text-gray-500 ml-2">
|
||
[{markdownService.formatRange(node.range.start, node.range.end)}]
|
||
</span>
|
||
) : null;
|
||
|
||
switch (node.node_type) {
|
||
case MarkdownNodeType.Heading:
|
||
const level = parseInt(node.attributes.level || '1');
|
||
const HeadingTag = `h${Math.min(level, 6)}` as keyof JSX.IntrinsicElements;
|
||
return (
|
||
<HeadingTag
|
||
key={`${node.range.start.line}-${node.range.start.column}`}
|
||
className={nodeClasses}
|
||
onClick={handleNodeClick}
|
||
style={{ marginLeft: `${depth * 20}px` }}
|
||
>
|
||
{markdownService.extractTextContent(node)}
|
||
{positionInfo}
|
||
</HeadingTag>
|
||
);
|
||
|
||
case MarkdownNodeType.Paragraph:
|
||
return (
|
||
<p
|
||
key={`${node.range.start.line}-${node.range.start.column}`}
|
||
className={nodeClasses}
|
||
onClick={handleNodeClick}
|
||
style={{ marginLeft: `${depth * 20}px` }}
|
||
>
|
||
{node.children.map((child, index) => renderNode(child, depth + 1))}
|
||
{positionInfo}
|
||
</p>
|
||
);
|
||
|
||
case MarkdownNodeType.CodeBlock:
|
||
const language = node.attributes.language || 'text';
|
||
return (
|
||
<pre
|
||
key={`${node.range.start.line}-${node.range.start.column}`}
|
||
className={`${nodeClasses} bg-gray-100 p-4 rounded overflow-x-auto`}
|
||
onClick={handleNodeClick}
|
||
style={{ marginLeft: `${depth * 20}px` }}
|
||
>
|
||
<code className={`language-${language}`}>
|
||
{markdownService.extractTextContent(node)}
|
||
</code>
|
||
{positionInfo}
|
||
</pre>
|
||
);
|
||
|
||
case MarkdownNodeType.Link:
|
||
const url = node.attributes.url || '#';
|
||
const title = node.attributes.title;
|
||
return (
|
||
<a
|
||
key={`${node.range.start.line}-${node.range.start.column}`}
|
||
href={url}
|
||
title={title}
|
||
className={`${nodeClasses} text-blue-600 hover:text-blue-800 underline`}
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
handleNodeClick();
|
||
}}
|
||
style={{ marginLeft: `${depth * 20}px` }}
|
||
>
|
||
{markdownService.extractTextContent(node)}
|
||
{positionInfo}
|
||
</a>
|
||
);
|
||
|
||
case MarkdownNodeType.Image:
|
||
const src = node.attributes.src || '';
|
||
const alt = node.attributes.alt || '';
|
||
return (
|
||
<img
|
||
key={`${node.range.start.line}-${node.range.start.column}`}
|
||
src={src}
|
||
alt={alt}
|
||
className={`${nodeClasses} max-w-full h-auto`}
|
||
onClick={handleNodeClick}
|
||
style={{ marginLeft: `${depth * 20}px` }}
|
||
/>
|
||
);
|
||
|
||
case MarkdownNodeType.List:
|
||
const ListTag = node.content.trim().startsWith('1.') ? 'ol' : 'ul';
|
||
return (
|
||
<ListTag
|
||
key={`${node.range.start.line}-${node.range.start.column}`}
|
||
className={nodeClasses}
|
||
onClick={handleNodeClick}
|
||
style={{ marginLeft: `${depth * 20}px` }}
|
||
>
|
||
{node.children.map((child, index) => renderNode(child, depth + 1))}
|
||
{positionInfo}
|
||
</ListTag>
|
||
);
|
||
|
||
case MarkdownNodeType.ListItem:
|
||
return (
|
||
<li
|
||
key={`${node.range.start.line}-${node.range.start.column}`}
|
||
className={nodeClasses}
|
||
onClick={handleNodeClick}
|
||
style={{ marginLeft: `${depth * 20}px` }}
|
||
>
|
||
{node.children.map((child, index) => renderNode(child, depth + 1))}
|
||
{positionInfo}
|
||
</li>
|
||
);
|
||
|
||
case MarkdownNodeType.Emphasis:
|
||
return (
|
||
<em
|
||
key={`${node.range.start.line}-${node.range.start.column}`}
|
||
className={nodeClasses}
|
||
onClick={handleNodeClick}
|
||
>
|
||
{node.children.map((child, index) => renderNode(child, depth + 1))}
|
||
{positionInfo}
|
||
</em>
|
||
);
|
||
|
||
case MarkdownNodeType.Strong:
|
||
return (
|
||
<strong
|
||
key={`${node.range.start.line}-${node.range.start.column}`}
|
||
className={nodeClasses}
|
||
onClick={handleNodeClick}
|
||
>
|
||
{node.children.map((child, index) => renderNode(child, depth + 1))}
|
||
{positionInfo}
|
||
</strong>
|
||
);
|
||
|
||
case MarkdownNodeType.Text:
|
||
return (
|
||
<span
|
||
key={`${node.range.start.line}-${node.range.start.column}`}
|
||
className={nodeClasses}
|
||
onClick={handleNodeClick}
|
||
>
|
||
{node.content}
|
||
{positionInfo}
|
||
</span>
|
||
);
|
||
|
||
default:
|
||
return (
|
||
<div
|
||
key={`${node.range.start.line}-${node.range.start.column}`}
|
||
className={nodeClasses}
|
||
onClick={handleNodeClick}
|
||
style={{ marginLeft: `${depth * 20}px` }}
|
||
>
|
||
{node.children.map((child, index) => renderNode(child, depth + 1))}
|
||
{positionInfo}
|
||
</div>
|
||
);
|
||
}
|
||
}, [selectedNode, showPositionInfo, onNodeClick]);
|
||
|
||
/**
|
||
* 渲染大纲
|
||
*/
|
||
const renderOutline = useMemo(() => {
|
||
if (!showOutline || outline.length === 0) return null;
|
||
|
||
return (
|
||
<div className="markdown-outline mb-4 p-4 bg-gray-50 rounded">
|
||
<h3 className="text-lg font-semibold mb-2">目录</h3>
|
||
<ul className="space-y-1">
|
||
{outline.map((item, index) => (
|
||
<li
|
||
key={index}
|
||
className="cursor-pointer hover:text-blue-600"
|
||
style={{ marginLeft: `${(item.level - 1) * 16}px` }}
|
||
onClick={() => {
|
||
// 可以添加滚动到对应位置的逻辑
|
||
console.log('Navigate to:', item);
|
||
}}
|
||
>
|
||
<span className="text-sm text-gray-500">H{item.level}</span>
|
||
<span className="ml-2">{item.title}</span>
|
||
{showPositionInfo && (
|
||
<span className="text-xs text-gray-400 ml-2">
|
||
[Line {item.range.start.line + 1}]
|
||
</span>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}, [showOutline, outline, showPositionInfo]);
|
||
|
||
/**
|
||
* 渲染链接列表
|
||
*/
|
||
const renderLinks = useMemo(() => {
|
||
if (!showLinks || links.length === 0) return null;
|
||
|
||
return (
|
||
<div className="markdown-links mb-4 p-4 bg-blue-50 rounded">
|
||
<h3 className="text-lg font-semibold mb-2">链接</h3>
|
||
<ul className="space-y-2">
|
||
{links.map((link, index) => (
|
||
<li key={index} className="flex items-center space-x-2">
|
||
<span className="text-sm text-gray-500">
|
||
{link.link_type === 'Image' ? '🖼️' : '🔗'}
|
||
</span>
|
||
<a
|
||
href={link.url}
|
||
className="text-blue-600 hover:text-blue-800 underline"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
{link.text || link.url}
|
||
</a>
|
||
{showPositionInfo && (
|
||
<span className="text-xs text-gray-400">
|
||
[Line {link.range.start.line + 1}]
|
||
</span>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}, [showLinks, links, showPositionInfo]);
|
||
|
||
/**
|
||
* 渲染验证结果
|
||
*/
|
||
const renderValidation = useMemo(() => {
|
||
if (!showValidation || !validation) return null;
|
||
|
||
const { is_valid, issues } = validation;
|
||
|
||
return (
|
||
<div className={`markdown-validation mb-4 p-4 rounded ${
|
||
is_valid ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'
|
||
}`}>
|
||
<h3 className="text-lg font-semibold mb-2">
|
||
验证结果 {is_valid ? '✅' : '❌'}
|
||
</h3>
|
||
{issues.length > 0 && (
|
||
<ul className="space-y-2">
|
||
{issues.map((issue, index) => (
|
||
<li
|
||
key={index}
|
||
className={`flex items-start space-x-2 ${
|
||
issue.severity === ValidationSeverity.Error ? 'text-red-600' :
|
||
issue.severity === ValidationSeverity.Warning ? 'text-yellow-600' :
|
||
'text-blue-600'
|
||
}`}
|
||
>
|
||
<span className="text-sm">
|
||
{issue.severity === ValidationSeverity.Error ? '❌' :
|
||
issue.severity === ValidationSeverity.Warning ? '⚠️' : 'ℹ️'}
|
||
</span>
|
||
<div>
|
||
<div>{issue.message}</div>
|
||
{showPositionInfo && (
|
||
<div className="text-xs text-gray-500">
|
||
Line {issue.range.start.line + 1}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
}, [showValidation, validation, showPositionInfo]);
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className={`markdown-parser-renderer ${className}`}>
|
||
<div className="flex items-center justify-center p-8">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||
<span className="ml-2">解析中...</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className={`markdown-parser-renderer ${className}`}>
|
||
<div className="bg-red-50 border border-red-200 rounded p-4">
|
||
<h3 className="text-red-800 font-semibold">解析错误</h3>
|
||
<p className="text-red-600 mt-2">{error}</p>
|
||
<button
|
||
onClick={parseContent}
|
||
className="mt-2 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||
>
|
||
重试
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`markdown-parser-renderer ${className}`}>
|
||
{renderOutline}
|
||
{renderLinks}
|
||
{renderValidation}
|
||
|
||
<div className="markdown-content">
|
||
{parseResult ? (
|
||
<div className="parsed-content">
|
||
{renderNode(parseResult.root)}
|
||
</div>
|
||
) : (
|
||
<div className="text-gray-500 text-center p-8">
|
||
暂无内容
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{selectedNode && (
|
||
<div className="selected-node-info mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded">
|
||
<h4 className="font-semibold">选中节点信息</h4>
|
||
<div className="mt-2 text-sm">
|
||
<div><strong>类型:</strong> {selectedNode.node_type}</div>
|
||
<div><strong>位置:</strong> {markdownService.formatRange(selectedNode.range.start, selectedNode.range.end)}</div>
|
||
<div><strong>内容长度:</strong> {selectedNode.content.length} 字符</div>
|
||
{Object.keys(selectedNode.attributes).length > 0 && (
|
||
<div><strong>属性:</strong> {JSON.stringify(selectedNode.attributes, null, 2)}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{parseResult && (
|
||
<div className="parse-statistics mt-4 p-4 bg-gray-50 rounded text-sm">
|
||
<h4 className="font-semibold mb-2">解析统计</h4>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>总节点数: {parseResult.statistics.total_nodes}</div>
|
||
<div>错误节点数: {parseResult.statistics.error_nodes}</div>
|
||
<div>解析耗时: {parseResult.statistics.parse_time_ms}ms</div>
|
||
<div>最大深度: {parseResult.statistics.max_depth}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default MarkdownParserRenderer;
|