zhangmeng
2024-04-19 e3ba120cb766a17e098e58d11c39ffc600a3070c
commit | author | age
e3ba12 1 <template>
Z 2   <view id="_root" :class="(selectable?'_select ':'')+'_root'">
3     <slot v-if="!nodes[0]" />
4     <!-- #ifndef APP-PLUS-NVUE -->
5     <node v-else :childs="nodes" :opts="[lazyLoad,loadingImg,errorImg,showImgMenu]" />
6     <!-- #endif -->
7     <!-- #ifdef APP-PLUS-NVUE -->
8     <web-view ref="web" src="/static/app-plus/mp-html/local.html" :style="'margin-top:-2px;height:' + height + 'px'" @onPostMessage="_onMessage" />
9     <!-- #endif -->
10   </view>
11 </template>
12
13 <script>
14     import props from './props.js';
15 /**
16  * mp-html v2.0.4
17  * @description 富文本组件
18  * @tutorial https://github.com/jin-yufeng/mp-html
19  * @property {String}            bgColor        背景颜色,只适用与APP-PLUS-NVUE
20  * @property {String}            content        用于渲染的富文本字符串(默认 true )
21  * @property {Boolean}            copyLink    是否允许外部链接被点击时自动复制
22  * @property {String}            domain        主域名,用于拼接链接
23  * @property {String}            errorImg    图片出错时的占位图链接
24  * @property {Boolean}            lazyLoad    是否开启图片懒加载(默认 true )
25  * @property {string}            loadingImg    图片加载过程中的占位图链接
26  * @property {Boolean}            pauseVideo    是否在播放一个视频时自动暂停其它视频(默认 true )
27  * @property {Boolean}            previewImg    是否允许图片被点击时自动预览(默认 true )
28  * @property {Boolean}            scrollTable    是否给每个表格添加一个滚动层使其能单独横向滚动
29  * @property {Boolean}            selectable    是否开启长按复制
30  * @property {Boolean}            setTitle    是否将 title 标签的内容设置到页面标题(默认 true )
31  * @property {Boolean}            showImgMenu    是否允许图片被长按时显示菜单(默认 true )
32  * @property {Object}            tagStyle    标签的默认样式
33  * @property {Boolean | Number}    useAnchor    是否使用锚点链接
34  * 
35  * @event {Function}    load    dom 结构加载完毕时触发
36  * @event {Function}    ready    所有图片加载完毕时触发
37  * @event {Function}    imgTap    图片被点击时触发
38  * @event {Function}    linkTap    链接被点击时触发
39  * @event {Function}    error    媒体加载出错时触发
40  */
41 const plugins=[]
42 const parser = require('./parser')
43 // #ifndef APP-PLUS-NVUE
44 import node from './node/node'
45 // #endif
46 // #ifdef APP-PLUS-NVUE
47 const dom = weex.requireModule('dom')
48 // #endif
49 export default {
50   name: 'mp-html',
51   data() {
52     return {
53       nodes: [],
54       // #ifdef APP-PLUS-NVUE
55       height: 0
56       // #endif
57     }
58   },
59   mixins:[props],
60   // #ifndef APP-PLUS-NVUE
61   components: {
62     node
63   },
64   // #endif
65   watch: {
66     content(content) {
67       this.setContent(content)
68     }
69   },
70   created() {
71     this.plugins = []
72     for (let i = plugins.length; i--;)
73       this.plugins.push(new plugins[i](this))
74   },
75   mounted() {
76     if (this.content && !this.nodes.length)
77       this.setContent(this.content)
78   },
79   beforeDestroy() {
80     this._hook('onDetached')
81     clearInterval(this._timer)
82   },
83   methods: {
84     /**
85      * @description 将锚点跳转的范围限定在一个 scroll-view 内
86      * @param {Object} page scroll-view 所在页面的示例
87      * @param {String} selector scroll-view 的选择器
88      * @param {String} scrollTop scroll-view scroll-top 属性绑定的变量名
89      */
90     in(page, selector, scrollTop) {
91       // #ifndef APP-PLUS-NVUE
92       if (page && selector && scrollTop)
93         this._in = {
94           page,
95           selector,
96           scrollTop
97         }
98       // #endif
99     },
100
101     /**
102      * @description 锚点跳转
103      * @param {String} id 要跳转的锚点 id
104      * @param {Number} offset 跳转位置的偏移量
105      * @returns {Promise}
106      */
107     navigateTo(id, offset) {
108       return new Promise((resolve, reject) => {
109         if (!this.useAnchor)
110           return reject('Anchor is disabled')
111         offset = offset || parseInt(this.useAnchor) || 0
112         // #ifdef APP-PLUS-NVUE
113         if (!id) {
114           dom.scrollToElement(this.$refs.web, {
115             offset
116           })
117           resolve()
118         } else {
119           this._navigateTo = {
120             resolve,
121             reject,
122             offset
123           }
124           this.$refs.web.evalJs('uni.postMessage({data:{action:"getOffset",offset:(document.getElementById(' + id + ')||{}).offsetTop}})')
125         }
126         // #endif
127         // #ifndef APP-PLUS-NVUE
128         let deep = ' '
129         // #ifdef MP-WEIXIN || MP-QQ || MP-TOUTIAO
130         deep = '>>>'
131         // #endif
132         const selector = uni.createSelectorQuery()
133           // #ifndef MP-ALIPAY
134           .in(this._in ? this._in.page : this)
135           // #endif
136           .select((this._in ? this._in.selector : '._root') + (id ? `${deep}#${id}` : '')).boundingClientRect()
137         if (this._in)
138           selector.select(this._in.selector).scrollOffset()
139             .select(this._in.selector).boundingClientRect() // 获取 scroll-view 的位置和滚动距离
140         else
141           selector.selectViewport().scrollOffset() // 获取窗口的滚动距离
142         selector.exec(res => {
143           if (!res[0])
144             return reject('Label not found')
145           const scrollTop = res[1].scrollTop + res[0].top - (res[2] ? res[2].top : 0) + offset
146           if (this._in)
147             // scroll-view 跳转
148             this._in.page[this._in.scrollTop] = scrollTop
149           else
150             // 页面跳转
151             uni.pageScrollTo({
152               scrollTop,
153               duration: 300
154             })
155           resolve()
156         })
157         // #endif
158       })
159     },
160
161     /**
162      * @description 获取文本内容
163      * @return {String}
164      */
165     getText() {
166       let text = '';
167       (function traversal(nodes) {
168         for (let i = 0; i < nodes.length; i++) {
169           const node = nodes[i]
170           if (node.type == 'text')
171             text += node.text.replace(/&amp;/g, '&')
172           else if (node.name == 'br')
173             text += '\n'
174           else {
175             // 块级标签前后加换行
176             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')
177             if (isBlock && text && text[text.length - 1] != '\n')
178               text += '\n'
179             // 递归获取子节点的文本
180             if (node.children)
181               traversal(node.children)
182             if (isBlock && text[text.length - 1] != '\n')
183               text += '\n'
184             else if (node.name == 'td' || node.name == 'th')
185               text += '\t'
186           }
187         }
188       })(this.nodes)
189       return text
190     },
191
192     /**
193      * @description 获取内容大小和位置
194      * @return {Promise}
195      */
196     getRect() {
197       return new Promise((resolve, reject) => {
198         uni.createSelectorQuery()
199           // #ifndef MP-ALIPAY
200           .in(this)
201           // #endif
202           .select('#_root').boundingClientRect().exec(res => res[0] ? resolve(res[0]) : reject('Root label not found'))
203       })
204     },
205
206     /**
207      * @description 设置内容
208      * @param {String} content html 内容
209      * @param {Boolean} append 是否在尾部追加
210      */
211     setContent(content, append) {
212       if (!append || !this.imgList)
213         this.imgList = []
214       const nodes = new parser(this).parse(content)
215       // #ifdef APP-PLUS-NVUE
216       if (this._ready)
217         this._set(nodes, append)
218       // #endif
219       this.$set(this, 'nodes', append ? (this.nodes || []).concat(nodes) : nodes)
220
221       // #ifndef APP-PLUS-NVUE
222       this._videos = []
223       this.$nextTick(() => {
224         this._hook('onLoad')
225         this.$emit('load')
226       })
227
228       // 等待图片加载完毕
229       let height
230       clearInterval(this._timer)
231       this._timer = setInterval(() => {
232         this.getRect().then(rect => {
233           // 350ms 总高度无变化就触发 ready 事件
234           if (rect.height == height) {
235             this.$emit('ready', rect)
236             clearInterval(this._timer)
237           }
238           height = rect.height
239         }).catch(() => { })
240       }, 350)
241       // #endif
242     },
243
244     /**
245      * @description 调用插件钩子函数
246      */
247     _hook(name) {
248       for (let i = plugins.length; i--;)
249         if (this.plugins[i][name])
250           this.plugins[i][name]()
251     },
252
253     // #ifdef APP-PLUS-NVUE
254     /**
255      * @description 设置内容
256      */
257     _set(nodes, append) {
258       this.$refs.web.evalJs('setContent(' + JSON.stringify(nodes) + ',' + JSON.stringify([this.bgColor, this.errorImg, this.loadingImg, this.pauseVideo, this.scrollTable, this.selectable]) + ',' + append + ')')
259     },
260
261     /**
262      * @description 接收到 web-view 消息
263      */
264     _onMessage(e) {
265       const message = e.detail.data[0]
266       switch (message.action) {
267         // web-view 初始化完毕
268         case 'onJSBridgeReady':
269           this._ready = true
270           if (this.nodes)
271             this._set(this.nodes)
272           break
273         // 内容 dom 加载完毕
274         case 'onLoad':
275           this.height = message.height
276           this._hook('onLoad')
277           this.$emit('load')
278           break
279         // 所有图片加载完毕
280         case 'onReady':
281           this.getRect().then(res => {
282             this.$emit('ready', res)
283           }).catch(() => { })
284           break
285         // 总高度发生变化
286         case 'onHeightChange':
287           this.height = message.height
288           break
289         // 图片点击
290         case 'onImgTap':
291           this.$emit('imgTap', message.attrs)
292           if (this.previewImg)
293             uni.previewImage({
294               current: parseInt(message.attrs.i),
295               urls: this.imgList
296             })
297           break
298         // 链接点击
299         case 'onLinkTap':
300           const href = message.attrs.href
301           this.$emit('linkTap', message.attrs)
302           if (href) {
303             // 锚点跳转
304             if (href[0] == '#') {
305               if (this.useAnchor)
306                 dom.scrollToElement(this.$refs.web, {
307                   offset: message.offset
308                 })
309             }
310             // 打开外链
311             else if (href.includes('://')) {
312               if (this.copyLink)
313                 plus.runtime.openWeb(href)
314             }
315             else
316               uni.navigateTo({
317                 url: href,
318                 fail() {
319                   wx.switchTab({
320                     url: href
321                   })
322                 }
323               })
324           }
325           break
326         // 获取到锚点的偏移量
327         case 'getOffset':
328           if (typeof message.offset == 'number') {
329             dom.scrollToElement(this.$refs.web, {
330               offset: message.offset + this._navigateTo.offset
331             })
332             this._navigateTo.resolve()
333           } else
334             this._navigateTo.reject('Label not found')
335           break
336         // 点击
337         case 'onClick':
338           this.$emit('tap')
339           break
340         // 出错
341         case 'onError':
342           this.$emit('error', {
343             source: message.source,
344             attrs: message.attrs
345           })
346       }
347     }
348     // #endif
349   }
350 }
351 </script>
352
353 <style>
354 /* #ifndef APP-PLUS-NVUE */
355 /* 根节点样式 */
356 ._root {
357   overflow: auto;
358   -webkit-overflow-scrolling: touch;
359 }
360
361 /* 长按复制 */
362 ._select {
363   user-select: text;
364 }
365 /* #endif */
366 </style>