|
@@ -0,0 +1,407 @@
|
|
|
|
|
+import { useMemo } from 'react';
|
|
|
|
|
+import { View, Text, RichText } from '@tarojs/components';
|
|
|
|
|
+
|
|
|
|
|
+// 定义RichText节点类型
|
|
|
|
|
+interface RichTextNode {
|
|
|
|
|
+ name?: string;
|
|
|
|
|
+ attrs?: Record<string, string>;
|
|
|
|
|
+ children?: (RichTextNode | { type: 'text'; text: string })[];
|
|
|
|
|
+ type?: 'text';
|
|
|
|
|
+ text?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 列表项接口(包含内容和可能的子列表)
|
|
|
|
|
+interface ListItem {
|
|
|
|
|
+ content: React.ReactNode;
|
|
|
|
|
+ children?: ListLevel[]; // 嵌套在该项下的子列表
|
|
|
|
|
+ key: string; // 唯一标识
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 列表层级信息接口
|
|
|
|
|
+interface ListLevel {
|
|
|
|
|
+ type: 'ul' | 'ol';
|
|
|
|
|
+ items: ListItem[];
|
|
|
|
|
+ depth: number; // 缩进深度,用于判断嵌套关系
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 生成稳定的key(基于内容哈希)
|
|
|
|
|
+const getStableKey = (content: string, index: number = 0): string => {
|
|
|
|
|
+ let hash = 0;
|
|
|
|
|
+ const contentWithIndex = content + index.toString();
|
|
|
|
|
+ for (let i = 0; i < contentWithIndex.length; i++) {
|
|
|
|
|
+ const char = contentWithIndex.charCodeAt(i);
|
|
|
|
|
+ hash = ((hash << 5) - hash) + char;
|
|
|
|
|
+ hash = hash & hash; // 转换为32位整数
|
|
|
|
|
+ }
|
|
|
|
|
+ return `hash-${Math.abs(hash)}`;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 行内元素解析规则
|
|
|
|
|
+const inlineRules = [
|
|
|
|
|
+ {
|
|
|
|
|
+ regex: /`([^`]+)`/,
|
|
|
|
|
+ createNode: (content: string) => ({
|
|
|
|
|
+ name: 'code',
|
|
|
|
|
+ attrs: {},
|
|
|
|
|
+ children: parseInlineToNodes(content)
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ regex: /!\[(.*?)\]\((.*?)\)/,
|
|
|
|
|
+ createNode: (alt: string, url: string) => ({
|
|
|
|
|
+ name: 'img',
|
|
|
|
|
+ attrs: { src: url, alt },
|
|
|
|
|
+ children: []
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ regex: /\[(.*?)\]\((.*?)\)/,
|
|
|
|
|
+ createNode: (text: string, url: string) => ({
|
|
|
|
|
+ name: 'a',
|
|
|
|
|
+ attrs: { href: url },
|
|
|
|
|
+ children: parseInlineToNodes(text)
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ regex: /\*\*(.*?)\*\*/,
|
|
|
|
|
+ createNode: (content: string) => ({
|
|
|
|
|
+ name: 'strong',
|
|
|
|
|
+ attrs: {},
|
|
|
|
|
+ children: parseInlineToNodes(content)
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ regex: /__(.*?)__/,
|
|
|
|
|
+ createNode: (content: string) => ({
|
|
|
|
|
+ name: 'strong',
|
|
|
|
|
+ attrs: {},
|
|
|
|
|
+ children: parseInlineToNodes(content)
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ regex: /\*(.*?)\*/,
|
|
|
|
|
+ createNode: (content: string) => ({
|
|
|
|
|
+ name: 'em',
|
|
|
|
|
+ attrs: {},
|
|
|
|
|
+ children: parseInlineToNodes(content)
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ regex: /_(.*?)_/,
|
|
|
|
|
+ createNode: (content: string) => ({
|
|
|
|
|
+ name: 'em',
|
|
|
|
|
+ attrs: {},
|
|
|
|
|
+ children: parseInlineToNodes(content)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+// 递归解析行内元素
|
|
|
|
|
+const parseInlineToNodes = (text: string): RichTextNode[] => {
|
|
|
|
|
+ const nodes: RichTextNode[] = [];
|
|
|
|
|
+ let remaining = text;
|
|
|
|
|
+
|
|
|
|
|
+ while (remaining.length > 0) {
|
|
|
|
|
+ let matched = false;
|
|
|
|
|
+ let bestMatch: { index: number; rule: any; groups: string[] } | null = null;
|
|
|
|
|
+
|
|
|
|
|
+ for (const rule of inlineRules) {
|
|
|
|
|
+ const matches = remaining.match(rule.regex);
|
|
|
|
|
+ if (matches && matches.index !== undefined) {
|
|
|
|
|
+ if (!bestMatch || matches.index < bestMatch.index) {
|
|
|
|
|
+ bestMatch = {
|
|
|
|
|
+ index: matches.index,
|
|
|
|
|
+ rule,
|
|
|
|
|
+ groups: matches.slice(1)
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ matched = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (bestMatch) {
|
|
|
|
|
+ if (bestMatch.index > 0) {
|
|
|
|
|
+ nodes.push({ type: 'text', text: remaining.substring(0, bestMatch.index) });
|
|
|
|
|
+ }
|
|
|
|
|
+ const node = bestMatch.rule.createNode(...bestMatch.groups);
|
|
|
|
|
+ nodes.push(node);
|
|
|
|
|
+ const fullMatch = remaining.match(bestMatch.rule.regex)![0];
|
|
|
|
|
+ remaining = remaining.substring(bestMatch.index + fullMatch.length);
|
|
|
|
|
+ } else if (matched) {
|
|
|
|
|
+ nodes.push({ type: 'text', text: remaining });
|
|
|
|
|
+ break;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ nodes.push({ type: 'text', text: remaining });
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return nodes;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 计算列表项的缩进深度(4个空格为一级)
|
|
|
|
|
+const getIndentDepth = (line: string): number => {
|
|
|
|
|
+ const spaces = line.match(/^\s*/)![0];
|
|
|
|
|
+ return Math.floor(spaces.length / 4); // 每4个空格算一级缩进
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 渲染列表项及其子列表
|
|
|
|
|
+const renderListItems = (items: ListItem[], level: number) => {
|
|
|
|
|
+ return items.map((item, index) => {
|
|
|
|
|
+ // 渲染子列表
|
|
|
|
|
+ const renderChildLists = () => {
|
|
|
|
|
+ if (!item.children || item.children.length === 0) return null;
|
|
|
|
|
+
|
|
|
|
|
+ return item.children.map((childLevel, childIndex) => {
|
|
|
|
|
+ const childListKey = getStableKey(`child-list-${childLevel.type}-${childIndex}`, index);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <View
|
|
|
|
|
+ key={childListKey}
|
|
|
|
|
+ className={`markdown-list markdown-${childLevel.type} depth-${childLevel.depth}`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {renderListItems(childLevel.items, level + 1)}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <View key={item.key} className="markdown-list-item">
|
|
|
|
|
+ <View className="markdown-list-item-content">{item.content}</View>
|
|
|
|
|
+ {renderChildLists()}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+interface MarkdownParserProps {
|
|
|
|
|
+ content: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const MarkdownParser = ({ content }: MarkdownParserProps) => {
|
|
|
|
|
+ const parsedElements = useMemo(() => {
|
|
|
|
|
+ const lines = content.split('\n');
|
|
|
|
|
+ const elements: React.ReactNode[] = [];
|
|
|
|
|
+ let inCodeBlock = false;
|
|
|
|
|
+ let currentCodeBlock = '';
|
|
|
|
|
+ let codeLang = '';
|
|
|
|
|
+
|
|
|
|
|
+ // 列表栈:管理嵌套列表层级(栈顶为当前层级)
|
|
|
|
|
+ const listStack: ListLevel[] = [];
|
|
|
|
|
+
|
|
|
|
|
+ // 刷新当前列表(将栈中所有列表弹出并嵌套组装)
|
|
|
|
|
+ const flushLists = () => {
|
|
|
|
|
+ if (listStack.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 从顶层渲染列表
|
|
|
|
|
+ listStack.forEach((level, index) => {
|
|
|
|
|
+ const listKey = getStableKey(`list-${level.type}-${level.depth}-${index}`);
|
|
|
|
|
+ elements.push(
|
|
|
|
|
+ <View key={listKey} className={`markdown-list markdown-${level.type} depth-${level.depth}`}>
|
|
|
|
|
+ {renderListItems(level.items, index)}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 清空栈
|
|
|
|
|
+ listStack.length = 0;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 处理代码块
|
|
|
|
|
+ const flushCodeBlock = () => {
|
|
|
|
|
+ if (inCodeBlock && currentCodeBlock) {
|
|
|
|
|
+ const codeKey = getStableKey(currentCodeBlock);
|
|
|
|
|
+ elements.push(
|
|
|
|
|
+ <View key={codeKey} className="markdown-code-block">
|
|
|
|
|
+ {codeLang && <Text className="markdown-code-lang">{codeLang}</Text>}
|
|
|
|
|
+ <Text className="markdown-code">{currentCodeBlock}</Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ );
|
|
|
|
|
+ inCodeBlock = false;
|
|
|
|
|
+ currentCodeBlock = '';
|
|
|
|
|
+ codeLang = '';
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ lines.forEach((line, lineIndex) => {
|
|
|
|
|
+ // 处理空行(保留结构,但不添加多余元素)
|
|
|
|
|
+ const trimmedLine = line.trim();
|
|
|
|
|
+ if (!trimmedLine && !inCodeBlock) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 处理代码块
|
|
|
|
|
+ if (/^```/.test(trimmedLine)) {
|
|
|
|
|
+ flushLists(); // 遇到代码块,先刷新所有列表
|
|
|
|
|
+ if (inCodeBlock) {
|
|
|
|
|
+ flushCodeBlock();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ inCodeBlock = true;
|
|
|
|
|
+ codeLang = trimmedLine.replace(/^```/, '').trim();
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (inCodeBlock) {
|
|
|
|
|
+ currentCodeBlock += line + '\n';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 处理标题
|
|
|
|
|
+ const headingMatch = line.match(/^\s*#{1,6}\s+/);
|
|
|
|
|
+ if (headingMatch) {
|
|
|
|
|
+ flushLists(); // 遇到标题,刷新所有列表
|
|
|
|
|
+ const level = headingMatch[0].match(/#/g)?.length || 1;
|
|
|
|
|
+ const text = line.replace(/^\s*#{1,6}\s+/, '');
|
|
|
|
|
+ const headingKey = getStableKey(`h${level}-${text}`, lineIndex);
|
|
|
|
|
+ elements.push(
|
|
|
|
|
+ <View key={headingKey} className={`markdown-h${level}`}>
|
|
|
|
|
+ <RichText nodes={parseInlineToNodes(text)} />
|
|
|
|
|
+ </View>
|
|
|
|
|
+ );
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 处理无序列表(支持嵌套)
|
|
|
|
|
+ const ulMatch = line.match(/^\s*[-*+]\s+/);
|
|
|
|
|
+ if (ulMatch) {
|
|
|
|
|
+ const depth = getIndentDepth(line); // 当前列表项的缩进深度
|
|
|
|
|
+ const text = line.replace(/^\s*[-*+]\s+/, ''); // 提取文本内容
|
|
|
|
|
+ const itemKey = getStableKey(`ul-item-${text}`, lineIndex);
|
|
|
|
|
+
|
|
|
|
|
+ // 创建新列表项
|
|
|
|
|
+ const newItem: ListItem = {
|
|
|
|
|
+ content: <RichText nodes={parseInlineToNodes(text)} />,
|
|
|
|
|
+ key: itemKey
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 1. 调整列表栈到当前深度(弹出过深的层级)
|
|
|
|
|
+ while (listStack.length > depth) {
|
|
|
|
|
+ const poppedLevel = listStack.pop()!;
|
|
|
|
|
+ // 如果栈不为空,将弹出的层级作为上一级最后一个项的子列表
|
|
|
|
|
+ if (listStack.length > 0) {
|
|
|
|
|
+ const parentLevel = listStack[listStack.length - 1];
|
|
|
|
|
+ if (parentLevel.items.length > 0) {
|
|
|
|
|
+ const lastParentItem = parentLevel.items[parentLevel.items.length - 1];
|
|
|
|
|
+ if (!lastParentItem.children) {
|
|
|
|
|
+ lastParentItem.children = [];
|
|
|
|
|
+ }
|
|
|
|
|
+ lastParentItem.children.push(poppedLevel);
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 如果栈为空,直接渲染弹出的层级(这种情况不应该发生)
|
|
|
|
|
+ const listKey = getStableKey(`unexpected-list-${poppedLevel.type}-${poppedLevel.depth}`);
|
|
|
|
|
+ elements.push(
|
|
|
|
|
+ <View key={listKey} className={`markdown-list markdown-${poppedLevel.type} depth-${poppedLevel.depth}`}>
|
|
|
|
|
+ {renderListItems(poppedLevel.items, 0)}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 如果栈为空或当前深度大于栈顶深度,创建新层级
|
|
|
|
|
+ if (listStack.length === 0 || depth > listStack[listStack.length - 1].depth) {
|
|
|
|
|
+ listStack.push({
|
|
|
|
|
+ type: 'ul',
|
|
|
|
|
+ items: [newItem],
|
|
|
|
|
+ depth
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 3. 否则添加到当前层级
|
|
|
|
|
+ listStack[listStack.length - 1].items.push(newItem);
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 处理有序列表(支持嵌套)
|
|
|
|
|
+ const olMatch = line.match(/^\s*\d+\.\s+/);
|
|
|
|
|
+ if (olMatch) {
|
|
|
|
|
+ const depth = getIndentDepth(line);
|
|
|
|
|
+ const text = line.replace(/^\s*\d+\.\s+/, '');
|
|
|
|
|
+ const itemKey = getStableKey(`ol-item-${text}`, lineIndex);
|
|
|
|
|
+
|
|
|
|
|
+ const newItem: ListItem = {
|
|
|
|
|
+ content: <RichText nodes={parseInlineToNodes(text)} />,
|
|
|
|
|
+ key: itemKey
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ while (listStack.length > depth) {
|
|
|
|
|
+ const poppedLevel = listStack.pop()!;
|
|
|
|
|
+ if (listStack.length > 0) {
|
|
|
|
|
+ const parentLevel = listStack[listStack.length - 1];
|
|
|
|
|
+ if (parentLevel.items.length > 0) {
|
|
|
|
|
+ const lastParentItem = parentLevel.items[parentLevel.items.length - 1];
|
|
|
|
|
+ if (!lastParentItem.children) {
|
|
|
|
|
+ lastParentItem.children = [];
|
|
|
|
|
+ }
|
|
|
|
|
+ lastParentItem.children.push(poppedLevel);
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const listKey = getStableKey(`unexpected-list-${poppedLevel.type}-${poppedLevel.depth}`);
|
|
|
|
|
+ elements.push(
|
|
|
|
|
+ <View key={listKey} className={`markdown-list markdown-${poppedLevel.type} depth-${poppedLevel.depth}`}>
|
|
|
|
|
+ {renderListItems(poppedLevel.items, 0)}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (listStack.length === 0 || depth > listStack[listStack.length - 1].depth) {
|
|
|
|
|
+ listStack.push({
|
|
|
|
|
+ type: 'ol',
|
|
|
|
|
+ items: [newItem],
|
|
|
|
|
+ depth
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ listStack[listStack.length - 1].items.push(newItem);
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 处理引用
|
|
|
|
|
+ const quoteMatch = line.match(/^\s*>\s+/);
|
|
|
|
|
+ if (quoteMatch) {
|
|
|
|
|
+ flushLists();
|
|
|
|
|
+ const text = line.replace(/^\s*>\s+/, '');
|
|
|
|
|
+ const quoteKey = getStableKey(`quote-${text}`, lineIndex);
|
|
|
|
|
+ elements.push(
|
|
|
|
|
+ <View key={quoteKey} className="markdown-quote">
|
|
|
|
|
+ <RichText nodes={parseInlineToNodes(text)} />
|
|
|
|
|
+ </View>
|
|
|
|
|
+ );
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 处理分隔线
|
|
|
|
|
+ if (/^\s*[-*]{3,}\s*$/.test(line)) {
|
|
|
|
|
+ flushLists();
|
|
|
|
|
+ const hrKey = getStableKey(`hr-${line}`, lineIndex);
|
|
|
|
|
+ elements.push(
|
|
|
|
|
+ <View key={hrKey} className="markdown-hr" />
|
|
|
|
|
+ );
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 处理普通文本行
|
|
|
|
|
+ flushLists(); // 普通文本前刷新所有列表
|
|
|
|
|
+ const paragraphKey = getStableKey(`p-${line}`, lineIndex);
|
|
|
|
|
+ elements.push(
|
|
|
|
|
+ <View key={paragraphKey} className="markdown-paragraph">
|
|
|
|
|
+ <RichText nodes={parseInlineToNodes(line)} />
|
|
|
|
|
+ </View>
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 解析结束后刷新剩余列表和代码块
|
|
|
|
|
+ flushLists();
|
|
|
|
|
+ flushCodeBlock();
|
|
|
|
|
+
|
|
|
|
|
+ return elements;
|
|
|
|
|
+ }, [content]);
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <View className="markdown-viewer">
|
|
|
|
|
+ {parsedElements}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ );
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+export default MarkdownParser;
|