| <template> | |
| 	<view class="yingbing-computed" :style="{'padding-left': slide + 'px', 'padding-right':slide + 'px'}" :prop="computedProp" :change:prop="ybReaderComputed.propWatcher"></view> | |
| </template> | |
| 
 | |
| <script> | |
| 	export default { | |
| 		inject: ['getFontSize', 'getFontFamily', 'getLineHeight', 'getTopGap', 'getBottomGap', 'getSlide', 'getHeaderShow', 'getFooterShow', 'getTotalChapter', 'getSplit', 'getPageType'], | |
| 		props: { | |
| 			windowHeight: { | |
| 				type: [Number, String], | |
| 				default: 0 | |
| 			}, | |
| 			windowWidth: { | |
| 				type: [Number, String], | |
| 				default: 0 | |
| 			} | |
| 		}, | |
| 		computed: { | |
| 			fontSize () { | |
| 				return this.getFontSize() | |
| 			}, | |
| 			fontFamily () { | |
| 				return this.getFontFamily() | |
| 			}, | |
| 			lineHeight () { | |
| 				return this.getLineHeight() | |
| 			}, | |
| 			topGap () { | |
| 				return this.getTopGap() | |
| 			}, | |
| 			bottomGap () { | |
| 				return this.getBottomGap() | |
| 			}, | |
| 			slide () { | |
| 				return this.getSlide() | |
| 			}, | |
| 			totalChapter () { | |
| 				return this.getTotalChapter() | |
| 			}, | |
| 			split () { | |
| 				return this.getSplit() | |
| 			}, | |
| 			pageType () { | |
| 				return this.getPageType() | |
| 			}, | |
| 			//展示头部 | |
| 			headerShow () { | |
| 				return this.pageType == 'scroll' ? this.getHeaderShow() : typeof this.chapter.headerShow == 'boolean' ? this.chapter.headerShow : this.getHeaderShow()//判断是否显示头部 | |
| 			}, | |
| 			//展示底部 | |
| 			footerShow () { | |
| 				return this.pageType == 'scroll' ? this.getFooterShow() : typeof this.chapter.footerShow == 'boolean' ? this.chapter.footerShow : this.getFooterShow()//判断是否显示头部 | |
| 			}, | |
| 			contentWidth () { | |
| 				return this.windowWidth - (2 * this.slide) | |
| 			}, | |
| 			contentHeight () { | |
| 				return this.windowHeight - this.topGap - this.bottomGap - (this.headerShow ? 30 : 0) - (this.footerShow ? 30 : 0) | |
| 			}, | |
| 			computedProp () { | |
| 				return { | |
| 					chapter: this.chapter, | |
| 					fontSize: this.fontSize, | |
| 					fontFamily: this.fontFamily, | |
| 					lineHeight: this.lineHeight, | |
| 					contentHeight: this.contentHeight, | |
| 					split: this.split, | |
| 					totalChapter: this.totalChapter, | |
| 					isStart: this.isStart | |
| 				} | |
| 			} | |
| 		}, | |
| 		data () { | |
| 			return { | |
| 				isStart: false, | |
| 				chapter: {},//章节内容临时存储 | |
| 				success: null,//成功回调 | |
| 				fail: null//失败回调 | |
| 			} | |
| 		}, | |
| 		methods: { | |
| 			start ({chapter, success, fail}) { | |
| 				this.chapter = chapter | |
| 				this.success = success | |
| 				this.fail = fail | |
| 				this.$nextTick(function () { | |
| 					this.isStart = true | |
| 				}) | |
| 			}, | |
| 			handleRenderSuccess (pages) { | |
| 				this.success && this.success(pages) | |
| 				this.isStart = false | |
| 				this.chapter = {} | |
| 				this.success = null | |
| 				this.fail = null | |
| 			} | |
| 		} | |
| 	} | |
| </script> | |
| <script lang="renderjs" type="module" module="ybReaderComputed"> | |
| 	const htmlChars = ['㊦', '㊟', '㊨', '㊧'] | |
| 	export default { | |
| 		data () { | |
| 			return { | |
| 				fontSize: 0,//字体大小 | |
| 				fontFamily: '',//字体样式 | |
| 				lineHeight: 0,//行高 | |
| 				split: '',//分隔符 | |
| 				totalChapter: 0,//总章节数量 | |
| 				contentHeight: 0,//窗口高度 | |
| 				pages: [],//渲染页面数组 | |
| 				chapter: {},//章节内容临时存储 | |
| 				contents: [],//内容转化数组 | |
| 				startTags: []//临时存储被切割的开始标签 | |
| 				 | |
| 			} | |
| 		}, | |
| 		methods: { | |
| 			//外层包裹div,设置公共样式(为了兼容微信小程序) | |
| 			getContent (content) { | |
| 				return `<div class="yingbing-computed-content-html" style="font-family:${this.fontFamily};font-size:${this.fontSize}px;line-height:${this.lineHeight}px;">` + content.replace('<whole-render', '<div').replace('<\/whole-render>', '<\/div>') + '</div>' | |
| 			}, | |
| 			start (chapter) { | |
| 				this.chapter = chapter//记录章节内容 | |
| 				if ( !this.chapter.content ) { | |
| 					this.handleSuccess() | |
| 					return | |
| 				} | |
| 				let content = this.chapter.content | |
| 				const fullStyle = 'box-sizing:border-box;overflow:hidden;height:' + this.contentHeight + 'px'//full-box的样式 | |
| 				content = content.replace(/<full-box.*?>/g, function(item){ | |
| 				    if(item.includes('style=')){ | |
| 				        return item.replace(/style="(.*?)(?=\")/, 'style="' + fullStyle +';$1'); | |
| 				    }else{ | |
| 				        return item.replace(/\<full-box/, '<full-box style="'+fullStyle+'" $1'); | |
| 				    } | |
| 				})//给所有full-box添加全部高度 | |
| 				content = content.replace(/<full-box/g, '<whole-render')//将full-box标签替换为whole-render | |
| 				content = content.replace(/<\/full-box>/g, '</whole-render>')//将full-box标签替换为whole-render | |
| 				const imgClassName = 'yingbing-img'//img类名 | |
| 				const imgStyle = 'max-height:' + (this.contentHeight - this.lineHeight) + 'px'//img样式 | |
| 				content = content.replace(/<img.*?>/g, function(item){ | |
| 				    if(item.includes('class=')){ | |
| 				        return item.replace(/class="(.*?)(?=\")/, 'class="$1 '+imgClassName); | |
| 				    }else{ | |
| 				        return item.replace('<img', '<img class="'+imgClassName+'"'); | |
| 				    } | |
| 				})//给所有img添加class[yingbing-img] 设置img公共样式(为了兼容微信小程序) | |
| 				content = content.replace(/<img.*?>/g, function(item){ | |
| 					if(item.includes('style=')){ | |
| 					    return item.replace(/style="(.*?)(?=\")/, 'style="$1;'+imgStyle); | |
| 					}else{ | |
| 					    return item.replace('<img', '<img style="'+imgStyle+'"'); | |
| 					} | |
| 				})//给所有img添加style 设置img公共样式 | |
| 				content = content.replace(/\r|\n/g, '<br/>')//将\n|\r替换为换行符 | |
| 				content = content.replace(/\t/g, '')//将\t删除 | |
| 				this.contents = this.contentToArr(content)//初始化内容,并将文本内容转为数组 | |
| 				this.computedPage() | |
| 			}, | |
| 			//将文本内容转为数组 | |
| 			contentToArr (content) { | |
| 				let temp = null | |
| 				let originContent = content | |
| 				const wholeRegTags= ['whole-render', 'full-box', 'button', 'video', 'audio', 'iframe']//需要整块渲染的标签 | |
| 				const singleRegTags = ['img', 'hr', 'input', 'video', 'audio', 'iframe']//非闭合标签 | |
| 				const wrapTags = [], wholeTags = [], endTags = [], startTags = [] | |
| 				//将整块渲染的标签替换为㊟符号保存 | |
| 				const wholeTagReg = new RegExp(`<(${wholeRegTags.join('|')})>*([\\s\\S]*?)<\/(${wholeRegTags.join('|')})>|<(${singleRegTags.join('|')})[^>]+>`) | |
| 				while ( (temp = originContent.match(wholeTagReg)) != null ) { | |
| 					wholeTags.push(temp[0]) | |
| 					originContent = originContent.substring(0, temp.index) + htmlChars[1] + originContent.substring(temp.index + temp[0].length) | |
| 				} | |
| 				//将换行标签替换为㊦符号保存 | |
| 				const wrapTagReg = new RegExp(`<br[^>]+>`) | |
| 				while ( (temp = originContent.match(wrapTagReg)) != null ) { | |
| 					wrapTags.push(temp[0]) | |
| 					originContent = originContent.substring(0, temp.index) + htmlChars[0] + originContent.substring(temp.index + temp[0].length) | |
| 				} | |
| 				//将结束标签替换为㊨符号保存 | |
| 				const endTagReg = new RegExp(`<\/[^>]+>`)//结束标签 | |
| 				while ( (temp = originContent.match(endTagReg)) != null ) { | |
| 					endTags.push(temp[0]) | |
| 					originContent = originContent.substring(0, temp.index) + htmlChars[2] + originContent.substring(temp.index + temp[0].length) | |
| 				} | |
| 				//将开始标签替换为㊧符号保存 | |
| 				const startTagReg = new RegExp(`<[^>]+>`)//开始标签 | |
| 				while ( (temp = originContent.match(startTagReg)) != null ) { | |
| 					startTags.push(temp[0]) | |
| 					originContent = originContent.substring(0, temp.index) + htmlChars[3] + originContent.substring(temp.index + temp[0].length) | |
| 				} | |
| 				//将字符内容分割为数组 | |
| 				const arr = this.split ? [] : originContent.split('') | |
| 				if ( arr.length == 0 ) {//如果传入了分隔符 | |
| 					let chars = ''//临时字符串 | |
| 					for ( let i = 0; i < originContent.length; i++ ) { | |
| 						const char = originContent.charAt(i) | |
| 						if ( htmlChars.includes(char) ) {//如果是标签 | |
| 							if ( chars ) arr.push(chars)//直接将先前存储的字符push进数组 | |
| 							arr.push(char)//再将标签push进数组 | |
| 							chars = ''//清空临时字符串 | |
| 						} else if ( this.split.indexOf(char) > -1 ) {//如果是分隔符 | |
| 							chars += char//将分隔符加入字符串 | |
| 							arr.push(chars)//将字符串push进数组 | |
| 							chars = ''//清空临时字符串 | |
| 						} else {//其余字符先存着 | |
| 							chars += char | |
| 						} | |
| 					} | |
| 				} | |
| 				return arr.map((char, key) => { | |
| 					if ( htmlChars.indexOf(char) == -1 ) return char//非标签内容直接返回 | |
| 					else {//标签内容转化为真实标签再返回 | |
| 						let index = -1 | |
| 						for ( let j = 0; j < key + 1; j++ ) if ( arr[j] == char ) index++ | |
| 						const tags = char == '㊦' ? wrapTags : char == '㊟' ? wholeTags : char == '㊨' ? endTags : startTags | |
| 						return index > -1 ? char + ':' + tags[index] : char | |
| 					} | |
| 				}) | |
| 			}, | |
| 			//计算页面 | |
| 			computedPage () { | |
| 				const start = this.pages.length > 0 ? this.pages[this.pages.length-1].end : 0//获取字符开始位置 | |
| 				//新增页面 | |
| 				this.pages.push( Object.assign({}, this.chapter, { | |
| 					content: '', | |
| 					contents: [], | |
| 					type: 'text', | |
| 					start: start, | |
| 					end: 0, | |
| 				}) ) | |
| 				const div = document.createElement('DIV') | |
| 				div.setAttribute('class', 'yingbing-page-' + this.pages.length) | |
| 				document.querySelector('.yingbing-computed').appendChild(div) | |
| 				//给页面添加内容 | |
| 				this.addText(start) | |
| 			}, | |
| 			//增加内容 | |
| 			async addText (start) { | |
| 				let text = '',//渲染内容临时存储 | |
| 				end = 0,//结束位置 | |
| 				lt = this.startTags.length,//开始标签数量 | |
| 				rt = 0//结束标签数量 | |
| 				if ( this.startTags.length > 0 ) text += this.startTags.map(t => t.split('+')[1]).join('')//如果存在被切割的开始标签添加到内容最前面 | |
| 				//截取字符 渲染 | |
| 				for ( let i = start; i < this.contents.length; i++ ) { | |
| 					end = i//记录结束位置 | |
| 					const char = this.contents[i] | |
| 					text += char//拼接文本 | |
| 					if ( htmlChars.findIndex(c => char.indexOf(c)) > -1 ) {//如果是标签内容 | |
| 						if ( char.indexOf(htmlChars[0] + ':') > -1 && lt == rt ) break;//如是换行标签并且开始标签和结束标签数量一致时直接开始渲染 | |
| 						if ( char.indexOf(htmlChars[3] + ':') > -1 ) lt++//开始标签+1 | |
| 						if ( char.indexOf(htmlChars[2] + ':') > -1 ) rt++//结束标签+1 | |
| 						if ( lt == rt ) break;//如果开始标签数量等于结束标签则直接渲染 | |
| 					} | |
| 				} | |
| 				this.startTags = [] | |
| 				this.pages[this.pages.length-1].content += text.replace(new RegExp(`(${htmlChars.join('|')}):`, 'g'), '')// 开始渲染内容 | |
| 				this.pages[this.pages.length-1].end = end//记录结束位置 | |
| 				const handle = () => { | |
| 					if ( height <= this.contentHeight ) {//如果内容高度小于或等于窗口高度 | |
| 						if ( end < this.contents.length - 1 ) {//如果内容还没有渲染完 | |
| 							const subheight = this.contentHeight - height//页面剩余高度 | |
| 							if ( subheight > this.lineHeight ) this.addText(end+1)//当前剩余高度大于行高,则继续增加内容 | |
| 							else { | |
| 								this.pages[this.pages.length-1].end = end + 1//如果是增加内容时完成本页计算,结束位置要加1 | |
| 								this.computedPage()//否则开始计算下一页 | |
| 							} | |
| 						} else { | |
| 							this.pages[this.pages.length-1].end = end + 1//如果是增加内容时完成章节计算,结束位置要加1 | |
| 							this.handleSuccess()//页面计算完毕开始返回 | |
| 						} | |
| 					} else { | |
| 						//如果内容高度大于窗口高度开始减少内容 | |
| 						this.subText() | |
| 					} | |
| 				} | |
| 				let height = 0 | |
| 				const div = document.querySelector('.yingbing-page-' + this.pages.length) | |
| 				if ( /<img/.test(text)) { | |
| 					const regex = /<img[^>]*src="([^"]+)"[^>]*>/g; | |
| 					let match; | |
| 					while ((match = regex.exec(text)) !== null) { | |
| 						try{ | |
| 							await this.getImageInfo(match[1]) | |
| 						}catch(e){ | |
| 							//TODO handle the exception | |
| 						} | |
| 					} | |
| 				} | |
| 				div.innerHTML = this.getContent(this.pages[this.pages.length-1].content) | |
| 				height = div.offsetHeight | |
| 				handle() | |
| 			}, | |
| 			getImageInfo (src) { | |
| 				return new Promise((resolve, reject) => { | |
| 					const img = new Image() | |
| 					img.src = src | |
| 					img.onload = function () { | |
| 						resolve(img) | |
| 					} | |
| 					img.onerror = function (err) { | |
| 						reject(err) | |
| 					} | |
| 				}) | |
| 			}, | |
| 			//减少内容 | |
| 			subText () { | |
| 				const page = this.pages[this.pages.length - 1]//获取最后一页渲染数据 | |
| 				let contents = this.contentToArr(page.content),//将当前页内容转化为数组 | |
| 				end = page.end,//结束位置减1 | |
| 				index = 0,//字符索引 | |
| 				endTags = []//结束字符临时存储 | |
| 				contents.reverse()//反转文本内容集合 | |
| 				const handle = () => { | |
| 					let lts = [],//存储开始标签 | |
| 					rts = []//存储结束标签 | |
| 					end--//结束位置减1 | |
| 					const char = contents[index]//获取文本内容最后一位 | |
| 					if ( char.indexOf(htmlChars[2] + ':') > -1 ) {//如果最后一位是结束标签 | |
| 						let startIndex = 0//存储结束标签对应开始标签的位置 | |
| 						for ( let i = index; i < contents.length; i++ ) {//循环字符集合,找到结束标签对应的开始标签 | |
| 							const char1 = contents[i] | |
| 							if ( char1.indexOf(htmlChars[2] + ':') > -1 ) rts.unshift(char1)//如果标签是结束标签加入rts集合 | |
| 							if ( char1.indexOf(htmlChars[3] + ':') > -1 ) lts.push(char1)//如果标签是开始标签加入lts集合 | |
| 							if ( lts.length == rts.length ) break//如果开始标签数量等于结束标签时中段循环 | |
| 							startIndex = i | |
| 						} | |
| 						if ( lts.length > 0 ) this.startTags.push(startIndex + '+' + lts[lts.length-1])//将结束最后一位结束标签对应的开始标签加入startTags | |
| 						endTags.unshift(startIndex + '+' + char)//存储结束标签,用于最后的拼接 | |
| 					} | |
| 					if ( char.indexOf(htmlChars[3] + ':') > -1 ) {//如果最后一位是开始标签 | |
| 						const startIndex = this.startTags.findIndex(t => t.indexOf(index + '+') > -1)//获取当前开始标签在startTags中的位置 | |
| 						const endIndex = endTags.findIndex(t => t.indexOf(index + '+') > -1)//获取当前开始标签在endTags中对应的结束标签位置 | |
| 						this.startTags.splice(startIndex, 1)//删除记录的开始标签 | |
| 						endTags.splice(endIndex, 1)//删除记录的当前开始标签对应的结束标签 | |
| 					} | |
| 					let contents1 = JSON.parse(JSON.stringify(contents.slice(index)))//获取文本集合,并从索引位置开始截取 | |
| 					contents1 = contents1.slice(1)//去掉最后一位 | |
| 					contents1.reverse()//反转文本 | |
| 					const text = contents1.join('') + endTags.map(t => t.split('+')[1]).join('')//拼接结束标签 | |
| 					this.pages[this.pages.length - 1].content = text.replace(new RegExp(`(${htmlChars.join('|')}):`, 'g'), '')//渲染文本 | |
| 					this.pages[this.pages.length - 1].end = end + 1//记录结束位置 | |
| 					const div = document.querySelector('.yingbing-page-' + this.pages.length) | |
| 					div.innerHTML = this.getContent(this.pages[this.pages.length-1].content) | |
| 					const height = div.offsetHeight | |
| 					if ( height > this.contentHeight ) {//如果内容高度大于窗口高度 | |
| 						index++//索引+1 | |
| 						handle()//继续减少文本内容 | |
| 					} else { | |
| 						if ( end < this.contents.length - 1 ) this.computedPage()//如果内容还没有渲染完毕,继续计算下一页 | |
| 						else this.handleSuccess()//页面计算完毕开始返回 | |
| 					} | |
| 				} | |
| 				handle() | |
| 			}, | |
| 			//成功回调 | |
| 			handleSuccess () { | |
| 				const slots1 = this.chapter.frontSlots || []//获取章节前置插槽 | |
| 				const slots2 = this.chapter.backSlots || []//获取章节后置插槽 | |
| 				slots1.reverse()//反转前置插槽 | |
| 				//插入前置插槽 | |
| 				slots1.forEach(name => { | |
| 					const start = this.pages.length > 0 ? this.pages[0].start : 2 | |
| 					this.pages.unshift(Object.assign({}, this.chapter, { | |
| 						type: 'slot', | |
| 						content: name, | |
| 						start: start - 2, | |
| 						end: start - 1, | |
| 					})) | |
| 				}) | |
| 				//插入后置插槽 | |
| 				slots2.forEach(name => { | |
| 					const end = this.pages.length > 0 ? this.pages[this.pages.length - 1].end : -1 | |
| 					this.pages.push(Object.assign({}, this.chapter, { | |
| 						type: 'slot', | |
| 						content: name, | |
| 						start: end + 1, | |
| 						end: end + 2, | |
| 					})) | |
| 				}) | |
| 				this.pages = this.pages.map((p, key) => { | |
| 					const total = this.pages.length | |
| 					const current = key + 1 | |
| 					const rate = 1 / this.totalChapter | |
| 					const progress = this.totalChapter ? (rate * (current / total)) + ((p.index - 1) * rate) : 0 | |
| 					return Object.assign({}, p, {total: total, current: current, progress: progress * 100}, p.type == 'text' ? {contents: this.contents.slice(p.start, p.end).map(char => char.replace(new RegExp(`(${htmlChars.join('|')}):`), ''))} : {}) | |
| 				}) | |
| 				this.triggerMethod('handleRenderSuccess', this.pages) | |
| 				document.querySelector('.yingbing-computed').innerHTML = '' | |
| 				this.pages = [] | |
| 				this.startTags = [] | |
| 				this.contents = [] | |
| 				this.chapter = {} | |
| 			}, | |
| 			async propWatcher (newVal, oldVal) { | |
| 				this.fontSize = newVal.fontSize | |
| 				this.fontFamily = newVal.fontFamily | |
| 				this.lineHeight = newVal.lineHeight | |
| 				this.split = newVal.split | |
| 				this.totalChapter = newVal.totalChapter | |
| 				this.contentHeight = newVal.contentHeight | |
| 				if ( !oldVal ) return | |
| 				if ( newVal.isStart != oldVal.isStart ) { | |
| 					if ( newVal.isStart && newVal.chapter ) { | |
| 						this.start(newVal.chapter) | |
| 					} | |
| 				} | |
| 			}, | |
| 			triggerMethod (name, args) { | |
| 				// #ifndef H5 | |
| 				// UniViewJSBridge.publishHandler('onWxsInvokeCallMethod', { | |
| 				//   cid: this._$id, | |
| 				//   method: name, | |
| 				//   args: args | |
| 				// }) | |
| 				this.$ownerInstance.callMethod(name, args) | |
| 				// #endif | |
| 				// #ifdef H5 | |
| 				this[name](args) | |
| 				// #endif | |
| 			} | |
| 		} | |
| 	} | |
| </script> | |
| 
 | |
| <style scoped> | |
| 	.yingbing-computed { | |
| 		position: absolute; | |
| 		top: -100vh; | |
| 		left: -100vw; | |
| 		width: 100%; | |
| 		height: 100%; | |
| 		visibility: hidden; | |
| 		box-sizing: border-box; | |
| 		display: flex; | |
| 		flex-direction: column; | |
| 	} | |
| 	/deep/ .yingbing-img { | |
| 		max-width: 100%!important; | |
| 		box-sizing: border-box!important; | |
| 	} | |
| </style> |