Files
mixvideo-v2/apps/desktop/src/components/MarkdownParserRenderer.tsx
2025-07-22 15:04:37 +08:00

500 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;