fix: 优化markdown解析器
This commit is contained in:
499
apps/desktop/src/components/MarkdownParserRenderer.tsx
Normal file
499
apps/desktop/src/components/MarkdownParserRenderer.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user