ua-markdown.vue 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. <!-- uniapp vue3 markdown解析 -->
  2. <template>
  3. <view class="ua__markdown">
  4. <rich-text :nodes="parsedContent" type="text" mode="web" space="nbsp"></rich-text>
  5. <view class="links" v-if="linkTmp != '' || linkMaps.length > 0">
  6. <view class="links-title">参考链接:</view>
  7. <template v-for="item in linkMaps">
  8. <view class="link-item" @click="copyLink(item.href)">{{item.text}}</view>
  9. </template>
  10. <view v-if="linkTmp != ''" class="link-item">{{linkTmp}}</view>
  11. </view>
  12. </view>
  13. </template>
  14. <script setup>
  15. import {
  16. ref,
  17. watch
  18. } from 'vue'
  19. import MarkdownIt from './lib/markdown-it.min.js'
  20. import hljs from './lib/highlight/uni-highlight.min.js'
  21. import './lib/highlight/atom-one-dark.css'
  22. import parseHtml from './lib/html-parser.js'
  23. const props = defineProps({
  24. // 解析内容
  25. source: String,
  26. showLine: {
  27. type: [Boolean, String],
  28. default: true
  29. }
  30. })
  31. // 存储解析后的内容
  32. const parsedContent = ref('');
  33. // 临时链接缓存
  34. const linkTmp = ref('');
  35. const clearLinkTmpTimeout = ref(null);
  36. // 存储链接数据映射
  37. const linkMaps = ref([]);
  38. // 解析富文本内容,提取并替换a标签
  39. const parseContent = () => {
  40. if (!props.source) return
  41. // 用于生成唯一ID的计数器
  42. let linkIdCounter = 0;
  43. let parseNodeStr = parseNodes(props.source);
  44. parseNodeStr = parseNodeStr.replace(/[\n\r]/g, '')
  45. linkMaps.value = [];
  46. const linkLine = /<p[^>]*>(?:<[^>]+>)*\s*参考链接(.*?)<\/p>/gi;
  47. // 匹配a标签的正则表达式
  48. const linkRegex = /<a[^>]+href="([^"]+)"[^>]*>(.*?)<\/a>/gi;
  49. parsedContent.value = parseNodeStr.replace(linkLine, (match, content) => {
  50. content = content.replace(linkRegex, (match, href, text) => {
  51. // 存储链接信息
  52. linkMaps.value.push({
  53. text: text,
  54. href: href.replace(/&amp;/g, "&")
  55. });
  56. return '';
  57. });
  58. linkTmp.value = content.replace(/<[^>]*>/g, '');
  59. return '';
  60. });
  61. if (clearLinkTmpTimeout.value) {
  62. clearTimeout(clearLinkTmpTimeout.value);
  63. }
  64. // 提取单独的链接(使用 exec 循环代替 matchAll,兼容小程序环境)
  65. const linkRegex2 = /<a[^>]+href="([^"]+)"[^>]*>(.*?)<\/a>/gi;
  66. let match;
  67. while ((match = linkRegex2.exec(parsedContent.value)) !== null) {
  68. linkMaps.value.push({
  69. text: match[2],
  70. href: match[1].replace(/&amp;/g, "&")
  71. });
  72. }
  73. clearLinkTmpTimeout.value = setTimeout(() => {
  74. linkTmp.value = '';
  75. }, 60);
  76. }
  77. // 处理点击事件
  78. const copyLink = (link) => {
  79. uni.setClipboardData({
  80. data: link,
  81. success: () => {
  82. uni.showToast({
  83. title: "复制成功",
  84. icon: 'none'
  85. });
  86. },
  87. fail: () => {
  88. uni.showToast({
  89. title: "复制失败",
  90. icon: 'none'
  91. });
  92. }
  93. });
  94. }
  95. watch(() => props.source, () => {
  96. parseContent();
  97. });
  98. const markdown = MarkdownIt({
  99. html: true,
  100. highlight: function(str, lang) {
  101. let preCode = ""
  102. try {
  103. preCode = hljs.highlightAuto(str).value
  104. } catch (err) {
  105. preCode = markdown.utils.escapeHtml(str);
  106. }
  107. const lines = preCode.split(/\n/).slice(0, -1)
  108. // 添加自定义行号
  109. let html = lines.map((item, index) => {
  110. if (item == '') {
  111. return ''
  112. }
  113. return '<li><span class="line-num" data-line="' + (index + 1) + '"></span>' + item +
  114. '</li>'
  115. }).join('')
  116. if (props.showLine) {
  117. html = '<ol style="padding: 0px 30px;">' + html + '</ol>'
  118. } else {
  119. html = '<ol style="padding: 0px 7px;list-style:none;">' + html + '</ol>'
  120. }
  121. copyCodeData.push(str)
  122. let htmlCode = `<div class="markdown-wrap">`
  123. // #ifndef MP-WEIXIN
  124. htmlCode += `<div style="color: #aaa;text-align: right;font-size: 12px;padding:8px;">`
  125. htmlCode +=
  126. `${lang}<a class="copy-btn" code-data-index="${copyCodeData.length - 1}" style="margin-left: 8px;">复制代码</a>`
  127. htmlCode += `</div>`
  128. // #endif
  129. htmlCode +=
  130. `<pre class="hljs" style="padding:10px 8px 0;margin-bottom:5px;overflow: auto;display: block;border-radius: 5px;"><code>${html}</code></pre>`;
  131. htmlCode += '</div>'
  132. return htmlCode
  133. }
  134. })
  135. const parseNodes = (value) => {
  136. if (!value) return
  137. // 解析<br />到\n
  138. value = value.replace(/<br>|<br\/>|<br \/>/g, "\n")
  139. value = value.replace(/&nbsp;/g, " ")
  140. let htmlString = ''
  141. if (value.split("```").length % 2) {
  142. let mdtext = value
  143. if (mdtext[mdtext.length - 1] != '\n') {
  144. mdtext += '\n'
  145. }
  146. htmlString = markdown.render(mdtext)
  147. } else {
  148. htmlString = markdown.render(value)
  149. }
  150. // 小程序 rich-text 不继承外部CSS,必须使用内联样式
  151. // 表格样式
  152. htmlString = htmlString.replace(/<table>/g, `<table style="border-collapse:collapse;width:100%;margin:0.8em 0;border:1px solid #e5e5e5;">`)
  153. htmlString = htmlString.replace(/<tr>/g, `<tr style="border-top:1px solid #e5e5e5;">`)
  154. htmlString = htmlString.replace(/<th>/g, `<th style="padding:6px 13px;border:1px solid #e5e5e5;font-weight:600;background-color:#f5f5f5;">`)
  155. htmlString = htmlString.replace(/<td>/g, `<td style="padding:6px 13px;border:1px solid #e5e5e5;">`)
  156. htmlString = htmlString.replace(/<hr>|<hr\/>|<hr \/>/g, `<hr style="margin:1em 0;border:0;border-top:1px solid #e5e5e5;">`)
  157. // 列表样式
  158. htmlString = htmlString.replace(/<ul>/g, `<ul style="padding-left:20px;margin:0.5em 0;list-style-type:disc;">`)
  159. htmlString = htmlString.replace(/<ol>/g, `<ol style="padding-left:20px;margin:0.5em 0;list-style-type:decimal;">`)
  160. htmlString = htmlString.replace(/<li>/g, `<li style="line-height:1.6;margin-bottom:0.2em;">`)
  161. // 标题样式
  162. htmlString = htmlString.replace(/<h1>/g, `<h1 style="font-size:1.8em;margin:1em 0 0.5em;font-weight:bold;line-height:1.3;">`)
  163. htmlString = htmlString.replace(/<h2>/g, `<h2 style="font-size:1.5em;margin:1em 0 0.5em;font-weight:bold;line-height:1.3;">`)
  164. htmlString = htmlString.replace(/<h3>/g, `<h3 style="font-size:1.25em;margin:0.8em 0 0.4em;font-weight:bold;line-height:1.3;">`)
  165. htmlString = htmlString.replace(/<h4>/g, `<h4 style="font-size:1.1em;margin:0.8em 0 0.4em;font-weight:bold;line-height:1.3;">`)
  166. htmlString = htmlString.replace(/<h5>/g, `<h5 style="font-size:1em;margin:0.6em 0 0.3em;font-weight:bold;line-height:1.3;">`)
  167. htmlString = htmlString.replace(/<h6>/g, `<h6 style="font-size:0.9em;margin:0.6em 0 0.3em;font-weight:bold;line-height:1.3;">`)
  168. // 段落样式
  169. htmlString = htmlString.replace(/<p>/g, `<p style="margin:0.5em 0;line-height:1.6;">`)
  170. // 引用块样式
  171. htmlString = htmlString.replace(/<blockquote>/g, `<blockquote style="padding:10px 15px;margin:0.8em 0;border-left:4px solid #ddd;color:#666;background:#f9f9f9;">`)
  172. // 行内代码样式
  173. htmlString = htmlString.replace(/<code>(?![\s\S]*<\/code>[\s\S]*<code>)/g, `<code style="padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:3px;font-family:monospace;">`)
  174. // 链接样式
  175. htmlString = htmlString.replace(/<a /g, `<a style="color:#3498db;text-decoration:underline;" `)
  176. // 强调样式
  177. htmlString = htmlString.replace(/<strong>/g, `<strong style="font-weight:bold;">`)
  178. htmlString = htmlString.replace(/<em>/g, `<em style="font-style:italic;">`)
  179. // #ifndef APP-NVUE
  180. return htmlString
  181. // #endif
  182. // 将htmlString转成htmlArray,反之使用rich-text解析
  183. // #ifdef APP-NVUE
  184. return parseHtml(htmlString)
  185. // #endif
  186. }
  187. const extractPlainText = (markdown) => {
  188. const md = new MarkdownIt({
  189. // 禁用所有HTML标签渲染
  190. html: false,
  191. // 禁用自动链接识别
  192. linkify: false,
  193. // 禁用自动换行转换
  194. breaks: false,
  195. // 禁用typographer转换(如引号转换)
  196. typographer: false
  197. });
  198. // 自定义渲染器,只返回文本内容
  199. const originalRender = md.renderer.rules.text;
  200. md.renderer.rules.text = function(tokens, idx, options, env, self) {
  201. return originalRender ? originalRender(tokens, idx, options, env, self) : tokens[idx].content;
  202. };
  203. // 处理各种token类型
  204. md.renderer.rules.link_open = md.renderer.rules.link_close =
  205. md.renderer.rules.strong_open = md.renderer.rules.strong_close =
  206. md.renderer.rules.em_open = md.renderer.rules.em_close =
  207. md.renderer.rules.heading_open = md.renderer.rules.heading_close =
  208. md.renderer.rules.paragraph_open = md.renderer.rules.paragraph_close =
  209. md.renderer.rules.list_item_open = md.renderer.rules.list_item_close =
  210. md.renderer.rules.ordered_list_open = md.renderer.rules.ordered_list_close =
  211. md.renderer.rules.bullet_list_open = md.renderer.rules.bullet_list_close =
  212. md.renderer.rules.code_block = md.renderer.rules.fence =
  213. md.renderer.rules.blockquote_open = md.renderer.rules.blockquote_close =
  214. function(tokens, idx, options, env, self) {
  215. return '';
  216. };
  217. // 处理图片(完全忽略)
  218. md.renderer.rules.image = function() {
  219. return '';
  220. };
  221. // 渲染并清理结果
  222. return md.render(markdown)
  223. .replace(/\s+/g, ' ')
  224. .trim();
  225. }
  226. /**
  227. * 复制文字内容
  228. */
  229. const copyContent = () => {
  230. const content = extractPlainText(props.source);
  231. uni.setClipboardData({
  232. data: content,
  233. success: () => {
  234. uni.showToast({
  235. title: '复制成功',
  236. icon: 'none'
  237. });
  238. }
  239. })
  240. }
  241. defineExpose({
  242. copyContent,
  243. extractPlainText
  244. });
  245. </script>
  246. <style lang="scss">
  247. /* 小程序 rich-text 不继承外部CSS,内部元素样式通过内联方式在 parseNodes 中处理 */
  248. /* 此处仅保留容器样式和非 rich-text 内的元素样式 */
  249. .ua__markdown {
  250. font-size: 14px;
  251. line-height: 1.6;
  252. word-break: break-all;
  253. color: #333;
  254. .links {
  255. margin-top: 16rpx;
  256. .links-title {
  257. color: #101333;
  258. margin-bottom: 8rpx;
  259. font-weight: bold;
  260. font-size: 28rpx;
  261. }
  262. .link-item {
  263. font-size: 24rpx;
  264. color: #3498db;
  265. text-decoration: underline;
  266. margin-bottom: 6rpx;
  267. }
  268. }
  269. }
  270. </style>