浏览代码

first commit

chenyixian 3 月之前
当前提交
0ddd127c3d
共有 100 个文件被更改,包括 4050 次插入0 次删除
  1. 1 0
      .eslintignore
  2. 30 0
      .eslintrc.json
  3. 23 0
      .github/ISSUE_TEMPLATE/bug.md
  4. 14 0
      .github/ISSUE_TEMPLATE/enhancement.md
  5. 12 0
      .github/ISSUE_TEMPLATE/question.md
  6. 6 0
      .gitignore
  7. 6 0
      .npmignore
  8. 1 0
      .npmrc
  9. 6 0
      .stylelintrc.json
  10. 21 0
      LICENSE
  11. 261 0
      README.md
  12. 1 0
      dist/mp-alipay/index.acss
  13. 1 0
      dist/mp-alipay/index.axml
  14. 7 0
      dist/mp-alipay/index.js
  15. 1 0
      dist/mp-alipay/index.json
  16. 1 0
      dist/mp-alipay/node/node.acss
  17. 0 0
      dist/mp-alipay/node/node.axml
  18. 0 0
      dist/mp-alipay/node/node.js
  19. 1 0
      dist/mp-alipay/node/node.json
  20. 0 0
      dist/mp-alipay/parser.js
  21. 1 0
      dist/mp-baidu/index.css
  22. 7 0
      dist/mp-baidu/index.js
  23. 1 0
      dist/mp-baidu/index.json
  24. 1 0
      dist/mp-baidu/index.swan
  25. 1 0
      dist/mp-baidu/node/node.css
  26. 0 0
      dist/mp-baidu/node/node.js
  27. 1 0
      dist/mp-baidu/node/node.json
  28. 0 0
      dist/mp-baidu/node/node.swan
  29. 0 0
      dist/mp-baidu/parser.js
  30. 7 0
      dist/mp-qq/index.js
  31. 1 0
      dist/mp-qq/index.json
  32. 1 0
      dist/mp-qq/index.qml
  33. 1 0
      dist/mp-qq/index.qss
  34. 0 0
      dist/mp-qq/node/node.js
  35. 1 0
      dist/mp-qq/node/node.json
  36. 0 0
      dist/mp-qq/node/node.qml
  37. 1 0
      dist/mp-qq/node/node.qss
  38. 0 0
      dist/mp-qq/parser.js
  39. 7 0
      dist/mp-toutiao/index.js
  40. 1 0
      dist/mp-toutiao/index.json
  41. 1 0
      dist/mp-toutiao/index.ttml
  42. 1 0
      dist/mp-toutiao/index.ttss
  43. 0 0
      dist/mp-toutiao/node/node.js
  44. 1 0
      dist/mp-toutiao/node/node.json
  45. 0 0
      dist/mp-toutiao/node/node.ttml
  46. 1 0
      dist/mp-toutiao/node/node.ttss
  47. 0 0
      dist/mp-toutiao/parser.js
  48. 7 0
      dist/mp-weixin/index.js
  49. 1 0
      dist/mp-weixin/index.json
  50. 1 0
      dist/mp-weixin/index.wxml
  51. 1 0
      dist/mp-weixin/index.wxss
  52. 0 0
      dist/mp-weixin/node/node.js
  53. 1 0
      dist/mp-weixin/node/node.json
  54. 0 0
      dist/mp-weixin/node/node.wxml
  55. 1 0
      dist/mp-weixin/node/node.wxss
  56. 0 0
      dist/mp-weixin/parser.js
  57. 74 0
      dist/uni-app/components/mp-html/aliyun-video/aliyun-video.vue
  58. 7 0
      dist/uni-app/components/mp-html/aliyun-video/index.js
  59. 63 0
      dist/uni-app/components/mp-html/channel-video/channel-video.vue
  60. 7 0
      dist/uni-app/components/mp-html/channel-video/index.js
  61. 504 0
      dist/uni-app/components/mp-html/mp-html.vue
  62. 629 0
      dist/uni-app/components/mp-html/node/node.vue
  63. 1404 0
      dist/uni-app/components/mp-html/parser.js
  64. 0 0
      dist/uni-app/static/app-plus/mp-html/js/handler.js
  65. 0 0
      dist/uni-app/static/app-plus/mp-html/js/uni.webview.min.js
  66. 1 0
      dist/uni-app/static/app-plus/mp-html/local.html
  67. 0 0
      docs/.nojekyll
  68. 56 0
      docs/README.md
  69. 12 0
      docs/_coverpage.md
  70. 20 0
      docs/_sidebar.md
  71. 230 0
      docs/advanced/api.md
  72. 114 0
      docs/advanced/develop.md
  73. 488 0
      docs/advanced/plugin.md
  74. 二进制
      docs/assets/case/AI瓦力.jpg
  75. 二进制
      docs/assets/case/MiniProgram模版演示.jpg
  76. 二进制
      docs/assets/case/优秀笔记.jpg
  77. 二进制
      docs/assets/case/你的代码写的真棒.jpg
  78. 二进制
      docs/assets/case/典典博客.jpg
  79. 二进制
      docs/assets/case/前端八股通.jpg
  80. 二进制
      docs/assets/case/同城共享书.jpg
  81. 二进制
      docs/assets/case/多么生活.jpg
  82. 二进制
      docs/assets/case/富文本插件.jpg
  83. 二进制
      docs/assets/case/小莫唐尼.png
  84. 二进制
      docs/assets/case/微慕.jpg
  85. 二进制
      docs/assets/case/技术源share.jpg
  86. 二进制
      docs/assets/case/欢喜商城.png
  87. 二进制
      docs/assets/case/科学复习.png
  88. 二进制
      docs/assets/case/程序员技术之旅.jpg
  89. 二进制
      docs/assets/case/谛否.jpg
  90. 二进制
      docs/assets/case/豆流便签.jpg
  91. 二进制
      docs/assets/case/食法查.png
  92. 二进制
      docs/assets/group.jpg
  93. 二进制
      docs/assets/logo/logo.ico
  94. 二进制
      docs/assets/logo/logo.png
  95. 二进制
      docs/assets/plugin/card.png
  96. 二进制
      docs/assets/sponsor.png
  97. 二进制
      docs/assets/tutorials/01.png
  98. 二进制
      docs/assets/tutorials/02.png
  99. 二进制
      docs/assets/tutorials/03.png
  100. 二进制
      docs/assets/tutorials/04.png

+ 1 - 0
.eslintignore

@@ -0,0 +1 @@
+*.min.js

+ 30 - 0
.eslintrc.json

@@ -0,0 +1,30 @@
+{
+  "env": {
+    "browser": true,
+    "es6": true,
+    "node": true
+  },
+  "extends": [
+    "eslint:recommended",
+    "plugin:vue/essential"
+  ],
+  "globals": {
+    "Component": "readonly",
+    "wx": "readonly",
+    "qq": "readonly",
+    "swan": "readonly",
+    "my": "readonly",
+    "tt": "readonly",
+    "uni": "readonly",
+    "plus": "readonly",
+    "weex": "readonly",
+    "requirePlugin": "readonly"
+  },
+  "rules": {
+    "semi": [
+      "error",
+      "never"
+    ],
+    "no-console": "error"
+  }
+}

+ 23 - 0
.github/ISSUE_TEMPLATE/bug.md

@@ -0,0 +1,23 @@
+---
+name: 提交 Bug
+about: 如果发现某部分功能表现与文档描述不符或出错,请选择此模板反馈
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+<!--注意:必须严格按照下列要求反馈 bug,以便尽快确定问题并修复,不符合要求的反馈不予处理-->
+
+## 使用环境  
+<!--请注明使用的小程序平台和基础库版本等环境信息-->
+
+## 问题描述  
+<!--请详细描述遇到的问题,可以附上截图-->
+
+## 复现方式  
+<!--
+注意:无法复现的问题将不予处理
+如果将您使用的 html 内容拷贝到示例项目中就能复现问题,请在下方附上您使用的 html 内容
+如果在示例项目中无法复现,请附上能够复现的 demo 项目并说明复现方式
+-->

+ 14 - 0
.github/ISSUE_TEMPLATE/enhancement.md

@@ -0,0 +1,14 @@
+---
+name: 新功能需求
+about: 如果需要某些新功能或有改进建议,请选择此模板反馈
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+<!--注意:提出的新功能或改进建议应有一定的应用场景和通用性,如果您已经实现了改进可以直接提交 pr-->
+
+## 新功能描述
+
+## 应用场景

+ 12 - 0
.github/ISSUE_TEMPLATE/question.md

@@ -0,0 +1,12 @@
+---
+name: 咨询问题
+about: 如果找不到文档对某功能的描述或描述不清,请选择此模板咨询
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+<!--注意:提问前请确认已经在文档中查找过没有相关资料或文档没有描述清楚-->
+
+## 问题描述  

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+node_modules/
+coverage/
+dev/
+yarn.lock
+*.yml
+*.psd

+ 6 - 0
.npmignore

@@ -0,0 +1,6 @@
+node_modules/
+coverage/
+dev/
+yarn.lock
+*.yml
+*.psd

+ 1 - 0
.npmrc

@@ -0,0 +1 @@
+@kasite:registry=http://maven.kasitesoft.com/repository/intelmt-npm-group/

+ 6 - 0
.stylelintrc.json

@@ -0,0 +1,6 @@
+{
+  "extends": [
+    "stylelint-config-standard",
+    "stylelint-config-recess-order"
+  ]
+}

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2019-present Jin Yufeng
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 261 - 0
README.md

@@ -0,0 +1,261 @@
+# mp-html
+
+> 一个强大的小程序富文本组件
+
+![star](https://img.shields.io/github/stars/jin-yufeng/mp-html)
+![forks](https://img.shields.io/github/forks/jin-yufeng/mp-html)
+[![npm](https://img.shields.io/npm/v/mp-html)](https://www.npmjs.com/package/mp-html)
+![downloads](https://img.shields.io/npm/dt/mp-html)
+[![Coverage Status](https://coveralls.io/repos/github/jin-yufeng/mp-html/badge.svg?branch=master)](https://coveralls.io/github/jin-yufeng/mp-html?branch=master)
+![license](https://img.shields.io/github/license/jin-yufeng/mp-html)
+[![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)
+
+## 功能介绍
+- 支持在多个主流的小程序平台和 `uni-app` 中使用
+- 支持丰富的标签(包括 `table`、`video`、`svg` 等)
+- 支持丰富的事件效果(自动预览图片、链接处理等)
+- 支持设置占位图(加载中、出错时、预览时)
+- 支持锚点跳转、长按复制等丰富功能
+- 支持大部分 *html* 实体
+- 丰富的插件(关键词搜索、内容编辑、`latex` 公式等)
+- 效率高、容错性强且轻量化(`≈25KB`,`9KB gzipped`)
+
+查看 [功能介绍](https://jin-yufeng.github.io/mp-html/#/overview/feature) 了解更多
+
+## 使用方法
+### 原生平台
+- `npm` 方式
+  1. 在项目目录下安装组件包
+
+     ```bash
+     npm install mp-html
+     ```
+  2. 开发者工具中勾选 `使用 npm 模块`(若没有此选项则不需要)并点击 `工具 - 构建 npm`
+  3. 在需要使用页面的 `json` 文件中添加
+
+     ```json
+     {
+       "usingComponents": {
+         "mp-html": "mp-html"
+       }
+     }
+     ```
+  4. 在需要使用页面的 `wxml` 文件中添加
+
+     ```html
+     <mp-html content="{{html}}" />
+     ```
+  5. 在需要使用页面的 `js` 文件中添加
+
+     ```javascript
+     Page({
+       onLoad () {
+         this.setData({
+           html: '<div>Hello World!</div>'
+         })
+       }
+     })
+     ```
+- 源码方式
+  1. 将源码中对应平台的代码包(`dist/platform`)拷贝到 `components` 目录下,更名为 `mp-html`
+  2. 在需要使用页面的 `json` 文件中添加
+
+     ```json
+     {
+       "usingComponents": {
+         "mp-html": "/components/mp-html/index"
+       }
+     }
+     ```
+  
+  后续步骤同上
+
+查看 [快速开始](https://jin-yufeng.github.io/mp-html/#/overview/quickstart) 了解更多
+
+### uni-app
+- 源码方式
+  1. 将源码中 `dist/uni-app` 内的内容拷贝到项目根目录下  
+     可以直接通过 [插件市场](https://ext.dcloud.net.cn/plugin?id=805) 引入
+  2. 在需要使用页面的 `vue` 文件中添加
+
+     ```vue
+     <template>
+       <view>
+         <mp-html :content="html" />
+       </view>
+     </template>
+     <script>
+       import mpHtml from '@/components/mp-html/mp-html'
+       export default {
+         // HBuilderX 2.5.5+ 可以通过 easycom 自动引入
+         components: {
+           mpHtml
+         },
+         data () {
+           return {
+             html: '<div>Hello World!</div>'
+           }
+         }
+       }
+     </script>
+     ```
+- `npm` 方式
+  1. 在项目目录下安装组件包
+
+     ```bash
+     npm install mp-html
+     ```
+  2. 在需要使用页面的 `vue` 文件中添加
+
+     ```vue
+     <template>
+       <view>
+         <mp-html :content="html" />
+       </view>
+     </template>
+     <script>
+       import mpHtml from 'mp-html/dist/uni-app/components/mp-html/mp-html'
+       export default {
+         // 不可省略
+         components: {
+           mpHtml
+         },
+         data () {
+           return {
+             html: '<div>Hello World!</div>'
+           }
+         }
+       }
+     </script>
+     ```
+
+  使用 `cli` 方式运行的项目,通过 `npm` 方式引入时,需要在 `vue.config.js` 中配置 `transpileDependencies`,详情可见 [#330](https://github.com/jin-yufeng/mp-html/issues/330#issuecomment-913617687)  
+  如果在 `nvue` 中使用还要将 `dist/uni-app/static` 目录下的内容拷贝到项目的 `static` 目录下,否则无法运行  
+
+查看 [快速开始](https://jin-yufeng.github.io/mp-html/#/overview/quickstart) 了解更多
+
+## 组件属性
+
+| 属性 | 类型 | 默认值 | 说明 |
+|:---:|:---:|:---:|---|
+| container-style | String |  | 容器的样式([2.1.0+](https://jin-yufeng.github.io/mp-html/#/changelog/changelog#v210)) |
+| content | String |  | 用于渲染的 html 字符串 |
+| copy-link | Boolean | true | 是否允许外部链接被点击时自动复制 |
+| domain | String |  | 主域名(用于链接拼接) |
+| error-img | String |  | 图片出错时的占位图链接 |
+| lazy-load | Boolean | false | 是否开启图片懒加载 |
+| loading-img | String |  | 图片加载过程中的占位图链接 |
+| pause-video | Boolean | true | 是否在播放一个视频时自动暂停其他视频 |
+| preview-img | Boolean | true | 是否允许图片被点击时自动预览 |
+| scroll-table | Boolean | false | 是否给每个表格添加一个滚动层使其能单独横向滚动 |
+| selectable | Boolean | false | 是否开启文本长按复制 |
+| set-title | Boolean | true | 是否将 title 标签的内容设置到页面标题 |
+| show-img-menu | Boolean | true | 是否允许图片被长按时显示菜单 |
+| tag-style | Object |  | 设置标签的默认样式 |
+| use-anchor | Boolean | false | 是否使用锚点链接 |
+
+查看 [属性](https://jin-yufeng.github.io/mp-html/#/basic/prop) 了解更多
+
+## 组件事件
+
+| 名称 | 触发时机 |
+|:---:|---|
+| load | dom 树加载完毕时 |
+| ready | 图片加载完毕时 |
+| error | 发生渲染错误时 |
+| imgtap | 图片被点击时 |
+| linktap | 链接被点击时 |
+
+查看 [事件](https://jin-yufeng.github.io/mp-html/#/basic/event) 了解更多
+
+## api
+组件实例上提供了一些 `api` 方法可供调用
+
+| 名称 | 作用 |
+|:---:|---|
+| in | 将锚点跳转的范围限定在一个 scroll-view 内 |
+| navigateTo | 锚点跳转 |
+| getText | 获取文本内容 |
+| getRect | 获取富文本内容的位置和大小 |
+| setContent | 设置富文本内容 |
+| imgList | 获取所有图片的数组 |
+| pauseMedia | 暂停播放音视频([2.2.2+](https://jin-yufeng.github.io/mp-html/#/changelog/changelog#v222)) |
+| setPlaybackRate | 设置音视频播放速率([2.4.0+](https://jin-yufeng.github.io/mp-html/#/changelog/changelog#v240)) |
+
+查看 [api](https://jin-yufeng.github.io/mp-html/#/advanced/api) 了解更多
+
+## 插件扩展  
+除基本功能外,本组件还提供了丰富的扩展,可按照需要选用
+
+| 名称 | 作用 |
+|:---:|---|
+| audio | 音乐播放器 |
+| editable | 富文本编辑 |
+| emoji | 解析 emoji |
+| highlight | 代码块高亮显示 |
+| markdown | 渲染 markdown |
+| search | 关键词搜索 |
+| style | 匹配 style 标签中的样式 |
+| txv-video | 使用腾讯视频 |
+| img-cache | 图片缓存 by [@PentaTea](https://github.com/PentaTea) |
+| latex | 渲染 latex 公式 by [@Zeng-J](https://github.com/Zeng-J) |
+| card | 卡片展示 by [@whoooami](https://github.com/whoooami) |
+
+查看 [插件](https://jin-yufeng.github.io/mp-html/#/advanced/plugin) 了解更多
+
+## 使用案例
+
+| [官方示例](https://github.com/jin-yufeng/mp-html-demo) | 欢喜商城 | 多么生活 | 食法查 | 微慕 | 科学复习 |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| ![富文本插件](docs/assets/case/富文本插件.jpg) | ![欢喜商城](docs/assets/case/欢喜商城.png) | ![多么生活](docs/assets/case/多么生活.jpg) | ![食法查](docs/assets/case/食法查.png) | ![微慕](docs/assets/case/微慕.jpg) | ![科学复习](docs/assets/case/科学复习.png) |
+
+| [程序员技术之旅](https://github.com/fendoudebb/z-blog-wx) | 典典博客 | 优秀笔记 | 同城共享书 | [技术源 share](https://github.com/wangsrGit119/mini-blog-halo) | 你的代码写的真棒 |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| ![程序员技术之旅](docs/assets/case/程序员技术之旅.jpg) | ![典典博客](docs/assets/case/典典博客.jpg) | ![优秀笔记](docs/assets/case/优秀笔记.jpg) | ![同城共享书](docs/assets/case/同城共享书.jpg) | ![技术源share](docs/assets/case/技术源share.jpg) | ![你的代码写的真棒](docs/assets/case/你的代码写的真棒.jpg) |
+
+| 谛否 | 小莫唐尼 | [模版演示](https://github.com/zhihuifanqiechaodan/miniprogram-template) | AI瓦力 | 豆流便签 | 前端八股通 |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| ![谛否](docs/assets/case/谛否.jpg) | ![小莫唐尼](docs/assets/case/小莫唐尼.png) | ![MiniProgram模版演示](docs/assets/case/MiniProgram模版演示.jpg) | ![AI瓦力](docs/assets/case/AI瓦力.jpg) | ![豆流便签](docs/assets/case/豆流便签.jpg) | ![前端八股通](docs/assets/case/前端八股通.jpg) |
+
+以上排名不分先后,更多可见 [使用案例收集](https://github.com/jin-yufeng/mp-html/issues/27)(欢迎添加)  
+
+## 许可与支持
+- 许可  
+  您可以免费的使用(包括商用)、复制或修改本组件 [MIT License](https://github.com/jin-yufeng/mp-html/blob/master/LICENSE)  
+  在用于生产环境前务必经过充分测试,由插件 `bug` 带来的损失概不负责(可以自行修改源码)  
+
+- 联系  
+  欢迎加入 `QQ` 交流群:  
+  群1(已满):`699734691`  
+  群2(已满):`778239129`  
+  群3:`960265313`  
+  ![group](docs/assets/group.jpg)  
+
+- 支持  
+  ![支持](docs/assets/sponsor.png)   
+
+## 更新日志
+- v2.5.1 (20250420)
+  1. `U` `uni-app` 包适配鸿蒙 `APP` [详细](https://github.com/jin-yufeng/mp-html/issues/615)
+  2. `U` 微信小程序替换废弃 `api` `getSystemInfoSync` [详细](https://github.com/jin-yufeng/mp-html/issues/613)
+  3. `F` 修复了微信小程序 `glass-easel` 框架下真机换行异常的问题 [详细](https://github.com/jin-yufeng/mp-html/pull/607) by [@PaperStrike](https://github.com/PaperStrike)
+  4. `F` 修复了 `uni-app` 包 `app` 端播放视频可能报错的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/617)
+  5. `F` 修复了 `latex` 插件可能出现 `xxx can be used only in display mode` 的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/632)
+  6. `F` 修复了 `uni-app` 包 `latex` 公式可能不显示的问题  [#599](https://github.com/jin-yufeng/mp-html/issues/599)、[#627](https://github.com/jin-yufeng/mp-html/issues/627)
+
+- v2.5.0 (20240422)
+  1. `U` `play` 事件增加返回 `src` 等信息 [详细](https://github.com/jin-yufeng/mp-html/issues/526)
+  2. `U` `preview-img` 属性支持设置为 `all` 开启 `base64` 图片预览 [详细](https://github.com/jin-yufeng/mp-html/issues/536)
+  3. `U` `editable` 插件增加简易模式(点击文字直接编辑)
+  4. `U` `latex` 插件支持块级公式 [详细](https://github.com/jin-yufeng/mp-html/issues/582)
+  5. `F` 修复了表格部分情况下背景丢失的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/587)
+  6. `F` 修复了部分 `svg` 无法显示的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/591)
+  7. `F` 修复了 `uni-app` 包 `h5` 和 `app` 端部分情况下样式无法识别的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/518)
+  8. `F` 修复了 `latex` 插件部分情况下显示不正确的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/580)
+  9. `F` 修复了 `editable` 插件表格无法删除的问题
+  10. `F` 修复了 `editable` 插件 `uni-app` 包 `vue3` `h5` 端点击图片报错的问题
+  11. `F` 修复了 `editable` 插件 `uni-app` 包点击表格没有菜单栏的问题
+
+  从 `1.x` 的升级方法可见 [更新指南](https://jin-yufeng.github.io/mp-html/#/changelog/changelog?id=v200)
+
+查看 [更新日志](https://jin-yufeng.github.io/mp-html/#/changelog/changelog) 了解更多

+ 1 - 0
dist/mp-alipay/index.acss

@@ -0,0 +1 @@
+._root{padding:1px 0;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch}._select{-webkit-user-select:text;user-select:text}

+ 1 - 0
dist/mp-alipay/index.axml

@@ -0,0 +1 @@
+<view class="_root {{selectable?'_select':''}}" style="{{containerStyle}}"><slot a:if="{{!nodes[0]}}"/><node id="_root" childs="{{nodes}}" opts="{{[lazyLoad,loadingImg,errorImg,showImgMenu,selectable]}}" onAdd="_add"/></view>

文件差异内容过多而无法显示
+ 7 - 0
dist/mp-alipay/index.js


+ 1 - 0
dist/mp-alipay/index.json

@@ -0,0 +1 @@
+{"component":true,"usingComponents":{"node":"./node/node"}}

+ 1 - 0
dist/mp-alipay/node/node.acss

@@ -0,0 +1 @@
+._a{padding:1.5px 0 1.5px 0;color:#366092;word-break:break-all}._hover{text-decoration:underline;opacity:.7}._img{max-width:100%;-webkit-touch-callout:none}._b,._strong{font-weight:700}._code{font-family:monospace}._del{text-decoration:line-through}._em,._i{font-style:italic}._h1{font-size:2em}._h2{font-size:1.5em}._h3{font-size:1.17em}._h5{font-size:.83em}._h6{font-size:.67em}._h1,._h2,._h3,._h4,._h5,._h6{display:block;font-weight:700}._ins{text-decoration:underline}._li{display:list-item}._ol{list-style-type:decimal}._ol,._ul{display:block;padding-left:40px;margin:1em 0}._q::before{content:'"'}._q::after{content:'"'}._sub{font-size:smaller;vertical-align:sub}._sup{font-size:smaller;vertical-align:super}._tbody,._tfoot,._thead{display:table-row-group}._tr{display:table-row}._td,._th{display:table-cell;vertical-align:middle}._th{font-weight:700;text-align:center}._ul{list-style-type:disc}._ul ._ul{margin:0;list-style-type:circle}._ul ._ul ._ul{list-style-type:square}._abbr,._b,._code,._del,._em,._i,._ins,._label,._q,._span,._strong,._sub,._sup{display:inline}

文件差异内容过多而无法显示
+ 0 - 0
dist/mp-alipay/node/node.axml


文件差异内容过多而无法显示
+ 0 - 0
dist/mp-alipay/node/node.js


+ 1 - 0
dist/mp-alipay/node/node.json

@@ -0,0 +1 @@
+{"component":true,"usingComponents":{"node":"./node"}}

文件差异内容过多而无法显示
+ 0 - 0
dist/mp-alipay/parser.js


+ 1 - 0
dist/mp-baidu/index.css

@@ -0,0 +1 @@
+._root{padding:1px 0;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch}._select{-webkit-user-select:text;user-select:text}

文件差异内容过多而无法显示
+ 7 - 0
dist/mp-baidu/index.js


+ 1 - 0
dist/mp-baidu/index.json

@@ -0,0 +1 @@
+{"component":true,"usingComponents":{"node":"./node/node"}}

+ 1 - 0
dist/mp-baidu/index.swan

@@ -0,0 +1 @@
+<view class="_root {{selectable?'_select':''}}" style="{{containerStyle}}"><slot s-if="!nodes[0]"/><node id="_root" childs="{{nodes}}" opts="{{[lazyLoad,loadingImg,errorImg,showImgMenu,selectable]}}" catchadd="_add"/></view>

+ 1 - 0
dist/mp-baidu/node/node.css

@@ -0,0 +1 @@
+._a{padding:1.5px 0 1.5px 0;color:#366092;word-break:break-all}._hover{text-decoration:underline;opacity:.7}._img{max-width:100%;-webkit-touch-callout:none}._b,._strong{font-weight:700}._code{font-family:monospace}._del{text-decoration:line-through}._em,._i{font-style:italic}._h1{font-size:2em}._h2{font-size:1.5em}._h3{font-size:1.17em}._h5{font-size:.83em}._h6{font-size:.67em}._h1,._h2,._h3,._h4,._h5,._h6{display:block;font-weight:700}._ins{text-decoration:underline}._li{display:list-item}._ol{list-style-type:decimal}._ol,._ul{display:block;padding-left:40px;margin:1em 0}._q::before{content:'"'}._q::after{content:'"'}._sub{font-size:smaller;vertical-align:sub}._sup{font-size:smaller;vertical-align:super}._tbody,._tfoot,._thead{display:table-row-group}._tr{display:table-row}._td,._th{display:table-cell;vertical-align:middle}._th{font-weight:700;text-align:center}._ul{list-style-type:disc}._ul ._ul{margin:0;list-style-type:circle}._ul ._ul ._ul{list-style-type:square}._abbr,._b,._code,._del,._em,._i,._ins,._label,._q,._span,._strong,._sub,._sup{display:inline}._blockquote,._div,._p{display:block}

文件差异内容过多而无法显示
+ 0 - 0
dist/mp-baidu/node/node.js


+ 1 - 0
dist/mp-baidu/node/node.json

@@ -0,0 +1 @@
+{"component":true,"usingComponents":{"node":"./node"}}

文件差异内容过多而无法显示
+ 0 - 0
dist/mp-baidu/node/node.swan


文件差异内容过多而无法显示
+ 0 - 0
dist/mp-baidu/parser.js


文件差异内容过多而无法显示
+ 7 - 0
dist/mp-qq/index.js


+ 1 - 0
dist/mp-qq/index.json

@@ -0,0 +1 @@
+{"component":true,"usingComponents":{"node":"./node/node"}}

+ 1 - 0
dist/mp-qq/index.qml

@@ -0,0 +1 @@
+<view class="_root {{selectable?'_select':''}}" style="{{containerStyle}}"><slot qq:if="{{!nodes[0]}}"/><node id="_root" childs="{{nodes}}" opts="{{[lazyLoad,loadingImg,errorImg,showImgMenu,selectable]}}" catchadd="_add"/></view>

+ 1 - 0
dist/mp-qq/index.qss

@@ -0,0 +1 @@
+._root{padding:1px 0;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch}._select{-webkit-user-select:text;user-select:text}

文件差异内容过多而无法显示
+ 0 - 0
dist/mp-qq/node/node.js


+ 1 - 0
dist/mp-qq/node/node.json

@@ -0,0 +1 @@
+{"component":true,"usingComponents":{"node":"./node"}}

文件差异内容过多而无法显示
+ 0 - 0
dist/mp-qq/node/node.qml


+ 1 - 0
dist/mp-qq/node/node.qss

@@ -0,0 +1 @@
+._a{padding:1.5px 0 1.5px 0;color:#366092;word-break:break-all}._hover{text-decoration:underline;opacity:.7}._img{max-width:100%;-webkit-touch-callout:none}._b,._strong{font-weight:700}._code{font-family:monospace}._del{text-decoration:line-through}._em,._i{font-style:italic}._h1{font-size:2em}._h2{font-size:1.5em}._h3{font-size:1.17em}._h5{font-size:.83em}._h6{font-size:.67em}._h1,._h2,._h3,._h4,._h5,._h6{display:block;font-weight:700}._ins{text-decoration:underline}._li{display:list-item}._ol{list-style-type:decimal}._ol,._ul{display:block;padding-left:40px;margin:1em 0}._q::before{content:'"'}._q::after{content:'"'}._sub{font-size:smaller;vertical-align:sub}._sup{font-size:smaller;vertical-align:super}._tbody,._tfoot,._thead{display:table-row-group}._tr{display:table-row}._td,._th{display:table-cell;vertical-align:middle}._th{font-weight:700;text-align:center}._ul{list-style-type:disc}._ul ._ul{margin:0;list-style-type:circle}._ul ._ul ._ul{list-style-type:square}._abbr,._b,._code,._del,._em,._i,._ins,._label,._q,._span,._strong,._sub,._sup{display:inline}._blockquote,._div,._p{display:block}

文件差异内容过多而无法显示
+ 0 - 0
dist/mp-qq/parser.js


文件差异内容过多而无法显示
+ 7 - 0
dist/mp-toutiao/index.js


+ 1 - 0
dist/mp-toutiao/index.json

@@ -0,0 +1 @@
+{"component":true,"usingComponents":{"node":"./node/node"}}

+ 1 - 0
dist/mp-toutiao/index.ttml

@@ -0,0 +1 @@
+<view class="_root {{selectable?'_select':''}}" style="{{containerStyle}}"><slot tt:if="{{!nodes[0]}}"/><node id="_root" childs="{{nodes}}" opts="{{[lazyLoad,loadingImg,errorImg,showImgMenu,selectable]}}"/></view>

+ 1 - 0
dist/mp-toutiao/index.ttss

@@ -0,0 +1 @@
+._root{padding:1px 0;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch}._select{-webkit-user-select:text;user-select:text}

文件差异内容过多而无法显示
+ 0 - 0
dist/mp-toutiao/node/node.js


+ 1 - 0
dist/mp-toutiao/node/node.json

@@ -0,0 +1 @@
+{"component":true,"usingComponents":{"node":"./node"}}

文件差异内容过多而无法显示
+ 0 - 0
dist/mp-toutiao/node/node.ttml


+ 1 - 0
dist/mp-toutiao/node/node.ttss

@@ -0,0 +1 @@
+._a{padding:1.5px 0 1.5px 0;color:#366092;word-break:break-all}._hover{text-decoration:underline;opacity:.7}._img{max-width:100%;-webkit-touch-callout:none}._b,._strong{font-weight:700}._code{font-family:monospace}._del{text-decoration:line-through}._em,._i{font-style:italic}._h1{font-size:2em}._h2{font-size:1.5em}._h3{font-size:1.17em}._h5{font-size:.83em}._h6{font-size:.67em}._h1,._h2,._h3,._h4,._h5,._h6{display:block;font-weight:700}._ins{text-decoration:underline}._li{display:list-item}._ol{list-style-type:decimal}._ol,._ul{display:block;padding-left:40px;margin:1em 0}._q::before{content:'"'}._q::after{content:'"'}._sub{font-size:smaller;vertical-align:sub}._sup{font-size:smaller;vertical-align:super}._tbody,._tfoot,._thead{display:table-row-group}._tr{display:table-row}._td,._th{display:table-cell;vertical-align:middle}._th{font-weight:700;text-align:center}._ul{list-style-type:disc}._ul ._ul{margin:0;list-style-type:circle}._ul ._ul ._ul{list-style-type:square}._abbr,._b,._code,._del,._em,._i,._ins,._label,._q,._span,._strong,._sub,._sup{display:inline}

文件差异内容过多而无法显示
+ 0 - 0
dist/mp-toutiao/parser.js


文件差异内容过多而无法显示
+ 7 - 0
dist/mp-weixin/index.js


+ 1 - 0
dist/mp-weixin/index.json

@@ -0,0 +1 @@
+{"component":true,"usingComponents":{"node":"./node/node"}}

+ 1 - 0
dist/mp-weixin/index.wxml

@@ -0,0 +1 @@
+<view class="_root {{selectable?'_select':''}}" style="{{containerStyle}}"><slot wx:if="{{!nodes[0]}}"/><node id="_root" childs="{{nodes}}" opts="{{[lazyLoad,loadingImg,errorImg,showImgMenu,selectable]}}" catchadd="_add"/></view>

+ 1 - 0
dist/mp-weixin/index.wxss

@@ -0,0 +1 @@
+._root{padding:1px 0;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch}._select{-webkit-user-select:text;user-select:text}

文件差异内容过多而无法显示
+ 0 - 0
dist/mp-weixin/node/node.js


+ 1 - 0
dist/mp-weixin/node/node.json

@@ -0,0 +1 @@
+{"component":true,"usingComponents":{"node":"./node"}}

文件差异内容过多而无法显示
+ 0 - 0
dist/mp-weixin/node/node.wxml


+ 1 - 0
dist/mp-weixin/node/node.wxss

@@ -0,0 +1 @@
+._a{padding:1.5px 0 1.5px 0;color:#366092;word-break:break-all}._hover{text-decoration:underline;opacity:.7}._img{max-width:100%;-webkit-touch-callout:none}._b,._strong{font-weight:700}._code{font-family:monospace}._del{text-decoration:line-through}._em,._i{font-style:italic}._h1{font-size:2em}._h2{font-size:1.5em}._h3{font-size:1.17em}._h5{font-size:.83em}._h6{font-size:.67em}._h1,._h2,._h3,._h4,._h5,._h6{display:block;font-weight:700}._ins{text-decoration:underline}._li{display:list-item}._ol{list-style-type:decimal}._ol,._ul{display:block;padding-left:40px;margin:1em 0}._q::before{content:'"'}._q::after{content:'"'}._sub{font-size:smaller;vertical-align:sub}._sup{font-size:smaller;vertical-align:super}._tbody,._tfoot,._thead{display:table-row-group}._tr{display:table-row}._td,._th{display:table-cell;vertical-align:middle}._th{font-weight:700;text-align:center}._ul{list-style-type:disc}._ul ._ul{margin:0;list-style-type:circle}._ul ._ul ._ul{list-style-type:square}._abbr,._b,._code,._del,._em,._i,._ins,._label,._q,._span,._strong,._sub,._sup{display:inline}._blockquote,._div,._p{display:block}

文件差异内容过多而无法显示
+ 0 - 0
dist/mp-weixin/parser.js


+ 74 - 0
dist/uni-app/components/mp-html/aliyun-video/aliyun-video.vue

@@ -0,0 +1,74 @@
+<template>
+  <view :class="loadingClass" v-if="!url">{{ loadingText }}</view>
+  <block v-else>
+    <video :src="url"></video>
+  </block>
+</template>
+
+<script>
+import { handle, request } from "@kasite/uni-app-base";
+import { REQUEST_CONFIG } from "@kasite/uni-app-base/config"
+export default {
+  props: {
+    // 视频ID
+    videoId: {
+      type: [String, null],
+      default: null,
+    },
+  },
+  data() {
+    return {
+      url: "",
+      loadingText: "视频解码中",
+      loadingClass: "aliyun-video-loading-text",
+    };
+  },
+  methods: {
+    async doPost(url, data, options) {
+      let resp = handle.promistHandleNew(await request.doPost(url, data, options));
+      return handle.catchPromiseNew(resp, () => resp, options);
+    },
+    async getVideoUrlByIdasync(id) {
+      if (!id) return;
+      const { resp } = await this.doPost(`${REQUEST_CONFIG.BASE_URL}wsgw/article/video/GetVideoUrl/callApiJSON.do`, { ID: id });
+      const url = resp && resp.length ? resp[0].Url : "";
+      if (!url) {
+        this.loadingClass = "aliyun-video-loading-fail-text";
+        this.loadingText = "视频未完成解码,请刷新页面或稍后再试";
+      } else {
+        this.url = url;
+      }
+    },
+  },
+  mounted() {
+    this.getVideoUrlByIdasync(this.videoId);
+  },
+};
+</script>
+
+<style>
+.aliyun-video-loading-fail-text {
+  color: red;
+}
+.aliyun-video-loading-text::after {
+  animation: aliyun-video-loading-dots 2s linear infinite;
+  content: "";
+}
+@keyframes aliyun-video-loading-dots {
+  0% {
+    content: ".";
+  }
+
+  30% {
+    content: "..";
+  }
+
+  60% {
+    content: "...";
+  }
+
+  90% {
+    content: "";
+  }
+}
+</style>

+ 7 - 0
dist/uni-app/components/mp-html/aliyun-video/index.js

@@ -0,0 +1,7 @@
+/**
+ * @fileoverview AliyunVideo 插件
+ */
+function AliyunVideo (vm) {
+}
+
+export default AliyunVideo

+ 63 - 0
dist/uni-app/components/mp-html/channel-video/channel-video.vue

@@ -0,0 +1,63 @@
+<template>
+  <block v-if="type === 'channelVideoQrCode'">
+    <view class="channel-video-qrcode">
+      <view>
+        <image style="width: 240rpx; height: 240rpx" :src="src"></image>
+        <view>扫码查看视频内容</view>
+      </view>
+    </view>
+  </block>
+  <block v-else>
+    <view class="channel-video">
+      <channel-video :feed-id="feedId" :finder-user-name="finderUserName"></channel-video>
+      <view style="color: var(--uni-color-primary)">点击观看视频</view>
+    </view>
+  </block>
+</template>
+
+<script>
+import { handle, request } from "@kasite/uni-app-base";
+import { REQUEST_CONFIG } from "@kasite/uni-app-base/config";
+export default {
+  props: {
+    type: {
+      type: String,
+      default: "",
+    },
+    src: {
+      type: String,
+      default: "",
+    },
+    feedId: {
+      type: String,
+      default: "",
+    },
+    finderUserName: {
+      type: String,
+      default: "",
+    },
+    
+  },
+  data() {
+    return {};
+  },
+  methods: {},
+  mounted() {},
+};
+</script>
+
+<style>
+.channel-video {
+  display: flex;
+  justify-content: center;
+}
+.channel-video-qrcode {
+  display: flex;
+  justify-content: center;
+}
+.channel-video-qrcode > view {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+</style>

+ 7 - 0
dist/uni-app/components/mp-html/channel-video/index.js

@@ -0,0 +1,7 @@
+/**
+ * @fileoverview ChannelVideo 插件
+ */
+function ChannelVideo (vm) {
+}
+
+export default ChannelVideo

+ 504 - 0
dist/uni-app/components/mp-html/mp-html.vue

@@ -0,0 +1,504 @@
+<template>
+  <view id="_root" :class="(selectable?'_select ':'')+'_root'" :style="containerStyle">
+    <slot v-if="!nodes[0]" />
+    <!-- #ifndef APP-PLUS-NVUE -->
+    <node v-else :token="token" :childs="nodes" :opts="[lazyLoad,loadingImg,errorImg,showImgMenu,selectable]" name="span" />
+    <!-- #endif -->
+    <!-- #ifdef APP-PLUS-NVUE -->
+    <web-view ref="web" src="/static/app-plus/mp-html/local.html" :style="'margin-top:-2px;height:' + height + 'px'" @onPostMessage="_onMessage" />
+    <!-- #endif -->
+  </view>
+</template>
+
+<script>
+/**
+ * mp-html v2.5.1
+ * @description 富文本组件
+ * @tutorial https://github.com/jin-yufeng/mp-html
+ * @property {String} container-style 容器的样式
+ * @property {String} content 用于渲染的 html 字符串
+ * @property {Boolean} copy-link 是否允许外部链接被点击时自动复制
+ * @property {String} domain 主域名,用于拼接链接
+ * @property {String} error-img 图片出错时的占位图链接
+ * @property {Boolean} lazy-load 是否开启图片懒加载
+ * @property {string} loading-img 图片加载过程中的占位图链接
+ * @property {Boolean} pause-video 是否在播放一个视频时自动暂停其他视频
+ * @property {Boolean} preview-img 是否允许图片被点击时自动预览
+ * @property {Boolean} scroll-table 是否给每个表格添加一个滚动层使其能单独横向滚动
+ * @property {Boolean | String} selectable 是否开启长按复制
+ * @property {Boolean} set-title 是否将 title 标签的内容设置到页面标题
+ * @property {Boolean} show-img-menu 是否允许图片被长按时显示菜单
+ * @property {Object} tag-style 标签的默认样式
+ * @property {Boolean | Number} use-anchor 是否使用锚点链接
+ * @event {Function} load dom 结构加载完毕时触发
+ * @event {Function} ready 所有图片加载完毕时触发
+ * @event {Function} imgtap 图片被点击时触发
+ * @event {Function} linktap 链接被点击时触发
+ * @event {Function} play 音视频播放时触发
+ * @event {Function} error 媒体加载出错时触发
+ */
+// #ifndef APP-PLUS-NVUE
+import node from './node/node'
+// #endif
+import Parser from './parser'
+import aliyunVideo from './aliyun-video/index.js'
+import channelVideo from './channel-video/index.js'
+const plugins=[aliyunVideo,channelVideo,]
+// #ifdef APP-PLUS-NVUE
+const dom = weex.requireModule('dom')
+// #endif
+export default {
+  name: 'mp-html',
+  data () {
+    return {
+      nodes: [],
+      // #ifdef APP-PLUS-NVUE
+      height: 3
+      // #endif
+    }
+  },
+  props: {
+    containerStyle: {
+      type: String,
+      default: ''
+    },
+    content: {
+      type: String,
+      default: ''
+    },
+    copyLink: {
+      type: [Boolean, String],
+      default: true
+    },
+    domain: String,
+    errorImg: {
+      type: String,
+      default: ''
+    },
+    lazyLoad: {
+      type: [Boolean, String],
+      default: false
+    },
+    loadingImg: {
+      type: String,
+      default: ''
+    },
+    pauseVideo: {
+      type: [Boolean, String],
+      default: true
+    },
+    previewImg: {
+      type: [Boolean, String],
+      default: true
+    },
+    scrollTable: [Boolean, String],
+    selectable: [Boolean, String],
+    setTitle: {
+      type: [Boolean, String],
+      default: true
+    },
+    showImgMenu: {
+      type: [Boolean, String],
+      default: true
+    },
+    tagStyle: Object,
+    useAnchor: [Boolean, Number],
+    token: {
+      type: String,
+      default: ''
+    },
+  },
+  // #ifdef VUE3
+  emits: ['load', 'ready', 'imgtap', 'linktap', 'play', 'error'],
+  // #endif
+  // #ifndef APP-PLUS-NVUE
+  components: {
+    node
+  },
+  // #endif
+  watch: {
+    content (content) {
+      this.setContent(content)
+    }
+  },
+  created () {
+    this.plugins = []
+    for (let i = plugins.length; i--;) {
+      this.plugins.push(new plugins[i](this))
+    }
+  },
+  mounted () {
+    if (this.content && !this.nodes.length) {
+      this.setContent(this.content)
+    }
+  },
+  beforeDestroy () {
+    this._hook('onDetached')
+  },
+  methods: {
+    /**
+     * @description 将锚点跳转的范围限定在一个 scroll-view 内
+     * @param {Object} page scroll-view 所在页面的示例
+     * @param {String} selector scroll-view 的选择器
+     * @param {String} scrollTop scroll-view scroll-top 属性绑定的变量名
+     */
+    in (page, selector, scrollTop) {
+      // #ifndef APP-PLUS-NVUE
+      if (page && selector && scrollTop) {
+        this._in = {
+          page,
+          selector,
+          scrollTop
+        }
+      }
+      // #endif
+    },
+
+    /**
+     * @description 锚点跳转
+     * @param {String} id 要跳转的锚点 id
+     * @param {Number} offset 跳转位置的偏移量
+     * @returns {Promise}
+     */
+    navigateTo (id, offset) {
+      return new Promise((resolve, reject) => {
+        if (!this.useAnchor) {
+          reject(Error('Anchor is disabled'))
+          return
+        }
+        offset = offset || parseInt(this.useAnchor) || 0
+        // #ifdef APP-PLUS-NVUE
+        if (!id) {
+          dom.scrollToElement(this.$refs.web, {
+            offset
+          })
+          resolve()
+        } else {
+          this._navigateTo = {
+            resolve,
+            reject,
+            offset
+          }
+          this.$refs.web.evalJs('uni.postMessage({data:{action:"getOffset",offset:(document.getElementById(' + id + ')||{}).offsetTop}})')
+        }
+        // #endif
+        // #ifndef APP-PLUS-NVUE
+        let deep = ' '
+        // #ifdef MP-WEIXIN || MP-QQ || MP-TOUTIAO
+        deep = '>>>'
+        // #endif
+        const selector = uni.createSelectorQuery()
+          // #ifndef MP-ALIPAY
+          .in(this._in ? this._in.page : this)
+          // #endif
+          .select((this._in ? this._in.selector : '._root') + (id ? `${deep}#${id}` : '')).boundingClientRect()
+        if (this._in) {
+          selector.select(this._in.selector).scrollOffset()
+            .select(this._in.selector).boundingClientRect()
+        } else {
+          // 获取 scroll-view 的位置和滚动距离
+          selector.selectViewport().scrollOffset() // 获取窗口的滚动距离
+        }
+        selector.exec(res => {
+          if (!res[0]) {
+            reject(Error('Label not found'))
+            return
+          }
+          const scrollTop = res[1].scrollTop + res[0].top - (res[2] ? res[2].top : 0) + offset
+          if (this._in) {
+            // scroll-view 跳转
+            this._in.page[this._in.scrollTop] = scrollTop
+          } else {
+            // 页面跳转
+            uni.pageScrollTo({
+              scrollTop,
+              duration: 300
+            })
+          }
+          resolve()
+        })
+        // #endif
+      })
+    },
+
+    /**
+     * @description 获取文本内容
+     * @return {String}
+     */
+    getText (nodes) {
+      let text = '';
+      (function traversal (nodes) {
+        for (let i = 0; i < nodes.length; i++) {
+          const node = nodes[i]
+          if (node.type === 'text') {
+            text += node.text.replace(/&amp;/g, '&')
+          } else if (node.name === 'br') {
+            text += '\n'
+          } else {
+            // 块级标签前后加换行
+            const isBlock = node.name === 'p' || node.name === 'div' || node.name === 'tr' || node.name === 'li' || (node.name[0] === 'h' && node.name[1] > '0' && node.name[1] < '7')
+            if (isBlock && text && text[text.length - 1] !== '\n') {
+              text += '\n'
+            }
+            // 递归获取子节点的文本
+            if (node.children) {
+              traversal(node.children)
+            }
+            if (isBlock && text[text.length - 1] !== '\n') {
+              text += '\n'
+            } else if (node.name === 'td' || node.name === 'th') {
+              text += '\t'
+            }
+          }
+        }
+      })(nodes || this.nodes)
+      return text
+    },
+
+    /**
+     * @description 获取内容大小和位置
+     * @return {Promise}
+     */
+    getRect () {
+      return new Promise((resolve, reject) => {
+        uni.createSelectorQuery()
+          // #ifndef MP-ALIPAY
+          .in(this)
+          // #endif
+          .select('#_root').boundingClientRect().exec(res => res[0] ? resolve(res[0]) : reject(Error('Root label not found')))
+      })
+    },
+
+    /**
+     * @description 暂停播放媒体
+     */
+    pauseMedia () {
+      for (let i = (this._videos || []).length; i--;) {
+        this._videos[i].pause()
+      }
+      // #ifdef APP-PLUS
+      const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].pause()'
+      // #ifndef APP-PLUS-NVUE
+      let page = this.$parent
+      while (!page.$scope) page = page.$parent
+      page.$scope.$getAppWebview().evalJS(command)
+      // #endif
+      // #ifdef APP-PLUS-NVUE
+      this.$refs.web.evalJs(command)
+      // #endif
+      // #endif
+    },
+
+    /**
+     * @description 设置媒体播放速率
+     * @param {Number} rate 播放速率
+     */
+    setPlaybackRate (rate) {
+      this.playbackRate = rate
+      for (let i = (this._videos || []).length; i--;) {
+        this._videos[i].playbackRate(rate)
+      }
+      // #ifdef APP-PLUS
+      const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].playbackRate=' + rate
+      // #ifndef APP-PLUS-NVUE
+      let page = this.$parent
+      while (!page.$scope) page = page.$parent
+      page.$scope.$getAppWebview().evalJS(command)
+      // #endif
+      // #ifdef APP-PLUS-NVUE
+      this.$refs.web.evalJs(command)
+      // #endif
+      // #endif
+    },
+
+    /**
+     * @description 设置内容
+     * @param {String} content html 内容
+     * @param {Boolean} append 是否在尾部追加
+     */
+    setContent (content, append) {
+      if (!append || !this.imgList) {
+        this.imgList = []
+      }
+      const nodes = new Parser(this).parse(content)
+      // #ifdef APP-PLUS-NVUE
+      if (this._ready) {
+        this._set(nodes, append)
+      }
+      // #endif
+      this.$set(this, 'nodes', append ? (this.nodes || []).concat(nodes) : nodes)
+
+      // #ifndef APP-PLUS-NVUE
+      this._videos = []
+      this.$nextTick(() => {
+        this._hook('onLoad')
+        this.$emit('load')
+      })
+
+      if (this.lazyLoad || this.imgList._unloadimgs < this.imgList.length / 2) {
+        // 设置懒加载,每 350ms 获取高度,不变则认为加载完毕
+        let height = 0
+        const callback = rect => {
+          if (!rect || !rect.height) rect = {}
+          // 350ms 总高度无变化就触发 ready 事件
+          if (rect.height === height) {
+            this.$emit('ready', rect)
+          } else {
+            height = rect.height
+            setTimeout(() => {
+              this.getRect().then(callback).catch(callback)
+            }, 350)
+          }
+        }
+        this.getRect().then(callback).catch(callback)
+      } else {
+        // 未设置懒加载,等待所有图片加载完毕
+        if (!this.imgList._unloadimgs) {
+          this.getRect().then(rect => {
+            this.$emit('ready', rect)
+          }).catch(() => {
+            this.$emit('ready', {})
+          })
+        }
+      }
+      // #endif
+    },
+
+    /**
+     * @description 调用插件钩子函数
+     */
+    _hook (name) {
+      for (let i = plugins.length; i--;) {
+        if (this.plugins[i][name]) {
+          this.plugins[i][name]()
+        }
+      }
+    },
+
+    // #ifdef APP-PLUS-NVUE
+    /**
+     * @description 设置内容
+     */
+    _set (nodes, append) {
+      this.$refs.web.evalJs('setContent(' + JSON.stringify(nodes).replace(/%22/g, '') + ',' + JSON.stringify([this.containerStyle.replace(/(?:margin|padding)[^;]+/g, ''), this.errorImg, this.loadingImg, this.pauseVideo, this.scrollTable, this.selectable]) + ',' + append + ')')
+    },
+
+    /**
+     * @description 接收到 web-view 消息
+     */
+    _onMessage (e) {
+      const message = e.detail.data[0]
+      switch (message.action) {
+        // web-view 初始化完毕
+        case 'onJSBridgeReady':
+          this._ready = true
+          if (this.nodes) {
+            this._set(this.nodes)
+          }
+          break
+        // 内容 dom 加载完毕
+        case 'onLoad':
+          this.height = message.height
+          this._hook('onLoad')
+          this.$emit('load')
+          break
+        // 所有图片加载完毕
+        case 'onReady':
+          this.getRect().then(res => {
+            this.$emit('ready', res)
+          }).catch(() => {
+            this.$emit('ready', {})
+          })
+          break
+        // 总高度发生变化
+        case 'onHeightChange':
+          this.height = message.height
+          break
+        // 图片点击
+        case 'onImgTap':
+          this.$emit('imgtap', message.attrs)
+          if (this.previewImg) {
+            uni.previewImage({
+              current: parseInt(message.attrs.i),
+              urls: this.imgList
+            })
+          }
+          break
+        // 链接点击
+        case 'onLinkTap': {
+          const href = message.attrs.href
+          this.$emit('linktap', message.attrs)
+          if (href) {
+            // 锚点跳转
+            if (href[0] === '#') {
+              if (this.useAnchor) {
+                dom.scrollToElement(this.$refs.web, {
+                  offset: message.offset
+                })
+              }
+            } else if (href.includes('://')) {
+              // 打开外链
+              if (this.copyLink) {
+                plus.runtime.openWeb(href)
+              }
+            } else {
+              uni.navigateTo({
+                url: href,
+                fail () {
+                  uni.switchTab({
+                    url: href
+                  })
+                }
+              })
+            }
+          }
+          break
+        }
+        case 'onPlay':
+          this.$emit('play')
+          break
+        // 获取到锚点的偏移量
+        case 'getOffset':
+          if (typeof message.offset === 'number') {
+            dom.scrollToElement(this.$refs.web, {
+              offset: message.offset + this._navigateTo.offset
+            })
+            this._navigateTo.resolve()
+          } else {
+            this._navigateTo.reject(Error('Label not found'))
+          }
+          break
+        // 点击
+        case 'onClick':
+          this.$emit('tap')
+          this.$emit('click')
+          break
+        // 出错
+        case 'onError':
+          this.$emit('error', {
+            source: message.source,
+            attrs: message.attrs
+          })
+      }
+    }
+    // #endif
+  }
+}
+</script>
+
+<style>
+/* #ifndef APP-PLUS-NVUE */
+/* 根节点样式 */
+._root {
+  padding: 1px 0;
+  overflow-x: auto;
+  overflow-y: hidden;
+  -webkit-overflow-scrolling: touch;
+}
+
+/* 长按复制 */
+._select {
+  user-select: text;
+}
+/* #endif */
+</style>

+ 629 - 0
dist/uni-app/components/mp-html/node/node.vue

@@ -0,0 +1,629 @@
+<template>
+  <view :id="attrs.id" :class="'_block _'+name+' '+attrs.class" :style="attrs.style">
+    <block v-for="(n, i) in nodes" v-bind:key="i">
+      <!-- 图片 -->
+      <!-- 占位图 -->
+      <image v-if="n.name==='img'&&!n.t&&((opts[1]&&!ctrl[i])||ctrl[i]<0)" class="_img" :style="n.attrs.style" :src="ctrl[i]<0?opts[2]:opts[1]" mode="widthFix" />
+      <!-- 显示图片 -->
+      <!-- #ifdef H5 || (APP-PLUS && VUE2) -->
+      <img v-if="n.name==='img'" :id="n.attrs.id" :class="'_img '+n.attrs.class" :style="(ctrl[i]===-1?'display:none;':'')+n.attrs.style" :src="n.attrs.src||(ctrl.load?n.attrs['data-src']:'')" :data-i="i" @load="imgLoad" @error="mediaError" @tap.stop="imgTap" @longpress="imgLongTap" />
+      <!-- #endif -->
+      <!-- #ifndef H5 || (APP-PLUS && VUE2) -->
+      <!-- 表格中的图片,使用 rich-text 防止大小不正确 -->
+      <rich-text v-if="n.name==='img'&&n.t" :style="'display:'+n.t" :nodes="[{attrs:{style:n.attrs.style||'',src:n.attrs.src},name:'img'}]" :data-i="i" @tap.stop="imgTap" />
+      <!-- #endif -->
+      <!-- #ifdef APP-HARMONY -->
+      <image v-else-if="n.name==='img'" :id="n.attrs.id" :class="'_img '+n.attrs.class" :style="(ctrl[i]===-1?'display:none;':'')+'width:'+ctrl[i]+'px;'+n.attrs.style" :src="n.attrs.src||(ctrl.load?n.attrs['data-src']:'')" :mode="!n.h?'widthFix':(!n.w?'heightFix':(n.m||'scaleToFill'))" :data-i="i" @load="imgLoad" @error="mediaError" @tap.stop="imgTap" @longpress="imgLongTap" />
+      <!-- #endif -->
+      <!-- #ifndef H5 || APP-PLUS || MP-KUAISHOU -->
+      <image v-else-if="n.name==='img'" :id="n.attrs.id" :class="'_img '+n.attrs.class" :style="(ctrl[i]===-1?'display:none;':'')+'width:'+(ctrl[i]||1)+'px;height:1px;'+n.attrs.style" :src="n.attrs.src" :mode="!n.h?'widthFix':(!n.w?'heightFix':(n.m||'scaleToFill'))" :lazy-load="opts[0]" :webp="n.webp" :show-menu-by-longpress="opts[3]&&!n.attrs.ignore" :image-menu-prevent="!opts[3]||n.attrs.ignore" :data-i="i" @load="imgLoad" @error="mediaError" @tap.stop="imgTap" @longpress="imgLongTap" />
+      <!-- #endif -->
+      <!-- #ifdef MP-KUAISHOU -->
+      <image v-else-if="n.name==='img'" :id="n.attrs.id" :class="'_img '+n.attrs.class" :style="(ctrl[i]===-1?'display:none;':'')+n.attrs.style" :src="n.attrs.src" :lazy-load="opts[0]" :data-i="i" @load="imgLoad" @error="mediaError" @tap.stop="imgTap"></image>
+      <!-- #endif -->
+      <!-- #ifdef APP-PLUS && VUE3 -->
+      <image v-else-if="n.name==='img'" :id="n.attrs.id" :class="'_img '+n.attrs.class" :style="(ctrl[i]===-1?'display:none;':'')+'width:'+(ctrl[i]||1)+'px;'+n.attrs.style" :src="n.attrs.src||(ctrl.load?n.attrs['data-src']:'')" :mode="!n.h?'widthFix':(!n.w?'heightFix':(n.m||''))" :data-i="i" @load="imgLoad" @error="mediaError" @tap.stop="imgTap" @longpress="imgLongTap" />
+      <!-- #endif -->
+      <!-- 文本 -->
+      <!-- #ifdef MP-WEIXIN -->
+      <text v-else-if="n.text" :user-select="opts[4]=='force'&&isiOS" decode>{{n.text}}</text>
+      <!-- #endif -->
+      <!-- #ifndef MP-WEIXIN || MP-BAIDU || MP-ALIPAY || MP-TOUTIAO -->
+      <text v-else-if="n.text" decode>{{n.text}}</text>
+      <!-- #endif -->
+      <text v-else-if="n.name==='br'">{{'\n'}}</text>
+      <!-- 链接 -->
+      <view v-else-if="n.name==='a'" :id="n.attrs.id" :class="(n.attrs.href?'_a ':'')+n.attrs.class" hover-class="_hover" :style="'display:inline;'+n.attrs.style" :data-i="i" @tap.stop="linkTap">
+        <node name="span" :childs="n.children" :opts="opts" style="display:inherit" />
+      </view>
+      <!-- 视频 -->
+      <!-- #ifdef APP-PLUS -->
+      <view v-else-if="n.html" :id="n.attrs.id" :class="'_video '+n.attrs.class" :style="n.attrs.style" v-html="n.html" :data-i="i" @vplay.stop="play" />
+      <!-- #endif -->
+      <!-- #ifndef APP-PLUS -->
+      <video v-else-if="n.name==='video'" :id="n.attrs.id" :class="n.attrs.class" :style="n.attrs.style" :autoplay="n.attrs.autoplay" :controls="n.attrs.controls" :loop="n.attrs.loop" :muted="n.attrs.muted" :object-fit="n.attrs['object-fit']" :poster="n.attrs.poster" :src="urlWithToken(n.src[ctrl[i]||0])" :data-i="i" @play="play" @error="mediaError" />
+      <template v-else-if="n.name==='channel-video'">
+				<wx-channel-video :feed-id="n.attrs['data-feed-id']" :finder-user-name="n.attrs['data-finder-user-name']" :src="n.attrs['data-url']" />
+			</template>
+      <template v-else-if="n.name==='aliyun-video'">
+				<aliyun-video :video-id="n.attrs['data-id']" :class="n.attrs.class" :style="n.attrs.style" :autoplay="n.attrs.autoplay" :controls="n.attrs.controls" :loop="n.attrs.loop" :muted="n.attrs.muted" :object-fit="n.attrs['object-fit']" :poster="n.attrs.poster" :data-i="i" @play="play" @error="mediaError" />
+			</template>
+      <!-- #endif -->
+      <!-- #ifdef H5 || APP-PLUS -->
+      <iframe v-else-if="n.name==='iframe'" :style="n.attrs.style" :allowfullscreen="n.attrs.allowfullscreen" :frameborder="n.attrs.frameborder" :src="n.attrs.src" />
+      <embed v-else-if="n.name==='embed'" :style="n.attrs.style" :src="n.attrs.src" />
+      <!-- #endif -->
+      <!-- #ifndef MP-TOUTIAO || ((H5 || APP-PLUS) && VUE3) -->
+      <!-- 音频 -->
+      <audio v-else-if="n.name==='audio'" :id="n.attrs.id" :class="n.attrs.class" :style="n.attrs.style" :author="n.attrs.author" :controls="n.attrs.controls" :loop="n.attrs.loop" :name="n.attrs.name" :poster="n.attrs.poster" :src="urlWithToken(n.src[ctrl[i]||0])" :data-i="i" @play="play" @error="mediaError" />
+      <!-- #endif -->
+      <view v-else-if="(n.name==='table'&&n.c)||n.name==='li'" :id="n.attrs.id" :class="'_'+n.name+' '+n.attrs.class" :style="n.attrs.style">
+        <node v-if="n.name==='li'" :childs="n.children" :opts="opts" />
+        <view v-else v-for="(tbody, x) in n.children" v-bind:key="x" :class="'_'+tbody.name+' '+tbody.attrs.class" :style="tbody.attrs.style">
+          <node v-if="tbody.name==='td'||tbody.name==='th'" :childs="tbody.children" :opts="opts" />
+          <block v-else v-for="(tr, y) in tbody.children" v-bind:key="y">
+            <view v-if="tr.name==='td'||tr.name==='th'" :class="'_'+tr.name+' '+tr.attrs.class" :style="tr.attrs.style">
+              <node :childs="tr.children" :opts="opts" />
+            </view>
+            <view v-else :class="'_'+tr.name+' '+tr.attrs.class" :style="tr.attrs.style">
+              <view v-for="(td, z) in tr.children" v-bind:key="z" :class="'_'+td.name+' '+td.attrs.class" :style="td.attrs.style">
+                <node :childs="td.children" :opts="opts" />
+              </view>
+            </view>
+          </block>
+        </view>
+      </view>
+      <aliyun-video v-else-if="n.name=='aliyun-video'" :video-id="n.attrs['data-id']" :class="n.attrs.class" :style="n.attrs.style" :mode="opts[5]" :src="n.attrs.src" :title="n.attrs.title" :desc="n.attrs.desc" :url="n.attrs.url" :color="n.attrs.color" :bgcolor="n.attrs.bgcolor" :border="n.attrs.border" :name="n.attrs.name" :data-i="i" data-source="card" /><channel-video v-else-if="n.name=='channel-video'" data-type='channelVideoQrCode' />
+      <!-- 富文本 -->
+      <!-- #ifdef H5 || ((MP-WEIXIN || MP-QQ || APP-PLUS || MP-360) && VUE2) -->
+      <rich-text v-else-if="!n.c&&!handler.isInline(n.name, n.attrs.style)" :id="n.attrs.id" :style="n.f" :user-select="opts[4]" :nodes="[n]" />
+      <!-- #endif -->
+      <!-- #ifndef H5 || ((MP-WEIXIN || MP-QQ || APP-PLUS || MP-360) && VUE2) -->
+      <rich-text v-else-if="!n.c" :id="n.attrs.id" :style="'display:inline;'+n.f" :preview="false" :selectable="opts[4]" :user-select="opts[4]" :nodes="[n]" />
+      <!-- #endif -->
+      <!-- 继续递归 -->
+      <view v-else-if="n.c===2" :id="n.attrs.id" :class="'_block _'+n.name+' '+n.attrs.class" :style="n.f+';'+n.attrs.style">
+        <node v-for="(n2, j) in n.children" v-bind:key="j" :style="n2.f" :name="n2.name" :attrs="n2.attrs" :childs="n2.children" :opts="opts" />
+      </view>
+      <node v-else :style="n.f" :name="n.name" :attrs="n.attrs" :childs="n.children" :opts="opts" />
+    </block>
+  </view>
+</template>
+<script module="handler" lang="wxs">
+// 行内标签列表
+var inlineTags = {
+  abbr: true,
+  b: true,
+  big: true,
+  code: true,
+  del: true,
+  em: true,
+  i: true,
+  ins: true,
+  label: true,
+  q: true,
+  small: true,
+  span: true,
+  strong: true,
+  sub: true,
+  sup: true
+}
+/**
+ * @description 判断是否为行内标签
+ */
+module.exports = {
+  isInline: function (tagName, style) {
+    return inlineTags[tagName] || (style || '').indexOf('display:inline') !== -1
+  }
+}
+</script>
+<script>
+import aliyunVideo from '../aliyun-video/aliyun-video'
+import wxChannelVideo from '../channel-video/channel-video'
+
+import node from './node'
+export default {
+  name: 'node',
+  options: {
+    // #ifdef MP-WEIXIN
+    virtualHost: true,
+    // #endif
+    // #ifdef MP-TOUTIAO
+    addGlobalClass: false
+    // #endif
+  },
+  data () {
+    return {
+      ctrl: {},
+      nodes: [],
+      // #ifdef MP-WEIXIN
+      isiOS: uni.getDeviceInfo().system.includes('iOS')
+      // #endif
+    }
+  },
+  props: {
+    name: String,
+    attrs: {
+      type: Object,
+      default () {
+        return {}
+      }
+    },
+    childs: Array,
+    opts: Array,
+    token: String
+  },
+  watch: {
+    childs: {
+		  handler (nodes) {
+        // 列表缩短会刷新整个列表,因此进行空填充
+        while (this.nodes.length > nodes.length) {
+			    nodes.push({})
+		    }
+        this.nodes = nodes
+      },
+	    immediate: true
+	  }
+  },
+  components: {
+aliyunVideo,
+wxChannelVideo,
+
+    // #ifndef ((H5 || APP-PLUS) && VUE3) || APP-HARMONY
+    node
+    // #endif
+  },
+  mounted () {
+    this.$nextTick(() => {
+      for (this.root = this.$parent; this.root.$options.name !== 'mp-html'; this.root = this.root.$parent);
+    })
+    // #ifdef H5 || APP-PLUS
+    if (this.opts[0]) {
+      let i
+      for (i = this.childs.length; i--;) {
+        if (this.childs[i].name === 'img') break
+      }
+      if (i !== -1) {
+        this.observer = uni.createIntersectionObserver(this).relativeToViewport({
+          top: 500,
+          bottom: 500
+        })
+        this.observer.observe('._img', res => {
+          if (res.intersectionRatio) {
+            this.$set(this.ctrl, 'load', 1)
+            this.observer.disconnect()
+          }
+        })
+      }
+    }
+    // #endif
+  },
+  beforeDestroy () {
+    // #ifdef H5 || APP-PLUS
+    if (this.observer) {
+      this.observer.disconnect()
+    }
+    // #endif
+  },
+  methods:{
+    // #ifdef MP-WEIXIN
+    toJSON () { return this },
+    // #endif
+    /**
+     * @description 播放视频事件
+     * @param {Event} e
+     */
+    play (e) {
+      const i = e.currentTarget.dataset.i
+      const node = this.childs[i]
+      this.root.$emit('play', {
+        source: node.name,
+        attrs: {
+          ...node.attrs,
+          src: node.src[this.ctrl[i] || 0]
+        }
+      })
+      // #ifndef APP-PLUS
+      if (this.root.pauseVideo) {
+        let flag = false
+        const id = e.target.id
+        for (let i = this.root._videos.length; i--;) {
+          if (this.root._videos[i].id === id) {
+            flag = true
+          } else {
+            this.root._videos[i].pause() // 自动暂停其他视频
+          }
+        }
+        // 将自己加入列表
+        if (!flag) {
+          const ctx = uni.createVideoContext(id
+            // #ifndef MP-BAIDU
+            , this
+            // #endif
+          )
+          ctx.id = id
+          if (this.root.playbackRate) {
+            ctx.playbackRate(this.root.playbackRate)
+          }
+          this.root._videos.push(ctx)
+        }
+      }
+      // #endif
+    },
+
+    /**
+     * @description 图片点击事件
+     * @param {Event} e
+     */
+    imgTap (e) {
+      const node = this.childs[e.currentTarget.dataset.i]
+      if (node.a) {
+        this.linkTap(node.a)
+        return
+      }
+      if (node.attrs.ignore) return
+      // #ifdef H5 || APP-PLUS
+      node.attrs.src = node.attrs.src || node.attrs['data-src']
+      // #endif
+      // #ifndef APP-HARMONY
+      this.root.$emit('imgtap', node.attrs)
+      // #endif
+      // #ifdef APP-HARMONY
+      this.root.$emit('imgtap', {
+        ...node.attrs
+      })
+      // #endif
+      // 自动预览图片
+      if (this.root.previewImg) {
+        uni.previewImage({
+          // #ifdef MP-WEIXIN
+          showmenu: this.root.showImgMenu,
+          // #endif
+          // #ifdef MP-ALIPAY
+          enablesavephoto: this.root.showImgMenu,
+          enableShowPhotoDownload: this.root.showImgMenu,
+          // #endif
+          current: parseInt(node.attrs.i),
+          urls: this.root.imgList
+        })
+      }
+    },
+
+    /**
+     * @description 图片长按
+     */
+    imgLongTap (e) {
+      // #ifdef APP-PLUS
+      const attrs = this.childs[e.currentTarget.dataset.i].attrs
+      if (this.opts[3] && !attrs.ignore) {
+        uni.showActionSheet({
+          itemList: ['保存图片'],
+          success: () => {
+            const save = path => {
+              uni.saveImageToPhotosAlbum({
+                filePath: path,
+                success () {
+                  uni.showToast({
+                    title: '保存成功'
+                  })
+                }
+              })
+            }
+            if (this.root.imgList[attrs.i].startsWith('http')) {
+              uni.downloadFile({
+                url: this.root.imgList[attrs.i],
+                success: res => save(res.tempFilePath)
+              })
+            } else {
+              save(this.root.imgList[attrs.i])
+            }
+          }
+        })
+      }
+      // #endif
+    },
+
+    /**
+     * @description 图片加载完成事件
+     * @param {Event} e
+     */
+    imgLoad (e) {
+      const i = e.currentTarget.dataset.i
+      /* #ifndef H5 || (APP-PLUS && VUE2) */
+      if (!this.childs[i].w) {
+        // 设置原宽度
+        this.$set(this.ctrl, i, e.detail.width)
+      } else /* #endif */ if ((this.opts[1] && !this.ctrl[i]) || this.ctrl[i] === -1) {
+        // 加载完毕,取消加载中占位图
+        this.$set(this.ctrl, i, 1)
+      }
+      this.checkReady()
+    },
+
+    /**
+     * @description 检查是否所有图片加载完毕
+     */
+    checkReady () {
+      if (this.root && !this.root.lazyLoad) {
+        this.root._unloadimgs -= 1
+        if (!this.root._unloadimgs) {
+          setTimeout(() => {
+            this.root.getRect().then(rect => {
+              this.root.$emit('ready', rect)
+            }).catch(() => {
+              this.root.$emit('ready', {})
+            })
+          }, 350)
+        }
+      }
+    },
+
+    /**
+     * @description 链接点击事件
+     * @param {Event} e
+     */
+    linkTap (e) {
+      const node = e.currentTarget ? this.childs[e.currentTarget.dataset.i] : {}
+      const attrs = node.attrs || e
+      const href = attrs.href
+      this.root.$emit('linktap', Object.assign({
+        innerText: this.root.getText(node.children || []) // 链接内的文本内容
+      }, attrs))
+      if (href) {
+        if (href[0] === '#') {
+          // 跳转锚点
+          this.root.navigateTo(href.substring(1)).catch(() => { })
+        } else if (href.split('?')[0].includes('://')) {
+          // 复制外部链接
+          if (this.root.copyLink) {
+            // #ifdef H5
+            window.open(href)
+            // #endif
+            // #ifdef MP
+            uni.setClipboardData({
+              data: href,
+              success: () =>
+                uni.showToast({
+                  title: '链接已复制'
+                })
+            })
+            // #endif
+            // #ifdef APP-PLUS
+            plus.runtime.openWeb(href)
+            // #endif
+          }
+        } else {
+          // 跳转页面
+          uni.navigateTo({
+            url: href,
+            fail () {
+              uni.switchTab({
+                url: href,
+                fail () { }
+              })
+            }
+          })
+        }
+      }
+    },
+
+    /**
+     * @description 错误事件
+     * @param {Event} e
+     */
+    mediaError (e) {
+      const i = e.currentTarget.dataset.i
+      const node = this.childs[i]
+      // 加载其他源
+      if (node.name === 'video' || node.name === 'audio') {
+        let index = (this.ctrl[i] || 0) + 1
+        if (index > node.src.length) {
+          index = 0
+        }
+        if (index < node.src.length) {
+          this.$set(this.ctrl, i, index)
+          return
+        }
+      } else if (node.name === 'img') {
+        // #ifdef H5 && VUE3
+        if (this.opts[0] && !this.ctrl.load) return
+        // #endif
+        // 显示错误占位图
+        if (this.opts[2]) {
+          this.$set(this.ctrl, i, -1)
+        }
+        this.checkReady()
+      }
+      if (this.root) {
+        this.root.$emit('error', {
+          source: node.name,
+          attrs: node.attrs,
+          // #ifndef H5 && VUE3
+          errMsg: e.detail.errMsg
+          // #endif
+        })
+      }
+    },
+    urlWithToken (url) {
+      if (this.root) {
+        const hasParams = url.includes('?')
+        return `${url}${this.root.token ? `${hasParams ? '&': '?'}token=${this.root.token}` : ''}` 
+      } else {
+        return url
+      }
+		}
+  },
+}
+</script>
+<style>
+/* a 标签默认效果 */
+._a {
+  padding: 1.5px 0 1.5px 0;
+  color: #366092;
+  word-break: break-all;
+}
+
+/* a 标签点击态效果 */
+._hover {
+  text-decoration: underline;
+  opacity: 0.7;
+}
+
+/* 图片默认效果 */
+._img {
+  max-width: 100%;
+  -webkit-touch-callout: none;
+}
+
+/* 内部样式 */
+
+._block {
+  display: block;
+}
+
+._b,
+._strong {
+  font-weight: bold;
+}
+
+._code {
+  font-family: monospace;
+}
+
+._del {
+  text-decoration: line-through;
+}
+
+._em,
+._i {
+  font-style: italic;
+}
+
+._h1 {
+  font-size: 2em;
+}
+
+._h2 {
+  font-size: 1.5em;
+}
+
+._h3 {
+  font-size: 1.17em;
+}
+
+._h5 {
+  font-size: 0.83em;
+}
+
+._h6 {
+  font-size: 0.67em;
+}
+
+._h1,
+._h2,
+._h3,
+._h4,
+._h5,
+._h6 {
+  display: block;
+  font-weight: bold;
+}
+
+._image {
+  height: 1px;
+}
+
+._ins {
+  text-decoration: underline;
+}
+
+._li {
+  display: list-item;
+}
+
+._ol {
+  list-style-type: decimal;
+}
+
+._ol,
+._ul {
+  display: block;
+  padding-left: 40px;
+  margin: 1em 0;
+}
+
+._q::before {
+  content: '"';
+}
+
+._q::after {
+  content: '"';
+}
+
+._sub {
+  font-size: smaller;
+  vertical-align: sub;
+}
+
+._sup {
+  font-size: smaller;
+  vertical-align: super;
+}
+
+._thead,
+._tbody,
+._tfoot {
+  display: table-row-group;
+}
+
+._tr {
+  display: table-row;
+}
+
+._td,
+._th {
+  display: table-cell;
+  vertical-align: middle;
+}
+
+._th {
+  font-weight: bold;
+  text-align: center;
+}
+
+._ul {
+  list-style-type: disc;
+}
+
+._ul ._ul {
+  margin: 0;
+  list-style-type: circle;
+}
+
+._ul ._ul ._ul {
+  list-style-type: square;
+}
+
+._abbr,
+._b,
+._code,
+._del,
+._em,
+._i,
+._ins,
+._label,
+._q,
+._span,
+._strong,
+._sub,
+._sup {
+  display: inline;
+}
+
+/* #ifdef APP-PLUS */
+._video {
+  width: 300px;
+  height: 225px;
+}
+/* #endif */
+</style>

+ 1404 - 0
dist/uni-app/components/mp-html/parser.js

@@ -0,0 +1,1404 @@
+/**
+ * @fileoverview html 解析器
+ */
+
+// 配置
+const config = {
+  // 信任的标签(保持标签名不变)
+  trustTags: makeMap('a,abbr,ad,audio,b,blockquote,br,code,col,colgroup,dd,del,dl,dt,div,em,fieldset,h1,h2,h3,h4,h5,h6,hr,i,img,ins,label,legend,li,ol,p,q,ruby,rt,source,span,strong,sub,sup,table,tbody,td,tfoot,th,thead,tr,title,ul,video,channel-video,aliyun-video'),
+
+  // 块级标签(转为 div,其他的非信任标签转为 span)
+  blockTags: makeMap('address,article,aside,body,caption,center,cite,footer,header,html,nav,pre,section'),
+
+  // #ifdef (MP-WEIXIN || MP-QQ || APP-PLUS || MP-360) && VUE3
+  // 行内标签
+  inlineTags: makeMap('abbr,b,big,code,del,em,i,ins,label,q,small,span,strong,sub,sup'),
+  // #endif
+
+  // 要移除的标签
+  ignoreTags: makeMap('area,base,canvas,embed,frame,head,iframe,input,link,map,meta,param,rp,script,source,style,textarea,title,track,wbr'),
+
+  // 自闭合的标签
+  voidTags: makeMap('area,base,br,col,circle,ellipse,embed,frame,hr,img,input,line,link,meta,param,path,polygon,rect,source,track,use,wbr'),
+
+  // html 实体
+  entities: {
+    lt: '<',
+    gt: '>',
+    quot: '"',
+    apos: "'",
+    ensp: '\u2002',
+    emsp: '\u2003',
+    nbsp: '\xA0',
+    semi: ';',
+    ndash: '–',
+    mdash: '—',
+    middot: '·',
+    lsquo: '‘',
+    rsquo: '’',
+    ldquo: '“',
+    rdquo: '”',
+    bull: '•',
+    hellip: '…',
+    larr: '←',
+    uarr: '↑',
+    rarr: '→',
+    darr: '↓'
+  },
+
+  // 默认的标签样式
+  tagStyle: {
+    // #ifndef APP-PLUS-NVUE
+    address: 'font-style:italic',
+    big: 'display:inline;font-size:1.2em',
+    caption: 'display:table-caption;text-align:center',
+    center: 'text-align:center',
+    cite: 'font-style:italic',
+    dd: 'margin-left:40px',
+    mark: 'background-color:yellow',
+    pre: 'font-family:monospace;white-space:pre',
+    s: 'text-decoration:line-through',
+    small: 'display:inline;font-size:0.8em',
+    strike: 'text-decoration:line-through',
+    u: 'text-decoration:underline'
+    // #endif
+  },
+
+  // svg 大小写对照表
+  svgDict: {
+    animatetransform: 'animateTransform',
+    lineargradient: 'linearGradient',
+    viewbox: 'viewBox',
+    attributename: 'attributeName',
+    repeatcount: 'repeatCount',
+    repeatdur: 'repeatDur',
+    foreignobject: 'foreignObject'
+  }
+}
+const tagSelector={}
+let windowWidth, system
+// #ifdef MP-WEIXIN
+if (uni.canIUse('getWindowInfo')) {
+  windowWidth = uni.getWindowInfo().windowWidth
+  system = uni.getDeviceInfo().system
+} else {
+// #endif
+  const systemInfo = uni.getSystemInfoSync()
+  windowWidth = systemInfo.windowWidth
+  // #ifdef MP-WEIXIN
+  system = systemInfo.system
+}
+// #endif
+const blankChar = makeMap(' ,\r,\n,\t,\f')
+let idIndex = 0
+
+// #ifdef H5 || APP-PLUS
+config.ignoreTags.iframe = undefined
+config.trustTags.iframe = true
+config.ignoreTags.embed = undefined
+config.trustTags.embed = true
+// #endif
+// #ifdef APP-PLUS-NVUE
+config.ignoreTags.source = undefined
+config.ignoreTags.style = undefined
+// #endif
+
+/**
+ * @description 创建 map
+ * @param {String} str 逗号分隔
+ */
+function makeMap (str) {
+  const map = Object.create(null)
+  const list = str.split(',')
+  for (let i = list.length; i--;) {
+    map[list[i]] = true
+  }
+  return map
+}
+
+/**
+ * @description 解码 html 实体
+ * @param {String} str 要解码的字符串
+ * @param {Boolean} amp 要不要解码 &amp;
+ * @returns {String} 解码后的字符串
+ */
+function decodeEntity (str, amp) {
+  let i = str.indexOf('&')
+  while (i !== -1) {
+    const j = str.indexOf(';', i + 3)
+    let code
+    if (j === -1) break
+    if (str[i + 1] === '#') {
+      // &#123; 形式的实体
+      code = parseInt((str[i + 2] === 'x' ? '0' : '') + str.substring(i + 2, j))
+      if (!isNaN(code)) {
+        str = str.substr(0, i) + String.fromCharCode(code) + str.substr(j + 1)
+      }
+    } else {
+      // &nbsp; 形式的实体
+      code = str.substring(i + 1, j)
+      if (config.entities[code] || (code === 'amp' && amp)) {
+        str = str.substr(0, i) + (config.entities[code] || '&') + str.substr(j + 1)
+      }
+    }
+    i = str.indexOf('&', i + 1)
+  }
+  return str
+}
+
+/**
+ * @description 合并多个块级标签,加快长内容渲染
+ * @param {Array} nodes 要合并的标签数组
+ */
+function mergeNodes (nodes) {
+  let i = nodes.length - 1
+  for (let j = i; j >= -1; j--) {
+    if (j === -1 || nodes[j].c || !nodes[j].name || (nodes[j].name !== 'div' && nodes[j].name !== 'p' && nodes[j].name[0] !== 'h') || (nodes[j].attrs.style || '').includes('inline')) {
+      if (i - j >= 5) {
+        nodes.splice(j + 1, i - j, {
+          name: 'div',
+          attrs: {},
+          children: nodes.slice(j + 1, i + 1)
+        })
+      }
+      i = j - 1
+    }
+  }
+}
+
+/**
+ * @description html 解析器
+ * @param {Object} vm 组件实例
+ */
+function Parser (vm) {
+  this.options = vm || {}
+  this.tagStyle = Object.assign({}, config.tagStyle, this.options.tagStyle)
+  this.imgList = vm.imgList || []
+  this.imgList._unloadimgs = 0
+  this.plugins = vm.plugins || []
+  this.attrs = Object.create(null)
+  this.stack = []
+  this.nodes = []
+  this.pre = (this.options.containerStyle || '').includes('white-space') && this.options.containerStyle.includes('pre') ? 2 : 0
+}
+
+/**
+ * @description 执行解析
+ * @param {String} content 要解析的文本
+ */
+Parser.prototype.parse = function (content) {
+  // 插件处理
+  for (let i = this.plugins.length; i--;) {
+    if (this.plugins[i].onUpdate) {
+      content = this.plugins[i].onUpdate(content, config) || content
+    }
+  }
+
+  new Lexer(this).parse(content)
+  // 出栈未闭合的标签
+  while (this.stack.length) {
+    this.popNode()
+  }
+  if (this.nodes.length > 50) {
+    mergeNodes(this.nodes)
+  }
+  return this.nodes
+}
+
+/**
+ * @description 将标签暴露出来(不被 rich-text 包含)
+ */
+Parser.prototype.expose = function () {
+  // #ifndef APP-PLUS-NVUE
+  for (let i = this.stack.length; i--;) {
+    const item = this.stack[i]
+    console.log(item)
+    if (item.c || item.name === 'a' || item.name === 'video' || item.name === 'audio') return
+    item.c = 1
+  }
+  // #endif
+}
+
+/**
+ * @description 处理插件
+ * @param {Object} node 要处理的标签
+ * @returns {Boolean} 是否要移除此标签
+ */
+Parser.prototype.hook = function (node) {
+  for (let i = this.plugins.length; i--;) {
+    if (this.plugins[i].onParse && this.plugins[i].onParse(node, this) === false) {
+      return false
+    }
+  }
+  return true
+}
+
+/**
+ * @description 将链接拼接上主域名
+ * @param {String} url 需要拼接的链接
+ * @returns {String} 拼接后的链接
+ */
+Parser.prototype.getUrl = function (url) {
+  const domain = this.options.domain
+  if (url[0] === '/') {
+    if (url[1] === '/') {
+      // // 开头的补充协议名
+      url = (domain ? domain.split('://')[0] : 'http') + ':' + url
+    } else if (domain) {
+      // 否则补充整个域名
+      url = domain + url
+    } /* #ifdef APP-PLUS */ else {
+      url = plus.io.convertLocalFileSystemURL(url)
+    } /* #endif */
+  } else if (!url.includes('data:') && !url.includes('://')) {
+    if (domain) {
+      url = domain + '/' + url
+    } /* #ifdef APP-PLUS */ else {
+      url = plus.io.convertLocalFileSystemURL(url)
+    } /* #endif */
+  }
+  return url
+}
+
+/**
+ * @description 解析样式表
+ * @param {Object} node 标签
+ * @returns {Object}
+ */
+Parser.prototype.parseStyle = function (node) {
+  const attrs = node.attrs
+  const list = (this.tagStyle[node.name] || '').split(';').concat((attrs.style || '').split(';'))
+  const styleObj = {}
+  let tmp = ''
+
+  if (attrs.id && !this.xml) {
+    // 暴露锚点
+    if (this.options.useAnchor) {
+      this.expose()
+    } else if (node.name !== 'img' && node.name !== 'a' && node.name !== 'video' && node.name !== 'audio') {
+      attrs.id = undefined
+    }
+  }
+
+  // 转换 width 和 height 属性
+  if (attrs.width) {
+    styleObj.width = parseFloat(attrs.width) + (attrs.width.includes('%') ? '%' : 'px')
+    attrs.width = undefined
+  }
+  if (attrs.height) {
+    styleObj.height = parseFloat(attrs.height) + (attrs.height.includes('%') ? '%' : 'px')
+    attrs.height = undefined
+  }
+
+  for (let i = 0, len = list.length; i < len; i++) {
+    const info = list[i].split(':')
+    if (info.length < 2) continue
+    const key = info.shift().trim().toLowerCase()
+    let value = info.join(':').trim()
+    if ((value[0] === '-' && value.lastIndexOf('-') > 0) || value.includes('safe')) {
+      // 兼容性的 css 不压缩
+      tmp += `;${key}:${value}`
+    } else if (!styleObj[key] || value.includes('import') || !styleObj[key].includes('import')) {
+      // 重复的样式进行覆盖
+      if (value.includes('url')) {
+        // 填充链接
+        let j = value.indexOf('(') + 1
+        if (j) {
+          while (value[j] === '"' || value[j] === "'" || blankChar[value[j]]) {
+            j++
+          }
+          value = value.substr(0, j) + this.getUrl(value.substr(j))
+        }
+      } else if (value.includes('rpx')) {
+        // 转换 rpx(rich-text 内部不支持 rpx)
+        value = value.replace(/[0-9.]+\s*rpx/g, $ => parseFloat($) * windowWidth / 750 + 'px')
+      }
+      styleObj[key] = value
+    }
+  }
+
+  node.attrs.style = tmp
+  return styleObj
+}
+
+/**
+ * @description 解析到标签名
+ * @param {String} name 标签名
+ * @private
+ */
+Parser.prototype.onTagName = function (name) {
+  this.tagName = this.xml ? name : name.toLowerCase()
+  if (this.tagName === 'svg') {
+    this.xml = (this.xml || 0) + 1 // svg 标签内大小写敏感
+    config.ignoreTags.style = undefined // svg 标签内 style 可用
+  }
+}
+
+/**
+ * @description 解析到属性名
+ * @param {String} name 属性名
+ * @private
+ */
+Parser.prototype.onAttrName = function (name) {
+  name = this.xml ? name : name.toLowerCase()
+  // #ifdef (VUE3 && (H5 || APP-PLUS)) || APP-PLUS-NVUE
+  if (name.includes('?') || name.includes(';')) {
+    this.attrName = undefined
+    return
+  }
+  // #endif
+  if (name.substr(0, 5) === 'data-') {
+    if (name === 'data-src' && !this.attrs.src) {
+      // data-src 自动转为 src
+      this.attrName = 'src'
+    } else if (this.tagName === 'img' || this.tagName === 'a') {
+      // a 和 img 标签保留 data- 的属性,可以在 imgtap 和 linktap 事件中使用
+      this.attrName = name
+    } else if(this.tagName === 'aliyun-video' || this.tagName === 'channel-video') {
+			this.attrName = name
+		} else {
+      // 剩余的移除以减小大小
+      this.attrName = undefined
+    }
+  } else {
+    this.attrName = name
+    this.attrs[name] = 'T' // boolean 型属性缺省设置
+  }
+}
+
+/**
+ * @description 解析到属性值
+ * @param {String} val 属性值
+ * @private
+ */
+Parser.prototype.onAttrVal = function (val) {
+  const name = this.attrName || ''
+  console.log(name, val)
+  if (name === 'style' || name === 'href') {
+    // 部分属性进行实体解码
+    this.attrs[name] = decodeEntity(val, true)
+  } else if (name.includes('src')) {
+    // 拼接主域名
+    this.attrs[name] = this.getUrl(decodeEntity(val, true))
+  } else if (name) {
+    this.attrs[name] = val
+  }
+}
+
+/**
+ * @description 解析到标签开始
+ * @param {Boolean} selfClose 是否有自闭合标识 />
+ * @private
+ */
+Parser.prototype.onOpenTag = function (selfClose) {
+  // 拼装 node
+  const node = Object.create(null)
+  node.name = this.tagName
+  node.attrs = this.attrs
+  // 避免因为自动 diff 使得 type 被设置为 null 导致部分内容不显示
+  if (this.options.nodes.length) {
+    node.type = 'node'
+  }
+  this.attrs = Object.create(null)
+
+  const attrs = node.attrs
+  const parent = this.stack[this.stack.length - 1]
+  const siblings = parent ? parent.children : this.nodes
+  const close = this.xml ? selfClose : config.voidTags[node.name]
+
+  // 替换标签名选择器
+  if (tagSelector[node.name]) {
+    attrs.class = tagSelector[node.name] + (attrs.class ? ' ' + attrs.class : '')
+  }
+
+  // 转换 embed 标签
+  if (node.name === 'embed') {
+    // #ifndef H5 || APP-PLUS
+    const src = attrs.src || ''
+    // 按照后缀名和 type 将 embed 转为 video 或 audio
+    if (src.includes('.mp4') || src.includes('.3gp') || src.includes('.m3u8') || (attrs.type || '').includes('video')) {
+      node.name = 'video'
+    } else if (src.includes('.mp3') || src.includes('.wav') || src.includes('.aac') || src.includes('.m4a') || (attrs.type || '').includes('audio')) {
+      node.name = 'audio'
+    }
+    if (attrs.autostart) {
+      attrs.autoplay = 'T'
+    }
+    attrs.controls = 'T'
+    // #endif
+    // #ifdef H5 || APP-PLUS
+    this.expose()
+    // #endif
+  }
+
+  // #ifndef APP-PLUS-NVUE
+  // 处理音视频
+  if (node.name === 'video' || node.name === 'audio') {
+    // 设置 id 以便获取 context
+    if (node.name === 'video' && !attrs.id) {
+      attrs.id = 'v' + idIndex++
+    }
+    // 没有设置 controls 也没有设置 autoplay 的自动设置 controls
+    if (!attrs.controls && !attrs.autoplay) {
+      attrs.controls = 'T'
+    }
+    // 用数组存储所有可用的 source
+    node.src = []
+    if (attrs.src) {
+      node.src.push(attrs.src)
+      attrs.src = undefined
+    }
+    this.expose()
+  }
+  // #endif
+
+  // 处理自闭合标签
+  if (close) {
+    if (!this.hook(node) || config.ignoreTags[node.name]) {
+      // 通过 base 标签设置主域名
+      if (node.name === 'base' && !this.options.domain) {
+        this.options.domain = attrs.href
+      } /* #ifndef APP-PLUS-NVUE */ else if (node.name === 'source' && parent && (parent.name === 'video' || parent.name === 'audio') && attrs.src) {
+        // 设置 source 标签(仅父节点为 video 或 audio 时有效)
+        parent.src.push(attrs.src)
+      } /* #endif */
+      return
+    }
+
+    // 解析 style
+    const styleObj = this.parseStyle(node)
+
+    // 处理图片
+    if (node.name === 'img') {
+      if (attrs.src) {
+        // 标记 webp
+        if (attrs.src.includes('webp')) {
+          node.webp = 'T'
+        }
+        // data url 图片如果没有设置 original-src 默认为不可预览的小图片
+        if (attrs.src.includes('data:') && this.options.previewImg !== 'all' && !attrs['original-src']) {
+          attrs.ignore = 'T'
+        }
+        if (!attrs.ignore || node.webp || attrs.src.includes('cloud://')) {
+          for (let i = this.stack.length; i--;) {
+            const item = this.stack[i]
+            if (item.name === 'a') {
+              node.a = item.attrs
+            }
+            if (item.name === 'table' && !node.webp && !attrs.src.includes('cloud://')) {
+              if (!styleObj.display || styleObj.display.includes('inline')) {
+                node.t = 'inline-block'
+              } else {
+                node.t = styleObj.display
+              }
+              styleObj.display = undefined
+            }
+            // #ifndef H5 || APP-PLUS
+            const style = item.attrs.style || ''
+            if (style.includes('flex:') && !style.includes('flex:0') && !style.includes('flex: 0') && (!styleObj.width || parseInt(styleObj.width) > 100)) {
+              styleObj.width = '100% !important'
+              styleObj.height = ''
+              for (let j = i + 1; j < this.stack.length; j++) {
+                this.stack[j].attrs.style = (this.stack[j].attrs.style || '').replace('inline-', '')
+              }
+            } else if (style.includes('flex') && styleObj.width === '100%') {
+              for (let j = i + 1; j < this.stack.length; j++) {
+                const style = this.stack[j].attrs.style || ''
+                if (!style.includes(';width') && !style.includes(' width') && style.indexOf('width') !== 0) {
+                  styleObj.width = ''
+                  break
+                }
+              }
+            } else if (style.includes('inline-block')) {
+              if (styleObj.width && styleObj.width[styleObj.width.length - 1] === '%') {
+                item.attrs.style += ';max-width:' + styleObj.width
+                styleObj.width = ''
+              } else {
+                item.attrs.style += ';max-width:100%'
+              }
+            }
+            // #endif
+            item.c = 1
+          }
+          attrs.i = this.imgList.length.toString()
+          let src = attrs['original-src'] || attrs.src
+          // #ifndef H5 || MP-ALIPAY || APP-PLUS || MP-360
+          if (this.imgList.includes(src)) {
+            // 如果有重复的链接则对域名进行随机大小写变换避免预览时错位
+            let i = src.indexOf('://')
+            if (i !== -1) {
+              i += 3
+              let newSrc = src.substr(0, i)
+              for (; i < src.length; i++) {
+                if (src[i] === '/') break
+                newSrc += Math.random() > 0.5 ? src[i].toUpperCase() : src[i]
+              }
+              newSrc += src.substr(i)
+              src = newSrc
+            }
+          }
+          // #endif
+          this.imgList.push(src)
+          if (!node.t) {
+            this.imgList._unloadimgs += 1
+          }
+          // #ifdef H5 || APP-PLUS
+          if (this.options.lazyLoad) {
+            attrs['data-src'] = attrs.src
+            attrs.src = undefined
+          }
+          // #endif
+        }
+      }
+      if (styleObj.display === 'inline') {
+        styleObj.display = ''
+      }
+      // #ifndef APP-PLUS-NVUE
+      if (attrs.ignore) {
+        styleObj['max-width'] = styleObj['max-width'] || '100%'
+        attrs.style += ';-webkit-touch-callout:none'
+      }
+      // #endif
+      // 设置的宽度超出屏幕,为避免变形,高度转为自动
+      if (parseInt(styleObj.width) > windowWidth) {
+        styleObj.height = undefined
+      }
+      // 记录是否设置了宽高
+      if (!isNaN(parseInt(styleObj.width))) {
+        node.w = 'T'
+      }
+      if (!isNaN(parseInt(styleObj.height)) && (!styleObj.height.includes('%') || (parent && (parent.attrs.style || '').includes('height')))) {
+        node.h = 'T'
+      }
+      if (node.w && node.h && styleObj['object-fit']) {
+        if (styleObj['object-fit'] === 'contain') {
+          node.m = 'aspectFit'
+        } else if (styleObj['object-fit'] === 'cover') {
+          node.m = 'aspectFill'
+        }
+      }
+    } else if (node.name === 'svg') {
+      siblings.push(node)
+      this.stack.push(node)
+      this.popNode()
+      return
+    }
+    for (const key in styleObj) {
+      if (styleObj[key]) {
+        attrs.style += `;${key}:${styleObj[key].replace(' !important', '')}`
+      }
+    }
+    attrs.style = attrs.style.substr(1) || undefined
+    // #ifdef (MP-WEIXIN || MP-QQ) && VUE3
+    if (!attrs.style) {
+      delete attrs.style
+    }
+    // #endif
+  } else {
+    if ((node.name === 'pre' || ((attrs.style || '').includes('white-space') && attrs.style.includes('pre'))) && this.pre !== 2) {
+      this.pre = node.pre = 1
+    }
+    node.children = []
+    this.stack.push(node)
+  }
+
+  // 加入节点树
+  siblings.push(node)
+}
+
+/**
+ * @description 解析到标签结束
+ * @param {String} name 标签名
+ * @private
+ */
+Parser.prototype.onCloseTag = function (name) {
+  // 依次出栈到匹配为止
+  name = this.xml ? name : name.toLowerCase()
+  let i
+  for (i = this.stack.length; i--;) {
+    if (this.stack[i].name === name) break
+  }
+  if (i !== -1) {
+    while (this.stack.length > i) {
+      this.popNode()
+    }
+  } else if (name === 'p' || name === 'br') {
+    const siblings = this.stack.length ? this.stack[this.stack.length - 1].children : this.nodes
+    siblings.push({
+      name,
+      attrs: {
+        class: tagSelector[name] || '',
+        style: this.tagStyle[name] || ''
+      }
+    })
+  }
+}
+
+/**
+ * @description 处理标签出栈
+ * @private
+ */
+Parser.prototype.popNode = function () {
+  const node = this.stack.pop()
+  let attrs = node.attrs
+  const children = node.children
+  const parent = this.stack[this.stack.length - 1]
+  const siblings = parent ? parent.children : this.nodes
+
+  if (!this.hook(node) || config.ignoreTags[node.name]) {
+    // 获取标题
+    if (node.name === 'title' && children.length && children[0].type === 'text' && this.options.setTitle) {
+      uni.setNavigationBarTitle({
+        title: children[0].text
+      })
+    }
+    siblings.pop()
+    return
+  }
+
+  if (node.pre && this.pre !== 2) {
+    // 是否合并空白符标识
+    this.pre = node.pre = undefined
+    for (let i = this.stack.length; i--;) {
+      if (this.stack[i].pre) {
+        this.pre = 1
+      }
+    }
+  }
+
+  const styleObj = {}
+
+  // 转换 svg
+  if (node.name === 'svg') {
+    if (this.xml > 1) {
+      // 多层 svg 嵌套
+      this.xml--
+      return
+    }
+    // #ifdef APP-PLUS-NVUE
+    (function traversal (node) {
+      if (node.name) {
+        // 调整 svg 的大小写
+        node.name = config.svgDict[node.name] || node.name
+        for (const item in node.attrs) {
+          if (config.svgDict[item]) {
+            node.attrs[config.svgDict[item]] = node.attrs[item]
+            node.attrs[item] = undefined
+          }
+        }
+        for (let i = 0; i < (node.children || []).length; i++) {
+          traversal(node.children[i])
+        }
+      }
+    })(node)
+    // #endif
+    // #ifndef APP-PLUS-NVUE
+    let src = ''
+    const style = attrs.style
+    attrs.style = ''
+    attrs.xmlns = 'http://www.w3.org/2000/svg';
+    (function traversal (node) {
+      if (node.type === 'text') {
+        src += node.text
+        return
+      }
+      const name = config.svgDict[node.name] || node.name
+      if (name === 'foreignObject') {
+        for (const child of (node.children || [])) {
+          if (child.attrs && !child.attrs.xmlns) {
+            child.attrs.xmlns = 'http://www.w3.org/1999/xhtml'
+            break
+          }
+        }
+      }
+      src += '<' + name
+      for (const item in node.attrs) {
+        const val = node.attrs[item]
+        if (val) {
+          src += ` ${config.svgDict[item] || item}="${val.replace(/"/g, '')}"`
+        }
+      }
+      if (!node.children) {
+        src += '/>'
+      } else {
+        src += '>'
+        for (let i = 0; i < node.children.length; i++) {
+          traversal(node.children[i])
+        }
+        src += '</' + name + '>'
+      }
+    })(node)
+    node.name = 'img'
+    node.attrs = {
+      src: 'data:image/svg+xml;utf8,' + src.replace(/#/g, '%23'),
+      style,
+      ignore: 'T'
+    }
+    node.children = undefined
+    // #endif
+    this.xml = false
+    config.ignoreTags.style = true
+    return
+  }
+
+  // #ifndef APP-PLUS-NVUE
+  // 转换 align 属性
+  if (attrs.align) {
+    if (node.name === 'table') {
+      if (attrs.align === 'center') {
+        styleObj['margin-inline-start'] = styleObj['margin-inline-end'] = 'auto'
+      } else {
+        styleObj.float = attrs.align
+      }
+    } else {
+      styleObj['text-align'] = attrs.align
+    }
+    attrs.align = undefined
+  }
+
+  // 转换 dir 属性
+  if (attrs.dir) {
+    styleObj.direction = attrs.dir
+    attrs.dir = undefined
+  }
+
+  // 转换 font 标签的属性
+  if (node.name === 'font') {
+    if (attrs.color) {
+      styleObj.color = attrs.color
+      attrs.color = undefined
+    }
+    if (attrs.face) {
+      styleObj['font-family'] = attrs.face
+      attrs.face = undefined
+    }
+    if (attrs.size) {
+      let size = parseInt(attrs.size)
+      if (!isNaN(size)) {
+        if (size < 1) {
+          size = 1
+        } else if (size > 7) {
+          size = 7
+        }
+        styleObj['font-size'] = ['x-small', 'small', 'medium', 'large', 'x-large', 'xx-large', 'xxx-large'][size - 1]
+      }
+      attrs.size = undefined
+    }
+  }
+  // #endif
+
+  // 一些编辑器的自带 class
+  if ((attrs.class || '').includes('align-center')) {
+    styleObj['text-align'] = 'center'
+  }
+
+  Object.assign(styleObj, this.parseStyle(node))
+
+  if (node.name !== 'table' && parseInt(styleObj.width) > windowWidth) {
+    styleObj['max-width'] = '100%'
+    styleObj['box-sizing'] = 'border-box'
+  }
+
+  // #ifndef APP-PLUS-NVUE
+  if (config.blockTags[node.name]) {
+    node.name = 'div'
+  } else if (!config.trustTags[node.name] && !this.xml) {
+    // 未知标签转为 span,避免无法显示
+    node.name = 'span'
+  }
+
+  if (node.name === 'a' || node.name === 'ad'
+    // #ifdef H5 || APP-PLUS
+    || node.name === 'iframe' // eslint-disable-line
+    // #endif
+  ) {
+    this.expose()
+  } else if (node.name === 'video') {
+    if ((styleObj.height || '').includes('auto')) {
+      styleObj.height = undefined
+    }
+    /* #ifdef APP-PLUS */
+    let str = '<video style="width:100%;height:100%"'
+    for (const item in attrs) {
+      if (attrs[item]) {
+        str += ' ' + item + '="' + attrs[item] + '"'
+      }
+    }
+    if (this.options.pauseVideo) {
+      str += ' onplay="this.dispatchEvent(new CustomEvent(\'vplay\',{bubbles:!0}));for(var e=document.getElementsByTagName(\'video\'),t=0;t<e.length;t++)e[t]!=this&&e[t].pause()"'
+    }
+    str += '>'
+    for (let i = 0; i < node.src.length; i++) {
+      str += '<source src="' + node.src[i] + '">'
+    }
+    str += '</video>'
+    node.html = str
+    /* #endif */
+  } else if ((node.name === 'ul' || node.name === 'ol') && node.c) {
+    // 列表处理
+    const types = {
+      a: 'lower-alpha',
+      A: 'upper-alpha',
+      i: 'lower-roman',
+      I: 'upper-roman'
+    }
+    if (types[attrs.type]) {
+      attrs.style += ';list-style-type:' + types[attrs.type]
+      attrs.type = undefined
+    }
+    for (let i = children.length; i--;) {
+      if (children[i].name === 'li') {
+        children[i].c = 1
+      }
+    }
+  } else if (node.name === 'table') {
+    // 表格处理
+    // cellpadding、cellspacing、border 这几个常用表格属性需要通过转换实现
+    let padding = parseFloat(attrs.cellpadding)
+    let spacing = parseFloat(attrs.cellspacing)
+    const border = parseFloat(attrs.border)
+    const bordercolor = styleObj['border-color']
+    const borderstyle = styleObj['border-style']
+    if (node.c) {
+      // padding 和 spacing 默认 2
+      if (isNaN(padding)) {
+        padding = 2
+      }
+      if (isNaN(spacing)) {
+        spacing = 2
+      }
+    }
+    if (border) {
+      attrs.style += `;border:${border}px ${borderstyle || 'solid'} ${bordercolor || 'gray'}`
+    }
+    if (node.flag && node.c) {
+      // 有 colspan 或 rowspan 且含有链接的表格通过 grid 布局实现
+      styleObj.display = 'grid'
+      if (styleObj['border-collapse'] === 'collapse') {
+        styleObj['border-collapse'] = undefined
+        spacing = 0
+      }
+      if (spacing) {
+        styleObj['grid-gap'] = spacing + 'px'
+        styleObj.padding = spacing + 'px'
+      } else if (border) {
+        // 无间隔的情况下避免边框重叠
+        attrs.style += ';border-left:0;border-top:0'
+      }
+
+      const width = [] // 表格的列宽
+      const trList = [] // tr 列表
+      const cells = [] // 保存新的单元格
+      const map = {}; // 被合并单元格占用的格子
+
+      (function traversal (nodes) {
+        for (let i = 0; i < nodes.length; i++) {
+          if (nodes[i].name === 'tr') {
+            trList.push(nodes[i])
+          } else if (nodes[i].name === 'colgroup') {
+            let colI = 1
+            for (const col of (nodes[i].children || [])) {
+              if (col.name === 'col') {
+                const style = col.attrs.style || ''
+                const start = style.indexOf('width') ? style.indexOf(';width') : 0
+                // 提取出宽度
+                if (start !== -1) {
+                  let end = style.indexOf(';', start + 6)
+                  if (end === -1) {
+                    end = style.length
+                  }
+                  width[colI] = style.substring(start ? start + 7 : 6, end)
+                }
+                colI += 1
+              }
+            }
+          } else {
+            traversal(nodes[i].children || [])
+          }
+        }
+      })(children)
+
+      for (let row = 1; row <= trList.length; row++) {
+        let col = 1
+        for (let j = 0; j < trList[row - 1].children.length; j++) {
+          const td = trList[row - 1].children[j]
+          if (td.name === 'td' || td.name === 'th') {
+            // 这个格子被上面的单元格占用,则列号++
+            while (map[row + '.' + col]) {
+              col++
+            }
+            let style = td.attrs.style || ''
+            let start = style.indexOf('width') ? style.indexOf(';width') : 0
+            // 提取出 td 的宽度
+            if (start !== -1) {
+              let end = style.indexOf(';', start + 6)
+              if (end === -1) {
+                end = style.length
+              }
+              if (!td.attrs.colspan) {
+                width[col] = style.substring(start ? start + 7 : 6, end)
+              }
+              style = style.substr(0, start) + style.substr(end)
+            }
+            // 设置竖直对齐
+            style += ';display:flex'
+            start = style.indexOf('vertical-align')
+            if (start !== -1) {
+              const val = style.substr(start + 15, 10)
+              if (val.includes('middle')) {
+                style += ';align-items:center'
+              } else if (val.includes('bottom')) {
+                style += ';align-items:flex-end'
+              }
+            } else {
+              style += ';align-items:center'
+            }
+            // 设置水平对齐
+            start = style.indexOf('text-align')
+            if (start !== -1) {
+              const val = style.substr(start + 11, 10)
+              if (val.includes('center')) {
+                style += ';justify-content: center'
+              } else if (val.includes('right')) {
+                style += ';justify-content: right'
+              }
+            }
+            style = (border ? `;border:${border}px ${borderstyle || 'solid'} ${bordercolor || 'gray'}` + (spacing ? '' : ';border-right:0;border-bottom:0') : '') + (padding ? `;padding:${padding}px` : '') + ';' + style
+            // 处理列合并
+            if (td.attrs.colspan) {
+              style += `;grid-column-start:${col};grid-column-end:${col + parseInt(td.attrs.colspan)}`
+              if (!td.attrs.rowspan) {
+                style += `;grid-row-start:${row};grid-row-end:${row + 1}`
+              }
+              col += parseInt(td.attrs.colspan) - 1
+            }
+            // 处理行合并
+            if (td.attrs.rowspan) {
+              style += `;grid-row-start:${row};grid-row-end:${row + parseInt(td.attrs.rowspan)}`
+              if (!td.attrs.colspan) {
+                style += `;grid-column-start:${col};grid-column-end:${col + 1}`
+              }
+              // 记录下方单元格被占用
+              for (let rowspan = 1; rowspan < td.attrs.rowspan; rowspan++) {
+                for (let colspan = 0; colspan < (td.attrs.colspan || 1); colspan++) {
+                  map[(row + rowspan) + '.' + (col - colspan)] = 1
+                }
+              }
+            }
+            if (style) {
+              td.attrs.style = style
+            }
+            cells.push(td)
+            col++
+          }
+        }
+        if (row === 1) {
+          let temp = ''
+          for (let i = 1; i < col; i++) {
+            temp += (width[i] ? width[i] : 'auto') + ' '
+          }
+          styleObj['grid-template-columns'] = temp
+        }
+      }
+      node.children = cells
+    } else {
+      // 没有使用合并单元格的表格通过 table 布局实现
+      if (node.c) {
+        styleObj.display = 'table'
+      }
+      if (!isNaN(spacing)) {
+        styleObj['border-spacing'] = spacing + 'px'
+      }
+      if (border || padding) {
+        // 遍历
+        (function traversal (nodes) {
+          for (let i = 0; i < nodes.length; i++) {
+            const td = nodes[i]
+            if (td.name === 'th' || td.name === 'td') {
+              if (border) {
+                td.attrs.style = `border:${border}px ${borderstyle || 'solid'} ${bordercolor || 'gray'};${td.attrs.style || ''}`
+              }
+              if (padding) {
+                td.attrs.style = `padding:${padding}px;${td.attrs.style || ''}`
+              }
+            } else if (td.children) {
+              traversal(td.children)
+            }
+          }
+        })(children)
+      }
+    }
+    // 给表格添加一个单独的横向滚动层
+    if (this.options.scrollTable && !(attrs.style || '').includes('inline')) {
+      const table = Object.assign({}, node)
+      node.name = 'div'
+      node.attrs = {
+        style: 'overflow:auto'
+      }
+      node.children = [table]
+      attrs = table.attrs
+    }
+  } else if ((node.name === 'tbody' || node.name === 'tr') && node.flag && node.c) {
+    node.flag = undefined;
+    (function traversal (nodes) {
+      for (let i = 0; i < nodes.length; i++) {
+        if (nodes[i].name === 'td') {
+          // 颜色样式设置给单元格避免丢失
+          for (const style of ['color', 'background', 'background-color']) {
+            if (styleObj[style]) {
+              nodes[i].attrs.style = style + ':' + styleObj[style] + ';' + (nodes[i].attrs.style || '')
+            }
+          }
+        } else {
+          traversal(nodes[i].children || [])
+        }
+      }
+    })(children)
+  } else if ((node.name === 'td' || node.name === 'th') && (attrs.colspan || attrs.rowspan)) {
+    for (let i = this.stack.length; i--;) {
+      if (this.stack[i].name === 'table' || this.stack[i].name === 'tbody' || this.stack[i].name === 'tr') {
+        this.stack[i].flag = 1 // 指示含有合并单元格
+      }
+    }
+  } else if (node.name === 'ruby') {
+    // 转换 ruby
+    node.name = 'span'
+    for (let i = 0; i < children.length - 1; i++) {
+      if (children[i].type === 'text' && children[i + 1].name === 'rt') {
+        children[i] = {
+          name: 'div',
+          attrs: {
+            style: 'display:inline-block;text-align:center'
+          },
+          children: [{
+            name: 'div',
+            attrs: {
+              style: 'font-size:50%;' + (children[i + 1].attrs.style || '')
+            },
+            children: children[i + 1].children
+          }, children[i]]
+        }
+        children.splice(i + 1, 1)
+      }
+    }
+  } else if (node.c) {
+    (function traversal (node) {
+      node.c = 2
+      for (let i = node.children.length; i--;) {
+        const child = node.children[i]
+        // #ifdef (MP-WEIXIN || MP-QQ || APP-PLUS || MP-360) && VUE3
+        if (child.name && (config.inlineTags[child.name] || ((child.attrs.style || '').includes('inline') && child.children)) && !child.c) {
+          traversal(child)
+        }
+        // #endif
+        if (!child.c || child.name === 'table') {
+          node.c = 1
+        }
+      }
+    })(node)
+  }
+
+  if ((styleObj.display || '').includes('flex') && !node.c) {
+    for (let i = children.length; i--;) {
+      const item = children[i]
+      if (item.f) {
+        item.attrs.style = (item.attrs.style || '') + item.f
+        item.f = undefined
+      }
+    }
+  }
+  // flex 布局时部分样式需要提取到 rich-text 外层
+  const flex = parent && ((parent.attrs.style || '').includes('flex') || (parent.attrs.style || '').includes('grid'))
+    // #ifdef MP-WEIXIN
+    // 检查基础库版本 virtualHost 是否可用
+    && !(node.c && wx.getNFCAdapter) // eslint-disable-line
+    // #endif
+    // #ifndef MP-WEIXIN || MP-QQ || MP-BAIDU || MP-TOUTIAO
+    && !node.c // eslint-disable-line
+  // #endif
+  if (flex) {
+    node.f = ';max-width:100%'
+  }
+
+  if (children.length >= 50 && node.c && !(styleObj.display || '').includes('flex')) {
+    mergeNodes(children)
+  }
+  // #endif
+
+  for (const key in styleObj) {
+    if (styleObj[key]) {
+      const val = `;${key}:${styleObj[key].replace(' !important', '')}`
+      /* #ifndef APP-PLUS-NVUE */
+      if (flex && ((key.includes('flex') && key !== 'flex-direction') || key === 'align-self' || key.includes('grid') || styleObj[key][0] === '-' || (key.includes('width') && val.includes('%')))) {
+        node.f += val
+        if (key === 'width') {
+          attrs.style += ';width:100%'
+        }
+      } else /* #endif */ {
+        attrs.style += val
+      }
+    }
+  }
+  attrs.style = attrs.style.substr(1) || undefined
+  // #ifdef (MP-WEIXIN || MP-QQ) && VUE3
+  for (const key in attrs) {
+    if (!attrs[key]) {
+      delete attrs[key]
+    }
+  }
+  // #endif
+}
+
+/**
+ * @description 解析到文本
+ * @param {String} text 文本内容
+ */
+Parser.prototype.onText = function (text) {
+  if (!this.pre) {
+    // 合并空白符
+    let trim = ''
+    let flag
+    for (let i = 0, len = text.length; i < len; i++) {
+      if (!blankChar[text[i]]) {
+        trim += text[i]
+      } else {
+        if (trim[trim.length - 1] !== ' ') {
+          trim += ' '
+        }
+        if (text[i] === '\n' && !flag) {
+          flag = true
+        }
+      }
+    }
+    // 去除含有换行符的空串
+    if (trim === ' ') {
+      if (flag) return
+      // #ifdef VUE3
+      else {
+        const parent = this.stack[this.stack.length - 1]
+        if (parent && parent.name[0] === 't') return
+      }
+      // #endif
+    }
+    text = trim
+  }
+  const node = Object.create(null)
+  node.type = 'text'
+  // #ifdef (MP-BAIDU || MP-ALIPAY || MP-TOUTIAO) && VUE3
+  node.attrs = {}
+  // #endif
+  node.text = decodeEntity(text)
+  if (this.hook(node)) {
+    // #ifdef MP-WEIXIN
+    if (this.options.selectable === 'force' && system.includes('iOS') && !uni.canIUse('rich-text.user-select')) {
+      this.expose()
+    }
+    // #endif
+    const siblings = this.stack.length ? this.stack[this.stack.length - 1].children : this.nodes
+    siblings.push(node)
+  }
+}
+
+/**
+ * @description html 词法分析器
+ * @param {Object} handler 高层处理器
+ */
+function Lexer (handler) {
+  this.handler = handler
+}
+
+/**
+ * @description 执行解析
+ * @param {String} content 要解析的文本
+ */
+Lexer.prototype.parse = function (content) {
+  this.content = content || ''
+  this.i = 0 // 标记解析位置
+  this.start = 0 // 标记一个单词的开始位置
+  this.state = this.text // 当前状态
+  for (let len = this.content.length; this.i !== -1 && this.i < len;) {
+    this.state()
+  }
+}
+
+/**
+ * @description 检查标签是否闭合
+ * @param {String} method 如果闭合要进行的操作
+ * @returns {Boolean} 是否闭合
+ * @private
+ */
+Lexer.prototype.checkClose = function (method) {
+  const selfClose = this.content[this.i] === '/'
+  if (this.content[this.i] === '>' || (selfClose && this.content[this.i + 1] === '>')) {
+    if (method) {
+      this.handler[method](this.content.substring(this.start, this.i))
+    }
+    this.i += selfClose ? 2 : 1
+    this.start = this.i
+    this.handler.onOpenTag(selfClose)
+    if (this.handler.tagName === 'script') {
+      this.i = this.content.indexOf('</', this.i)
+      if (this.i !== -1) {
+        this.i += 2
+        this.start = this.i
+      }
+      this.state = this.endTag
+    } else {
+      this.state = this.text
+    }
+    return true
+  }
+  return false
+}
+
+/**
+ * @description 文本状态
+ * @private
+ */
+Lexer.prototype.text = function () {
+  this.i = this.content.indexOf('<', this.i) // 查找最近的标签
+  if (this.i === -1) {
+    // 没有标签了
+    if (this.start < this.content.length) {
+      this.handler.onText(this.content.substring(this.start, this.content.length))
+    }
+    return
+  }
+  const c = this.content[this.i + 1]
+  if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
+    // 标签开头
+    if (this.start !== this.i) {
+      this.handler.onText(this.content.substring(this.start, this.i))
+    }
+    this.start = ++this.i
+    this.state = this.tagName
+  } else if (c === '/' || c === '!' || c === '?') {
+    if (this.start !== this.i) {
+      this.handler.onText(this.content.substring(this.start, this.i))
+    }
+    const next = this.content[this.i + 2]
+    if (c === '/' && ((next >= 'a' && next <= 'z') || (next >= 'A' && next <= 'Z'))) {
+      // 标签结尾
+      this.i += 2
+      this.start = this.i
+      this.state = this.endTag
+      return
+    }
+    // 处理注释
+    let end = '-->'
+    if (c !== '!' || this.content[this.i + 2] !== '-' || this.content[this.i + 3] !== '-') {
+      end = '>'
+    }
+    this.i = this.content.indexOf(end, this.i)
+    if (this.i !== -1) {
+      this.i += end.length
+      this.start = this.i
+    }
+  } else {
+    this.i++
+  }
+}
+
+/**
+ * @description 标签名状态
+ * @private
+ */
+Lexer.prototype.tagName = function () {
+  if (blankChar[this.content[this.i]]) {
+    // 解析到标签名
+    this.handler.onTagName(this.content.substring(this.start, this.i))
+    while (blankChar[this.content[++this.i]]);
+    if (this.i < this.content.length && !this.checkClose()) {
+      this.start = this.i
+      this.state = this.attrName
+    }
+  } else if (!this.checkClose('onTagName')) {
+    this.i++
+  }
+}
+
+/**
+ * @description 属性名状态
+ * @private
+ */
+Lexer.prototype.attrName = function () {
+  let c = this.content[this.i]
+  if (blankChar[c] || c === '=') {
+    // 解析到属性名
+    this.handler.onAttrName(this.content.substring(this.start, this.i))
+    let needVal = c === '='
+    const len = this.content.length
+    while (++this.i < len) {
+      c = this.content[this.i]
+      if (!blankChar[c]) {
+        if (this.checkClose()) return
+        if (needVal) {
+          // 等号后遇到第一个非空字符
+          this.start = this.i
+          this.state = this.attrVal
+          return
+        }
+        if (this.content[this.i] === '=') {
+          needVal = true
+        } else {
+          this.start = this.i
+          this.state = this.attrName
+          return
+        }
+      }
+    }
+  } else if (!this.checkClose('onAttrName')) {
+    this.i++
+  }
+}
+
+/**
+ * @description 属性值状态
+ * @private
+ */
+Lexer.prototype.attrVal = function () {
+  const c = this.content[this.i]
+  const len = this.content.length
+  if (c === '"' || c === "'") {
+    // 有冒号的属性
+    this.start = ++this.i
+    this.i = this.content.indexOf(c, this.i)
+    if (this.i === -1) return
+    this.handler.onAttrVal(this.content.substring(this.start, this.i))
+  } else {
+    // 没有冒号的属性
+    for (; this.i < len; this.i++) {
+      if (blankChar[this.content[this.i]]) {
+        this.handler.onAttrVal(this.content.substring(this.start, this.i))
+        break
+      } else if (this.checkClose('onAttrVal')) return
+    }
+  }
+  while (blankChar[this.content[++this.i]]);
+  if (this.i < len && !this.checkClose()) {
+    this.start = this.i
+    this.state = this.attrName
+  }
+}
+
+/**
+ * @description 结束标签状态
+ * @returns {String} 结束的标签名
+ * @private
+ */
+Lexer.prototype.endTag = function () {
+  const c = this.content[this.i]
+  if (blankChar[c] || c === '>' || c === '/') {
+    this.handler.onCloseTag(this.content.substring(this.start, this.i))
+    if (c !== '>') {
+      this.i = this.content.indexOf('>', this.i)
+      if (this.i === -1) return
+    }
+    this.start = ++this.i
+    this.state = this.text
+  } else {
+    this.i++
+  }
+}
+
+export default Parser

文件差异内容过多而无法显示
+ 0 - 0
dist/uni-app/static/app-plus/mp-html/js/handler.js


文件差异内容过多而无法显示
+ 0 - 0
dist/uni-app/static/app-plus/mp-html/js/uni.webview.min.js


+ 1 - 0
dist/uni-app/static/app-plus/mp-html/local.html

@@ -0,0 +1 @@
+<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no"><style>body,html{width:100%;height:100%;overflow-x:scroll;overflow-y:hidden}body{margin:0}video{width:300px;height:225px}img{max-width:100%;-webkit-touch-callout:none}</style></head><body><div id="content" style="overflow:hidden"></div><script type="text/javascript" src="./js/uni.webview.min.js"></script><script type="text/javascript" src="./js/handler.js"></script></body>

+ 0 - 0
docs/.nojekyll


+ 56 - 0
docs/README.md

@@ -0,0 +1,56 @@
+# mp-html
+
+> 一个强大的小程序富文本组件
+
+![star](https://img.shields.io/github/stars/jin-yufeng/mp-html)
+![forks](https://img.shields.io/github/forks/jin-yufeng/mp-html)
+[![npm](https://img.shields.io/npm/v/mp-html)](https://www.npmjs.com/package/mp-html)
+![downloads](https://img.shields.io/npm/dt/mp-html)
+[![Coverage Status](https://coveralls.io/repos/github/jin-yufeng/mp-html/badge.svg?branch=master)](https://coveralls.io/github/jin-yufeng/mp-html?branch=master)
+![license](https://img.shields.io/github/license/jin-yufeng/mp-html)
+[![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)
+
+## 📢 概况 :id=overview
+显示动态 *html* 富文本是很多应用必要的需求,小程序平台不支持 *dom* 操作使得这成为一个难题,其自带的 *rich-text* 组件支持的标签少且屏蔽所有事件,难以实际应用。因此就有了这样一个能够便捷的在小程序平台上处理富文本的组件,还支持丰富的扩展功能。  
+
+## 📋 特性 :id=feature
+- 支持在多个主流的小程序平台和 *uni-app* 中使用
+- 支持丰富的标签(包括 *table*、*video*、*svg* 等)
+- 支持丰富的事件效果(自动预览图片、链接处理等)
+- 支持设置占位图(加载中、出错时、预览时)
+- 支持锚点跳转、长按复制等丰富功能
+- 支持大部分 *html* 实体
+- 丰富的插件(关键词搜索、内容编辑、*latex* 公式 等)
+- 效率高、容错性强且轻量化(*≈25KB*,*9KB gzipped*)
+
+查看 [功能介绍](overview/feature) 了解更多
+
+## 🎉 使用案例 :id=case
+
+| [官方示例](https://github.com/jin-yufeng/mp-html-demo) | 欢喜商城 | 多么生活 | 食法查 | 微慕 | 科学复习 |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| ![富文本插件](assets/case/富文本插件.jpg) | ![欢喜商城](assets/case/欢喜商城.png) | ![多么生活](assets/case/多么生活.jpg) | ![食法查](assets/case/食法查.png) | ![微慕](assets/case/微慕.jpg) | ![科学复习](assets/case/科学复习.png) |
+
+| [程序员技术之旅](https://github.com/fendoudebb/z-blog-wx) | 典典博客 | 优秀笔记 | 同城共享书 | [技术源 share](https://github.com/wangsrGit119/mini-blog-halo) | 你的代码写的真棒 |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| ![程序员技术之旅](assets/case/程序员技术之旅.jpg) | ![典典博客](assets/case/典典博客.jpg) | ![优秀笔记](assets/case/优秀笔记.jpg) | ![同城共享书](assets/case/同城共享书.jpg) | ![技术源share](assets/case/技术源share.jpg) | ![你的代码写的真棒](assets/case/你的代码写的真棒.jpg) |
+
+| 谛否 | 小莫唐尼 | [模版演示](https://github.com/zhihuifanqiechaodan/miniprogram-template) | AI瓦力 | 豆流便签 | 前端八股通 |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| ![谛否](assets/case/谛否.jpg) | ![小莫唐尼](assets/case/小莫唐尼.png) | ![MiniProgram模版演示](assets/case/MiniProgram模版演示.jpg) | ![AI瓦力](assets/case/AI瓦力.jpg) | ![豆流便签](assets/case/豆流便签.jpg) | ![前端八股通](assets/case/前端八股通.jpg) |
+
+以上排名不分先后,更多可见 [使用案例收集](https://github.com/jin-yufeng/mp-html/issues/27)(欢迎添加)  
+
+## 🎈 联系与支持 :id=sponsor
+![group](assets/group.jpg)
+![支持](assets/sponsor.png)
+
+## 📃 许可 :id=license
+[MIT License](https://github.com/jin-yufeng/mp-html/blob/master/LICENSE)  
+
+?> 您可以免费的使用(包括商用)、复制或修改本组件  
+
+!> 在用于生产环境前,务必进行仔细测试,由本组件 *bug* 带来的损失概不负责  
+
+---
+Powered by docsify

+ 12 - 0
docs/_coverpage.md

@@ -0,0 +1,12 @@
+![logo](assets/logo/logo.png)
+
+# mp-html <small>2.5.1</small>
+
+> 一个强大的小程序富文本组件
+
+- 全面的标签支持
+- 多平台使用支持
+- 丰富的附加功能
+
+[GitHub](https://github.com/jin-yufeng/mp-html)
+[快速开始](/overview/quickstart)

+ 20 - 0
docs/_sidebar.md

@@ -0,0 +1,20 @@
+- 概览
+  - [🚀 快速开始](overview/quickstart)
+  - [🎉 功能介绍](overview/feature)
+
+- 基本使用
+  - [🔨 属性](basic/prop)
+  - [📫 事件](basic/event)
+
+- 进阶使用
+  - [🔎 api](advanced/api)
+  - [📌 插件](advanced/plugin)
+  - [🔧 二次开发](advanced/develop)
+
+- 疑问解答
+  - [📘 常见问题](question/faq)
+  - [📩 反馈](question/feedback)
+  - [📝 贡献指南](question/contribution)
+
+- 更新日志
+  - [📖 更新日志](changelog/changelog)

+ 230 - 0
docs/advanced/api.md

@@ -0,0 +1,230 @@
+# 🔎 api
+组件的实例上挂载了一些实用的 *api* 方法可供调用  
+
+## 获取组件实例 :id=getCompent
+- *uni-app*  
+  ```vue
+  <template>
+    <view>
+      <mp-html ref="article" />
+    </view>
+  </template>
+  <script>
+    export default {
+      onLoad () {
+        var ctx = this.$refs.article
+      }
+    }
+  </script>
+  ```
+
+- 支付宝小程序  
+  需开启 [component2](https://opendocs.alipay.com/mini/framework/component-ref) 模式
+  ```axml
+  <mp-html ref="article">
+  ```
+  ```javascript
+  Page({
+    article (ctx) {
+      // 获得组件实例
+    } 
+  })
+  ```
+
+- 其他小程序平台  
+  ```wxml
+  <mp-html id="article" />
+  ```
+  ```javascript
+  Page({
+    onLoad () {
+      // 微信、QQ、百度
+      var ctx = this.selectComponent('#article')
+      // 头条
+      this.selectComponent('#article', ctx => {
+
+      })
+    }
+  })
+  ```
+
+## in
+功能:将锚点跳转的范围限定在一个 *scroll-view*(需要开启纵向滚动)内  
+输入值:  
+
+| 参数名 | 类型 | 必填 | 默认值 | 说明 |
+|:---:|:---:|:---:|:---:|---|
+| page | object | 是 | - | scroll-view 标签所在页面实例 |
+| selector | string | 是 | - | scroll-view 标签 的选择器 |
+| scrollTop | string | 是 | - | scroll-view 标签 scrollTop 属性绑定的变量名 |
+
+返回值:无  
+示例:  
+```wxml
+<scroll-view id="scroll" style="height:300px" scroll-top="{{top}}" scroll-y scroll-with-animation>
+  <mp-html id="article" content="{{html}}" />
+</scroll-view>
+```
+```javascript
+Page({
+  onLoad () {
+    // ctx 为组件实例
+    ctx.in(this, '#scroll', 'top')
+  }
+})
+```
+
+!> 在 *scroll-view* 中使用时需要注意如果使用了视频,需要保证该平台的 *video* 标签支持同层渲染  
+
+## navigateTo
+功能:锚点跳转  
+前提是 [use-anchor](basic/prop#use-anchor) 属性的值为 *true*  
+必须在 [load](basic/event#load) 事件触发后使用,建议在 [ready](basic/event#ready) 事件触发后使用以保证跳转位置准确  
+  
+输入值:  
+
+| 参数名 | 类型 | 必填 | 默认值 | 说明 |
+|:---:|:---:|:---:|:---:|---|
+| id | string | 否 | - | 要跳转的锚点 id,为空则跳转到开头 |
+| offset | number | 否 | 0 | 跳转位置的偏移量 |
+
+返回值:**Promise**  
+
+该方法中传入的 *offset* 优先级高于 [use-anchor](basic/prop#use-anchor) 属性  
+
+示例:  
+```javascript
+Page({
+  ready () {
+    // ctx 为组件实例
+    ctx.navigateTo('anchor').then(() => {
+      console.log('跳转成功')
+    }).catch(err => {
+      console.log('跳转失败:', err)
+    })
+  }
+})
+```
+
+## getText
+功能:获取文本内容  
+必须在 [load](basic/event#load) 事件触发后使用  
+输入值:无  
+返回值:**String**
+
+## getRect
+功能:获取富文本内容的位置和大小  
+如果开启了 [lazy-load](basic/prop#lazy-load),[ready](basic/event#ready) 事件返回的不是最终大小,可通过此方法获得实时的大小和位置信息  
+输入值:无  
+返回值:**Promise**  
+示例:  
+```javascript
+Page({
+  getRect () {
+    // ctx 为组件实例
+    ctx.getRect().then(rect => {
+      console.log(rect) // boundingClientRect 信息
+    }).catch(err => {
+      console.log('获取失败', err)
+    })
+  }
+})
+```
+
+!> 该方法有小概率可能获取失败,需要做好错误处理  
+
+## setContent
+功能:设置富文本内容  
+此方法的功能与 [content](basic/prop#content) 属性基本一致,但此方法的设置不需要经过视图层且可以从尾部追加  
+输入值:  
+
+| 参数名 | 类型 | 必填 | 默认值 | 说明 |
+|:---:|:---:|:---:|:---:|---|
+| content | string | 是 | - | 要渲染的 html 字符串 |
+| append | boolean | 否 | false | 是否从尾部追加 | 
+
+返回值:无  
+
+!> 调用此方法会触发 [load](basic/event#load) 和 [ready](basic/event#ready) 事件,请勿在事件处理函数中调用,否则可能陷入死循环  
+
+## imgList
+功能:获取所有图片的数组  
+该数组用于图片预览,对其进行修改可以在自动预览时生效(如修改为高清图链接或转存 *base64*)  
+
+!> 这是一个属性,不是一个函数  
+请不要增删此数组(可以修改),否则在自动预览时可能出现问题    
+
+```javascript
+Page({
+  load () {
+    // ctx 为组件实例
+    var cover = ctx.imgList[0] // 首张图可以作为转发封面图
+    ctx.imgList.forEach((src, i, array) => {
+      console.log(src)
+
+      // 替换为高清图链接
+      array[i] = src.replace('thumb', '')
+
+      // 转存 base64 便于预览
+      var fs = wx.getFileSystemManager && wx.getFileSystemManager()
+      var info = src.match(/data:image\/(\S+?);(\S+?),(.+)/)
+      if (!info) return
+      var filePath = `${wx.env.USER_DATA_PATH}/${Date.now()}.${info[1]}`
+      fs && fs.writeFile({
+        filePath,
+        data: info[3],
+        encoding: info[2],
+        success: () => array[i] = filePath
+      })
+    })
+  }
+})
+```
+
+## pauseMedia
+?> [2.2.2](changelog/changelog#v222) 版本起支持  
+
+功能:暂停正在播放的视频或音频  
+
+?> 和 [play](basic/event#play) 事件配合可以实现与页面中其他音视频进行互斥播放  
+
+输入值:无  
+返回值:无  
+示例:  
+```javascript
+Page({
+  onHide () {
+    // ctx 为组件实例
+    ctx.pauseMedia() // 页面跳转或隐藏时暂停播放
+  }
+})
+```
+
+## setPlaybackRate
+?> [2.4.0](changelog/changelog#v240) 版本起支持  
+
+功能:设置音视频的播放速率
+
+输入值:  
+
+| 参数名 | 类型 | 必填 | 默认值 | 说明 |
+|:---:|:---:|:---:|:---:|---|
+| rate | number | 是 | - | 播放速率,一般支持 0.5~2.0 |
+
+返回值:无  
+示例:  
+```javascript
+Page({
+  // 点击设置速率按钮
+  setPlaybackRate () {
+    wx.showActionSheet({
+      itemList: ['0.5', '1.0', '1.25', '1.5', '2.0'],
+      success: res => {
+        const rate = [0.5, 1.0, 1.25, 1.5, 2.0][res.tapIndex]
+        // ctx 为组件实例
+        ctx.setPlaybackRate(rate)
+      }
+    })
+  }
+})
+```

+ 114 - 0
docs/advanced/develop.md

@@ -0,0 +1,114 @@
+# 二次开发 :id=develop
+
+## 📣 说明 :id=notice
+二次开发请在 *src* 目录下进行修改,修改完成后可通过下述方法自动生成各平台的代码包  
+为方便维护,本项目原生包多个平台共用一套源代码,在编写时直接按照微信端的写法进行编写即可,[生成代码包](#pack) 时会自动进行转换  
+自动转换已经抹平了大部分平台之间的差异(文件后缀名、*api* 格式等),需要注意的是 **访问组件的属性** 时,请通过 *this.properties* 访问而不是 *this.data*,因为在支付宝平台中两者不互通  
+
+个别问题可以自行修改 *tools/converter.js* 进行处理  
+
+附项目结构:  
+```
+├─dev(生成的各平台示例项目)
+├─dist(生成的各平台代码包)
+│  ├─mp-alipay
+│  ├─mp-baidu
+│  ├─mp-qq
+│  ├─mp-toutiao
+│  ├─mp-weixin
+│  └─uni-app
+├─docs(文档,由 docsify 生成)
+├─plugins(插件源代码)
+├─src(组件源代码)
+│  ├─miniprogram(原生包源代码)
+│  └─uni-app(uni-app 包源代码)
+├─test(测试代码)
+├─tools(构建工具)
+│   ├─demo(示例项目源代码)
+│   │  ├─miniprogram(原生平台示例项目)
+│   │  └─uni-app(uni-app 平台示例项目)
+|   ├─config.js(构建工具的配置项)
+|   ├─converter.js(将微信端的代码转换到各个平台)
+|   ├─ifdef.js(处理条件编译)
+|   ├─minifier.js(处理 json 和 wxs 的压缩)
+|   └─plugin.js(处理插件构建)
+├─.eslintrc.json(eslint 配置)
+├─.stylelintrc.json(stylelint 配置)
+├─gulpfile.js(gulp 生成文件)
+├─LICENSE(许可证 MIT)
+└─package.json(项目配置)
+```
+
+?> 对于较复杂的修改,如果能通过 [编写插件](advanced/plugin#develop) 方式实现更推荐插件方式,这样在组件包升级的时候便于维护和管理  
+
+## 🎈 条件编译 :id=ifdef
+不同平台之间一些差异的地方可能无法简单的通过替换解决,因此本项目中引入了一种条件编译机制解决平台差异,可在修改时加以利用(条件编译是指在生成包的过程中就仅保留本平台需要的代码,与运行过程中的 *if* 判断不同)  
+
+方式 *1*(适用于 *js*、*wxml*、*wxss* 文件)  
+仅在某平台下需要使用的代码放在两个注释(各种注释格式皆可)之间即可,示例:  
+```javascript
+// #ifdef MP-WEIXIN
+console.log('这是微信平台')
+// #endif
+// #ifndef MP-WEIXIN
+console.log('这不是微信平台')
+// #endif
+```
+
+方式 *2*(适用于 *wxml* 文件)    
+对于仅在某一平台使用的属性,可在属性名前加 *平台名:* ,示例:
+```wxml
+<!-- show-menu-by-longpress 属性将仅被生成到微信包中 -->
+<image mp-weixin:show-menu-by-longpress="xxx" />
+```
+
+说明:  
+1. 可用的平台名称:*mp-weixin*, *mp-qq*, *mp-baidu*, *mp-alipay*, *mp-toutiao*(不区分大小写)  
+2. *#if(n)def* 和 *#endif* 必须成对出现,否则会报错(可以多层嵌套)   
+3. 如果编译过程中发现问题可以自行修改 *tools/ifdef.js* 进行处理  
+
+## 📦 生成组件包 :id=pack
+修改完成后,可按以下步骤生成新的组件包  
+在 *mp-html* 文件夹下执行:  
+
+?> 以下命令需要在组件包根目录下执行,即包含 [package.json](https://github.com/jin-yufeng/mp-html/blob/master/package.json) 的目录(如果通过 *npm* 获取就是 *node_modules/mp-html*)
+
+1. 安装依赖  
+   ```bash
+   # 通过 npm 安装
+   npm install
+   # 或通过 yarn 安装
+   yarn
+   ```
+2. 生成代码包到 *dist* 文件夹  
+   ```bash
+   # 生成微信包到 dist/mp-weixin
+   npm run build:weixin
+   # 生成 qq 包到 dist/mp-qq
+   npm run build:qq
+   # 生成百度包到 dist/mp-baidu
+   npm run build:baidu
+   # 生成支付宝包到 dist/mp-alipay
+   npm run build:alipay
+   # 生成头条包到 dist/mp-toutiao
+   npm run build:toutiao
+   # 生成 uni-app 包到 dist/uni-app
+   npm run build:uni-app
+   # 生成所有包
+   npm run build
+   ```
+
+?> 如需修改打包过程中的配置(*babel*, *uglifyJs* 等),可以对 *tools/config.js* 进行修改  
+
+## 🔦 检查和测试 :id=test
+假设已安装好依赖  
+
+```bash
+npm run lint           # eslint 检查
+npm run lintcss        # stylelint 检查
+npm run lintcss --fix  # 检查并修复
+npm run test           # 执行 jest 测试
+npm run coverage       # 测试代码覆盖率
+```
+
+可以向 *test* 目录下添加新的测试用例进行测试  

+ 488 - 0
docs/advanced/plugin.md

@@ -0,0 +1,488 @@
+# 📌 插件 :id=plugin
+
+> 可以在这里选择需要的插件以实现更加丰富的功能
+
+## 使用插件 :id=use
+
+!> 直接将插件文件夹拷贝到组件包中无法生效,请通过以下方式生成包含扩展的组件包
+
+#### 小程序方式
+!> 该方式暂不可用
+
+?> 该方式适合不熟悉 *npm* 的用户
+
+1. 通过 [小程序方式](overview/quickstart#mp) 获取包含扩展插件的组件包  
+2. 将下载的组件包解压,原生小程序复制到 *components* 目录下,*uni-app* 复制到项目根目录下,按照源码方式引入即可,详见 [引入方式](overview/quickstart#use)
+
+#### npm 方式
+
+1. 获取完整的组件包  
+   通过 [npm](overview/quickstart#npm) 或 [git](overview/quickstart#git) 等方式获取 **包含完整项目** 的组件包(注意从 *uni-app* 的插件市场中导入的包中仅包含构建后的组件,**不包含** 构建工具和插件)  
+   ![step1](../assets/tutorials/01.png)
+2. 选择需要的插件  
+   参考下方插件使用说明,确定要使用的插件,将其名称填入 [tools/config.js](https://github.com/jin-yufeng/mp-html/blob/master/tools/config.js#L8) 中的 *plugins* 中  
+   如果想仅在部分平台使用该插件,可以在该插件目录下的 *build.js* 的 *platform* 字段中填入需要的平台名称  
+   ![step2](../assets/tutorials/02.png)
+3. 生成组件包  
+   设置完成后,可通过项目提供的命令行工具生成新的组件包,具体见 [生成组件包](advanced/develop#pack)  
+   ![step3_1](../assets/tutorials/03.png)  
+   ![step3_2](../assets/tutorials/04.png)
+4. 按照源码或 *npm* 方式引入构建后的组件包进行使用即可,详见 [引入方式](overview/quickstart#use)
+
+## audio
+功能:音乐播放器  
+大小:*≈4KB*  
+支持平台:  
+
+| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| √ | √ | √ | √ | √ | √(nvue 不支持) |
+
+!> 百度小程序原生包在此 [问题](https://smartprogram.baidu.com/forum/topic/show/125787) 未解决前无法使用  
+
+说明:  
+在大多数小程序平台,*audio* 标签已被废弃或无法使用,本插件可以代替 *audio* 标签播放音乐,并实现以下优化:  
+1. [pause-video](basic/prop#pause-video) 属性也可以应用于音频,即播放一个音视频时可以自动暂停其他正在播放的音视频  
+2. 增加了一个可以拖动的进度条  
+3. 组件大小可以根据页面宽度自动调整  
+4. 支持 *autoplay* 属性  
+5. 播放被后台打断时,页面显示后自动继续播放  
+
+基础库要求:  
+支付宝 *1.23.4+* ,其余平台满足 [最低要求](question/faq#lib) 即可  
+第 *5* 条仅微信 *2.2.3+* 、*QQ*、百度支持  
+
+?> 如果希望页面上使用本组件,组件的路径为 *path/to/mp-html/audio/audio*  
+属性和事件基本同 *audio* 组件,组件实例上提供了 *setSrc*、*play*、*seek*、*pause*、*stop* 方法可供控制播放状态  
+
+## editable
+功能:富文本编辑  
+下表列出了本插件与原生 *editor* 组件的功能差异,可按需选用
+
+| 组件 | 优点 | 缺点 |
+|:---:|:---:|:---:|
+| 原生 *editor* | 底层通过 *contenteditable* 实现,编辑流畅 | 支持标签少(不支持音视频、表格以及 *section* 等常用标签)、部分小程序平台不支持或低版本不兼容 |
+| 本插件 | 支持标签全面、支持平台全面 | 编辑灵活性不够强 |
+
+大小:*≈17.5KB*  
+支持平台:  
+
+| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| √ | √ | √ | √ | √ | √(nvue 不支持) |
+
+##### 示例项目 :id=editable_demo
+微信小程序点击 [代码片段](https://developers.weixin.qq.com/s/S2ZpZDm87fQP) 即可在微信开发者工具中导入;*uni-app* 下载 [示例项目](https://mp-html.oss-cn-hangzhou.aliyuncs.com/editable.zip) 在 *HBuilder X* 中打开即可体验;注意示例项目中不一定包含最新版本,仅供参考使用方法  
+
+?> 也可以参考示例小程序 [源代码](https://github.com/jin-yufeng/mp-html-demo)
+
+说明:  
+引入本插件后,会给组件添加以下属性:  
+
+| 属性名 | 类型 | 默认值 | 说明 |
+|:---:|:---:|:---:|:---:|
+| editable | Boolean | false | 是否开启内容编辑 |
+| placeholder | String | 请输入 | 输入框为空时占位符([2.1.0+](changelog/changelog#v210)) |
+
+?> [2.5.0](changelog/changelog#v250) 版本起支持将 *editable* 属性设置为 *"simple"* 来开启简易模式,简易模式下,点击文字内容直接进入编辑,不再弹出操作菜单栏和方框
+
+添加以下事件:  
+
+| 事件名 | 触发时机 | 用途 |
+|:---:|:---:|:---:|
+| remove([2.2.0+](changelog/changelog#v220)) | 删除图片/视频/音频标签时 | 删除已上传的线上文件 |
+
+支持以下操作:  
+
+| 类型 | 操作 |
+|:---:|:---:|
+| 文本 | 修改 |
+| 图片 | 更换链接、调整宽度、设置成超链接([2.0.4+](changelog/changelog#v204))、设置预览图链接、禁用预览、删除 |
+| 链接 | 更换链接、删除 |
+| 音视频 | 设置封面、设置循环播放、设置自动播放([2.2.0+](changelog/changelog#v220))、删除 |
+| 普通标签 | 设置字体大小、颜色([2.4.2+](changelog/changelog#v242))、斜体、粗体、下划线([2.0.4+](changelog/changelog#v204))、居中、缩进、删除 |
+
+?> [2.2.1](changelog/changelog#v221) 版本起所有标签支持上下移动操作,但仅限同级标签间移动,即在有同级标签且非第一个(或最后一个)时可以上移(或下移)
+
+?> 在支付宝小程序中使用时需要在页面样式中添加 *page { position: relative; }* 避免 *tooltip* 错位
+
+?> 菜单项可以通过编辑 *plugins/editable/config.js* 进行修改,仅可以删减或调整顺序,添加或更名无效(颜色设置除外)
+
+[组件实例](advanced/api#getCompent) 上提供了以下方法(*editable* 属性为 *true* 时才可以调用):  
+
+| 名称 | 功能 |
+|:---:|:---:|
+| undo | 撤销一个操作 |
+| redo | 重做一个操作 |
+| insertHtml | 在光标处插入指定 html 内容([2.1.0+](changelog/changelog#v210)) |
+| insertImg | 在光标处插入一张图片 |
+| insertTable(rows, cols) | 在光标处插入一个 rows 行 cols 列的表格([2.1.3+](changelog/changelog#v213)) |
+| insertVideo | 在光标处插入一个视频 |
+| insertAudio | 在光标处插入一个音频 |
+| insertLink | 在光标处插入一个链接 |
+| insertText | 在光标处插入一段文本 |
+| clear | 清空内容 |
+| getContent | 获取编辑后的 html 内容 |
+
+?> 考虑到不同场景下希望获取链接的方法不同,需要在初始时给组件设置一个 *getSrc* 方法(否则插入图片、音视频、链接或修改链接等操作无法使用),每次组件内需要链接时会调用此方法,开发者可在此方法中自行决定如何获取链接,返回 **线上地址** 即可(具体用法见下方示例)  
+[2.2.0](changelog/changelog#v220) 版本起设置了 [domain](basic/prop#domain) 属性时,返回的地址可以缺省主域名  
+
+编辑完成后,通过 *getContent* 方法获取编辑后的 *html*,最后将 *editable* 属性设置为 *false* 即可正常渲染  
+
+!> 点击保存按钮时,部分平台 *tap* 事件早于 *blur* 事件触发,直接获取内容可能导致无法获取当前编辑的文本内容,因此建议设置一个小的延时后获取(可参考下方示例,[详细](https://github.com/jin-yufeng/mp-html/issues/368))  
+
+示例:  
+```javascript
+Page({
+  onLoad () {
+    // ctx 为组件实例,获取方法见上
+    /**
+     * @description 设置获取链接的方法
+     * @param {String} type 链接的类型(img/video/audio/link)
+     * @param {String} value 修改链接时,这里会传入旧值
+     * @returns {Promise} 返回线上地址(2.2.0 版本起设置了 domain 属性时,可以缺省主域名)
+     *   type 为 audio/video 时,可以返回一个源地址数组
+     *   2.1.3 版本起 type 为 audio 时,可以返回一个 object,包含 src、name、author、poster 等字段
+     *   2.2.0 版本起 type 为 img 时,可以返回一个源地址数组,表示插入多张图片(修改链接时仅限一张)
+     */
+    this.ctx.getSrc = (type, value) => {
+      return new Promise((resolve, reject) => {
+        // 以图片为例
+        if (type == 'img') {
+          wx.chooseImage({
+            count: value === undefined ? 9 : 1, // 2.2.0 版本起插入图片时支持多张(修改图片链接时仅限一张)
+            success: res => {
+              wx.showLoading({
+                title: '上传中'
+              });
+              (async ()=>{
+                const arr = []
+                for (let item of res.tempFilePaths) {
+                  // 依次上传
+                  const src = await upload(item)
+                  arr.push(src)
+                }
+                return arr
+              })().then(res => {
+                wx.hideLoading()
+                resolve(res)
+              })
+            },
+            fail: reject
+          })
+        }
+      })
+    }
+  },
+  finishEdit () {
+    setTimeout(() => {
+      var html = ctx.getContent() // 获取编辑好的 html
+      // 上传 html
+      wx.request({
+        url: 'xxx',
+        data: {
+          html
+        },
+        success: () => {
+          this.setData({
+            editable: false // 结束编辑
+          })
+        }
+      })
+    }, 50)
+  }
+})
+```
+
+注意事项:  
+不要在 *editable* 属性被设置为 *true* 前通过 [setContent](advanced/api#setContent) 方法(用 [content](basic/prop#content) 属性)设置内容,否则在切换为 *true* 后会变成空白  
+
+## emoji
+功能:解析 *emoji*  
+大小:*≈3KB*  
+支持平台:  
+
+| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| √ | √ | √ | √ | √ | √ |
+
+说明:  
+将形如 *[笑脸]* 的文本替换为 *emoji* 字符 😄  
+匹配模式可以通过修改 *reg* 变量实现  
+默认配置了 *177* 个常用的 *emoji* 小表情,可以自行按照需要修改 *data* 变量  
+
+?> 与 [editable](#editable) 插件共用时,导出编辑好的 *html* 内容,会将 *emoji* 字符编码为文本形式,便于存储  
+
+## highlight
+功能:代码块高亮显示  
+大小:*≈16KB*  
+支持平台:  
+
+| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| √ | √ | √ | √ | √ | √ |
+
+说明:  
+编辑 *plugins/highlight/config.js* ,可以选择是否需要以下功能:  
+- *copyByLongPress* 是否需要长按代码块时显示复制代码内容菜单(*uni-app nvue* 暂不支持)  
+- *showLanguageName* 是否在代码块右上角显示语言的名称  
+- *showLineNumber* 是否在左侧显示行号  
+
+!> 修改该配置后需要重新 [生成组件包](advanced/develop#pack),在构建后的组件包中修改配置无法生效
+
+引入本插件后,*html* 中符合以下格式的 *pre* 将被高亮处理:  
+```html
+<!-- pre 中内含一个 code,并在 pre 或 code 的 class 中设置 language- -->
+<pre><code class="language-css">p { color: red }</code></pre>
+```
+
+?> 与 [editable](#editable) 插件共用时,编辑状态下,不会进行高亮,可以直接修改代码文本
+
+?> 本插件的高亮功能依赖于 [prismjs](https://prismjs.com/),默认配置中仅支持 *html*、*css*、*c-like*、*javascript* 语言和 *Tomorrow Night* 主题,如果需要更多语言或更换主题请前往 [官网](https://prismjs.com/download.html) 下载对应的 *prism.min.js* 和 *prism.css* 并替换 *plugins/highlight/* 目录下的文件(*prismjs* 的插件大多涉及 *dom* 操作,基本不可用,请勿选择)  
+
+## markdown
+功能:渲染 *markdown*  
+大小:*≈37KB*  
+支持平台:  
+
+| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| √ | √ | √ | √ | √ | √ |
+
+说明:  
+引入本插件后,会给组件添加一个 *markdown* 属性,将该属性设置为 *true* 后,即可通过 [content](basic/prop#content) 属性或 [setContent](advanced/api#setContent) 方法设置 *markdown* 内容即可  
+
+?> 若开启 [use-anchor](basic/prop#use-anchor) 属性,所有标题 `# xxx` 都会被设置为锚点,通过链接 `[xxx](#xxx)` 可以直接跳转  
+
+?> 本插件通过 [marked](https://github.com/markedjs/marked) 解析 *markdown* 文本,部分 *css* 摘选自 [github-markdown-css](https://github.com/sindresorhus/github-markdown-css)  
+
+?> 本插件可以和 [highlight](#highlight) 插件共用,实现 *markdown* 中代码块的高亮效果  
+
+## search
+功能:关键词搜索  
+大小:*≈1.5KB*  
+支持平台:  
+
+| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| √ | √ | √ | √ | √ | √(nvue 不支持) |
+
+说明:  
+引入后会在 [组件实例](advanced/api#getCompent) 上挂载一个 *search* 方法,用于关键词搜索  
+
+输入值  
+
+| 参数名 | 类型 | 默认值 | 说明 |
+|:---:|:---:|:---:|---|
+| key | String 或 RegExp | - | 要搜索的关键词,支持字符串和正则 |
+| anchor | Boolean | false | 是否将搜索结果设置为锚点 |
+| style | String | background-color:yellow | 标记搜索结果的样式 |
+
+返回值:*Promise*    
+
+| 属性 | 类型 | 说明 |
+|:---:|:---:|---|
+| num | Number | 搜索结果数量 |
+| highlight | Function(i, style='background-color:#FF9632') | 高亮第 i(1 ~ num)个结果,将其样式设置为 style |
+| jump | Function(i, offset) | 跳转到第 i(1 ~ num)个结果,偏移量为 offset,anchor 为 true 才可用 |
+
+示例:  
+```javascript
+function search (key) {
+  // ctx 为组件实例
+  ctx.search(key, true).then(res => {
+    res.highlight(1)
+    res.jump(1, -50) // 高亮第 1 个结果并跳转到该位置,偏移量 -50
+  })
+}
+```
+
+?> 具体用法可以参考示例小程序 [源代码](https://github.com/jin-yufeng/mp-html-demo)
+
+附加说明:  
+1. 不传入 *key*(或为空)时即可取消搜索,取消所有的高亮,还原到原来的效果  
+2. 进行新的搜索时旧的搜索结果将被还原,旧的结果中的 *highlight* 等方法不再可用  
+3. 调用 *highlight* 方法高亮一个结果时,之前被高亮的结果会被还原,即始终只有一个结果被高亮  
+4. *key* 传入字符串时大小写敏感,如果要忽略大小写可以用正则的 *i*(字符串搜索效率高于正则)  
+5. 设置 *anchor* 为 *true* 会一定程度上降低效率,非必要不要开启  
+6. 暂不支持跨标签搜索,即只有一个文本节点内包含整个关键词才能被搜索到  
+
+## style
+功能:解析和匹配 *style* 标签中的样式  
+
+?> 这里的 *style* 标签指的是传入 [content](basic/prop#content) 属性中的 *html* 里包含的 *style* 标签,且 *style* 标签要放在其他标签前面才能生效  
+
+大小:*≈3.5KB*  
+支持平台:  
+
+| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| √ | √ | √ | √ | √ | √ (nvue 直接支持) |
+
+说明:  
+支持以下选择器:  
+
+| 名称 | 示例 |
+|:---:|---|
+| 标签名选择器 | p {} |
+| class 选择器 | .class {} |
+| id 选择器 | #id {} |
+| 多选择器交集 | p.class {} |
+| 多选择器并集 | p, .class {} |
+| 后代选择器 | .class1 .class2 {} |
+| 子选择器 | .class1 > .class2 {} |
+| 伪类 | .class::before {} |
+
+伪类仅支持 *before* 和 *after*,支持 *attr* 方法  
+不支持的选择器(属性选择器等)将被忽略  
+
+!> 由于小程序中无法动态写入 *css*,本插件的实现原理是通过解析,将匹配的样式添加到各标签的行内 *style* 中去,请慎用宽泛的选择器,以免大大增加解析结果大小,减慢渲染速度  
+
+## txv-video
+功能:使用腾讯视频  
+大小:*≈1KB*  
+支持平台:  
+
+| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| √ | √ |   |   |   | √ (h5 和 app 直接支持) |
+
+说明:  
+引入本插件后,*html* 中符合下方格式的 *iframe* 标签(*src* 中含有 *vid*)将被转为通过腾讯视频播放:  
+```html
+<iframe src="https://v.qq.com/txp/iframe/player.html?vid=xxxxxx" allowFullScreen="true"></iframe>
+```
+
+同时,其可以被 [pause-video](basic/prop#pause-video) 属性控制  
+
+!> 本插件仅用于将官方 [腾讯视频插件](https://github.com/tvfe/txv-miniprogram-plugin) 应用于本组件,使用前请确认已经成功申请使用该插件并按要求在小程序 *app.json* 中配置完成(*uni-app* 中的配置方法可以参考 [#103](https://github.com/jin-yufeng/mp-html/issues/103#issuecomment-654586246)),否则可能报错 **This application has not registered any plugins yet** 且无法生效  
+
+?> 腾讯视频插件 [v2](https://github.com/tvfe/txv-miniprogram-plugin) 默认自动播放,[v1](https://github.com/tvfe/txv-miniprogram-plugin/blob/master/archieve/readme.md) 不会,可按需选择  
+
+## img-cache
+功能:图片本地缓存  
+大小:*≈4KB*  
+作者:[@PentaTea](https://github.com/PentaTea)  
+支持平台:  
+
+| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+|  |  |  |  |  | √(仅支持 app 的 vue 页面) |
+
+说明:  
+引入本插件后,会给组件添加一个 *img-cache* 属性,将该属性设置为 *true* 后,将自动下载引用的图片并将 *src* 属性更换为本地地址  
+同时在 [组件实例](advanced/api#getCompent) 上挂载了 *imgCache* 对象,扩充缓存控制能力  
+
+*imgCache* 对象属性和方法:  
+
+| 属性 | 功能 |
+|:---:|:---:|
+| list | 当前缓存的 url 列表 |
+| get(url) | 传入 url 获得本地地址 |
+| delete(url) | 传入 url 删除缓存记录 |
+| add(url) | 传入 url 并下载目标为缓存 |
+| clear() | 清空所有缓存 |
+
+!> 请尽量确保 *src* 中含有文件后缀名,不以后缀结尾也没关系,插件会从路径中推测合理的图片后缀,如果完全不包含后缀信息可能会无法保存到相册  
+
+## latex
+功能:渲染 *latex* 公式  
+大小:**≈300KB**  
+作者:[@Zeng-J](https://github.com/Zeng-J)  
+支持平台:  
+
+| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| √ | √ | √ | √ | √ | √ |
+
+说明:  
+引入本插件后,会将 *$xxx$* 的文本内容按照 *latex* 规则进行解析和渲染   
+
+?> [2.5.0](changelog/changelog#v250) 版本起支持 *$$xxx$$* 形式的块级公式
+
+?> 与 [editable](#editable) 插件共用时,编辑状态下,公式不会渲染,可以直接修改公式文本
+
+?> 在 *js* 的字符串中写 *latex* 公式时需注意 *\\* 会变成转义符,要使用 *\\\\* 或 *String.raw``* 的方式
+
+?> 本插件通过 [katex-mini](https://github.com/rojer95/katex-mini) 解析 *latex* 文本,[字体文件](https://github.com/KaTeX/KaTeX/tree/main/fonts) 建议自行转存  
+
+## card
+功能:商品(联络人)信息卡  
+大小:*≈7KB*  
+作者:[@whoooami](https://github.com/whoooami)  
+支持平台:  
+
+| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| √ | √ | √ | √ | √ | √(nvue 不支持) |
+
+效果图:  
+![效果图](../assets/plugin/card.png)
+
+参数列表:  
+
+|参数名|是否必须|类型|说明|
+|:---- |:---|:----- |----- |
+|src|是|String|图片Url|
+|title|是|String|标题|
+|desc|是|String|描述|
+|url|是|String|跳转url|
+|color|是|String|文字颜色|
+|bgcolor|是|String|卡片背景颜色|
+|border|是|String|卡片边框颜色|
+
+说明:  
+1. 可以显示商品信息卡片/联络人信息卡片  
+
+基础库要求:    
+满足最低要求即可  
+
+?> 如果希望页面上使用本组件,组件的路径为 *path/to/mp-html/card/card*  
+
+## 开发插件 :id=develop
+一个插件大致需要以下文件(*plugin/template* 中提供了一个模板) 
+
+- *build.js*  
+  构建文件,需要导出一个 *object*,可以内含以下项:  
+
+  | 名称 | 类型 | 默认值 | 功能 |
+  |:---:|:---:|:---:|---|
+  | main | string | index.js | 入口文件路径 |
+  | platform | string[] | ['mp-weixin', 'mp-qq', 'mp-baidu', 'mp-alipay', 'mp-toutiao', 'uni-app'] | 支持使用的平台 |
+  | template | string | - | 要被添加到模板文件中的标签 (nvue 不可用) |
+  | methods | object | {} | 用于处理模板中事件的方法 (nvue 不可用) |
+  | style | string | - | 用于模板文件的 css 样式 |
+  | import | string&#124;string[] | - | 用于模板文件的 css 文件路径 |
+  | usingComponents | object | {} | 用于模板的组件或插件列表 (nvue 不可用) |
+  | handler | function | - | 自定义文件处理方法 |
+
+
+- *index.js*  
+  入口文件,导出一个 *function*,每个组件在被创建时,会依次实例化各个插件,并传入组件实例可供调用  
+  插件实例上可以挂载以下钩子方法,将在对应时机被调用  
+
+  | 名称 | 触发时机 | 参数 | 返回值 |
+  |:---:|:---:|:---:|:---:|
+  | onUpdate | 更新 html 内容时触发 | 更新的 html 内容和解析配置 | 如果对输入值进行了修改,则返回修改后的内容 |
+  | onParse | 解析到一个标签时触发 | 标签和解析器实例 | 返回 false 将移除该标签 |
+  | onLoad | dom 树加载完成时触发 | - | - |
+  | onDetached | 组件被移除时触发 | - | - |
+
+- *README.md*  
+使用说明  
+
+- *其他依赖文件*  
+插件目录下,除了 *.md* 的文件、*build.js* 和 *import* 字段中的 *css* 文件,其他的都会被拷贝到生成的组件包中,因此请不要放置无关文件  
+
+对于仅在原生平台中使用的内容可放在 *miniprogram* 目录下,仅在 *uni-app* 中使用的内容可放在 *uni-app* 目录下,两个目录下可以分别放置 *build.js*  
+
+如果在插件中需要用到解析器(将 *html* 字符串解析为 *nodes* 数组),可以引入 *src/parser.js* 使用,方法如下:  
+```javascript
+const Parser = require('../parser.js')
+var instance = new Parser(vm) // 实例化解析器,传入组件实例将自动获取相关配置
+var nodes = instance.parse(content) // 解析完成
+```
+
+!> 如果编写了插件,在升级组件包时请注意备份,避免丢失  
+
+?> 欢迎提交实用的插件 [pull requests](https://github.com/jin-yufeng/mp-html/pulls) 到 *plugins* 文件夹  

二进制
docs/assets/case/AI瓦力.jpg


二进制
docs/assets/case/MiniProgram模版演示.jpg


二进制
docs/assets/case/优秀笔记.jpg


二进制
docs/assets/case/你的代码写的真棒.jpg


二进制
docs/assets/case/典典博客.jpg


二进制
docs/assets/case/前端八股通.jpg


二进制
docs/assets/case/同城共享书.jpg


二进制
docs/assets/case/多么生活.jpg


二进制
docs/assets/case/富文本插件.jpg


二进制
docs/assets/case/小莫唐尼.png


二进制
docs/assets/case/微慕.jpg


二进制
docs/assets/case/技术源share.jpg


二进制
docs/assets/case/欢喜商城.png


二进制
docs/assets/case/科学复习.png


二进制
docs/assets/case/程序员技术之旅.jpg


二进制
docs/assets/case/谛否.jpg


二进制
docs/assets/case/豆流便签.jpg


二进制
docs/assets/case/食法查.png


二进制
docs/assets/group.jpg


二进制
docs/assets/logo/logo.ico


二进制
docs/assets/logo/logo.png


二进制
docs/assets/plugin/card.png


二进制
docs/assets/sponsor.png


二进制
docs/assets/tutorials/01.png


二进制
docs/assets/tutorials/02.png


二进制
docs/assets/tutorials/03.png


二进制
docs/assets/tutorials/04.png


部分文件因为文件数量过多而无法显示