MarkdownParser.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. import { useMemo } from 'react';
  2. import { View, Text, RichText } from '@tarojs/components';
  3. // 定义RichText节点类型
  4. interface RichTextNode {
  5. name?: string;
  6. attrs?: Record<string, string>;
  7. children?: (RichTextNode | { type: 'text'; text: string })[];
  8. type?: 'text';
  9. text?: string;
  10. }
  11. // 列表项接口(包含内容和可能的子列表)
  12. interface ListItem {
  13. content: React.ReactNode;
  14. children?: ListLevel[]; // 嵌套在该项下的子列表
  15. key: string; // 唯一标识
  16. }
  17. // 列表层级信息接口
  18. interface ListLevel {
  19. type: 'ul' | 'ol';
  20. items: ListItem[];
  21. depth: number; // 缩进深度,用于判断嵌套关系
  22. }
  23. // 生成稳定的key(基于内容哈希)
  24. const getStableKey = (content: string, index: number = 0): string => {
  25. let hash = 0;
  26. const contentWithIndex = content + index.toString();
  27. for (let i = 0; i < contentWithIndex.length; i++) {
  28. const char = contentWithIndex.charCodeAt(i);
  29. hash = ((hash << 5) - hash) + char;
  30. hash = hash & hash; // 转换为32位整数
  31. }
  32. return `hash-${Math.abs(hash)}`;
  33. };
  34. // 行内元素解析规则
  35. const inlineRules = [
  36. {
  37. regex: /`([^`]+)`/,
  38. createNode: (content: string) => ({
  39. name: 'code',
  40. attrs: {},
  41. children: parseInlineToNodes(content)
  42. })
  43. },
  44. {
  45. regex: /!\[(.*?)\]\((.*?)\)/,
  46. createNode: (alt: string, url: string) => ({
  47. name: 'img',
  48. attrs: { src: url, alt },
  49. children: []
  50. })
  51. },
  52. {
  53. regex: /\[(.*?)\]\((.*?)\)/,
  54. createNode: (text: string, url: string) => ({
  55. name: 'a',
  56. attrs: { href: url },
  57. children: parseInlineToNodes(text)
  58. })
  59. },
  60. {
  61. regex: /\*\*(.*?)\*\*/,
  62. createNode: (content: string) => ({
  63. name: 'strong',
  64. attrs: {},
  65. children: parseInlineToNodes(content)
  66. })
  67. },
  68. {
  69. regex: /__(.*?)__/,
  70. createNode: (content: string) => ({
  71. name: 'strong',
  72. attrs: {},
  73. children: parseInlineToNodes(content)
  74. })
  75. },
  76. {
  77. regex: /\*(.*?)\*/,
  78. createNode: (content: string) => ({
  79. name: 'em',
  80. attrs: {},
  81. children: parseInlineToNodes(content)
  82. })
  83. },
  84. {
  85. regex: /_(.*?)_/,
  86. createNode: (content: string) => ({
  87. name: 'em',
  88. attrs: {},
  89. children: parseInlineToNodes(content)
  90. })
  91. }
  92. ];
  93. // 递归解析行内元素
  94. const parseInlineToNodes = (text: string): RichTextNode[] => {
  95. const nodes: RichTextNode[] = [];
  96. let remaining = text;
  97. while (remaining.length > 0) {
  98. let matched = false;
  99. let bestMatch: { index: number; rule: any; groups: string[] } | null = null;
  100. for (const rule of inlineRules) {
  101. const matches = remaining.match(rule.regex);
  102. if (matches && matches.index !== undefined) {
  103. if (!bestMatch || matches.index < bestMatch.index) {
  104. bestMatch = {
  105. index: matches.index,
  106. rule,
  107. groups: matches.slice(1)
  108. };
  109. }
  110. matched = true;
  111. }
  112. }
  113. if (bestMatch) {
  114. if (bestMatch.index > 0) {
  115. nodes.push({ type: 'text', text: remaining.substring(0, bestMatch.index) });
  116. }
  117. const node = bestMatch.rule.createNode(...bestMatch.groups);
  118. nodes.push(node);
  119. const fullMatch = remaining.match(bestMatch.rule.regex)![0];
  120. remaining = remaining.substring(bestMatch.index + fullMatch.length);
  121. } else if (matched) {
  122. nodes.push({ type: 'text', text: remaining });
  123. break;
  124. } else {
  125. nodes.push({ type: 'text', text: remaining });
  126. break;
  127. }
  128. }
  129. return nodes;
  130. };
  131. // 计算列表项的缩进深度(4个空格为一级)
  132. const getIndentDepth = (line: string): number => {
  133. const spaces = line.match(/^\s*/)![0];
  134. return Math.floor(spaces.length / 4); // 每4个空格算一级缩进
  135. };
  136. // 渲染列表项及其子列表
  137. const renderListItems = (items: ListItem[], level: number) => {
  138. return items.map((item, index) => {
  139. // 渲染子列表
  140. const renderChildLists = () => {
  141. if (!item.children || item.children.length === 0) return null;
  142. return item.children.map((childLevel, childIndex) => {
  143. const childListKey = getStableKey(`child-list-${childLevel.type}-${childIndex}`, index);
  144. return (
  145. <View
  146. key={childListKey}
  147. className={`markdown-list markdown-${childLevel.type} depth-${childLevel.depth}`}
  148. >
  149. {renderListItems(childLevel.items, level + 1)}
  150. </View>
  151. );
  152. });
  153. };
  154. return (
  155. <View key={item.key} className="markdown-list-item">
  156. <View className="markdown-list-item-content">{item.content}</View>
  157. {renderChildLists()}
  158. </View>
  159. );
  160. });
  161. };
  162. interface MarkdownParserProps {
  163. content: string;
  164. }
  165. const MarkdownParser = ({ content }: MarkdownParserProps) => {
  166. const parsedElements = useMemo(() => {
  167. const lines = content.split('\n');
  168. const elements: React.ReactNode[] = [];
  169. let inCodeBlock = false;
  170. let currentCodeBlock = '';
  171. let codeLang = '';
  172. // 列表栈:管理嵌套列表层级(栈顶为当前层级)
  173. const listStack: ListLevel[] = [];
  174. // 刷新当前列表(将栈中所有列表弹出并嵌套组装)
  175. const flushLists = () => {
  176. if (listStack.length === 0) return;
  177. // 从顶层渲染列表
  178. listStack.forEach((level, index) => {
  179. const listKey = getStableKey(`list-${level.type}-${level.depth}-${index}`);
  180. elements.push(
  181. <View key={listKey} className={`markdown-list markdown-${level.type} depth-${level.depth}`}>
  182. {renderListItems(level.items, index)}
  183. </View>
  184. );
  185. });
  186. // 清空栈
  187. listStack.length = 0;
  188. };
  189. // 处理代码块
  190. const flushCodeBlock = () => {
  191. if (inCodeBlock && currentCodeBlock) {
  192. const codeKey = getStableKey(currentCodeBlock);
  193. elements.push(
  194. <View key={codeKey} className="markdown-code-block">
  195. {codeLang && <Text className="markdown-code-lang">{codeLang}</Text>}
  196. <Text className="markdown-code">{currentCodeBlock}</Text>
  197. </View>
  198. );
  199. inCodeBlock = false;
  200. currentCodeBlock = '';
  201. codeLang = '';
  202. }
  203. };
  204. lines.forEach((line, lineIndex) => {
  205. // 处理空行(保留结构,但不添加多余元素)
  206. const trimmedLine = line.trim();
  207. if (!trimmedLine && !inCodeBlock) return;
  208. // 处理代码块
  209. if (/^```/.test(trimmedLine)) {
  210. flushLists(); // 遇到代码块,先刷新所有列表
  211. if (inCodeBlock) {
  212. flushCodeBlock();
  213. } else {
  214. inCodeBlock = true;
  215. codeLang = trimmedLine.replace(/^```/, '').trim();
  216. }
  217. return;
  218. }
  219. if (inCodeBlock) {
  220. currentCodeBlock += line + '\n';
  221. return;
  222. }
  223. // 处理标题
  224. const headingMatch = line.match(/^\s*#{1,6}\s+/);
  225. if (headingMatch) {
  226. flushLists(); // 遇到标题,刷新所有列表
  227. const level = headingMatch[0].match(/#/g)?.length || 1;
  228. const text = line.replace(/^\s*#{1,6}\s+/, '');
  229. const headingKey = getStableKey(`h${level}-${text}`, lineIndex);
  230. elements.push(
  231. <View key={headingKey} className={`markdown-h${level}`}>
  232. <RichText nodes={parseInlineToNodes(text)} />
  233. </View>
  234. );
  235. return;
  236. }
  237. // 处理无序列表和有序列表(统一显示为无序列表)
  238. const listMatch = line.match(/^\s*([-*+]|\d+\.)\s+/);
  239. if (listMatch) {
  240. const depth = getIndentDepth(line); // 当前列表项的缩进深度
  241. // 提取文本内容(移除列表标记)
  242. const text = line.replace(/^\s*([-*+]|\d+\.)\s+/, '');
  243. const itemKey = getStableKey(`list-item-${text}`, lineIndex);
  244. // 创建新列表项
  245. const newItem: ListItem = {
  246. content: <RichText nodes={parseInlineToNodes(text)} />,
  247. key: itemKey
  248. };
  249. // 1. 调整列表栈到当前深度(弹出过深的层级)
  250. while (listStack.length > depth) {
  251. const poppedLevel = listStack.pop()!;
  252. // 如果栈不为空,将弹出的层级作为上一级最后一个项的子列表
  253. if (listStack.length > 0) {
  254. const parentLevel = listStack[listStack.length - 1];
  255. if (parentLevel.items.length > 0) {
  256. const lastParentItem = parentLevel.items[parentLevel.items.length - 1];
  257. if (!lastParentItem.children) {
  258. lastParentItem.children = [];
  259. }
  260. lastParentItem.children.push(poppedLevel);
  261. }
  262. } else {
  263. // 如果栈为空,直接渲染弹出的层级
  264. const listKey = getStableKey(`unexpected-list-${poppedLevel.type}-${poppedLevel.depth}`);
  265. elements.push(
  266. <View key={listKey} className={`markdown-list markdown-${poppedLevel.type} depth-${poppedLevel.depth}`}>
  267. {renderListItems(poppedLevel.items, 0)}
  268. </View>
  269. );
  270. }
  271. }
  272. // 2. 如果栈为空或当前深度大于栈顶深度,创建新层级(统一使用ul类型)
  273. if (listStack.length === 0 || depth > listStack[listStack.length - 1].depth) {
  274. listStack.push({
  275. type: 'ul', // 所有列表都显示为无序列表
  276. items: [newItem],
  277. depth
  278. });
  279. } else {
  280. // 3. 否则添加到当前层级
  281. listStack[listStack.length - 1].items.push(newItem);
  282. }
  283. return;
  284. }
  285. // 处理引用
  286. const quoteMatch = line.match(/^\s*>\s+/);
  287. if (quoteMatch) {
  288. flushLists();
  289. const text = line.replace(/^\s*>\s+/, '');
  290. const quoteKey = getStableKey(`quote-${text}`, lineIndex);
  291. elements.push(
  292. <View key={quoteKey} className="markdown-quote">
  293. <RichText nodes={parseInlineToNodes(text)} />
  294. </View>
  295. );
  296. return;
  297. }
  298. // 处理分隔线
  299. if (/^\s*[-*]{3,}\s*$/.test(line)) {
  300. flushLists();
  301. const hrKey = getStableKey(`hr-${line}`, lineIndex);
  302. elements.push(
  303. <View key={hrKey} className="markdown-hr" />
  304. );
  305. return;
  306. }
  307. // 处理普通文本行
  308. flushLists(); // 普通文本前刷新所有列表
  309. const paragraphKey = getStableKey(`p-${line}`, lineIndex);
  310. elements.push(
  311. <View key={paragraphKey} className="markdown-paragraph">
  312. <RichText nodes={parseInlineToNodes(line)} />
  313. </View>
  314. );
  315. });
  316. // 解析结束后刷新剩余列表和代码块
  317. flushLists();
  318. flushCodeBlock();
  319. return elements;
  320. }, [content]);
  321. return (
  322. <View className="markdown-viewer">
  323. {parsedElements}
  324. </View>
  325. );
  326. };
  327. export default MarkdownParser;