四零语境前端代码仓库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

385 lines
16 KiB

  1. <template>
  2. <view class="yingbing-computed" :style="{'padding-left': slide + 'px', 'padding-right': slide + 'px'}">
  3. <view :class="'page_' + index" v-for="(item, index) in pages" v-html="getContent(item.content)"></view>
  4. </view>
  5. </template>
  6. <script>
  7. const htmlChars = ['㊦', '㊟', '㊨', '㊧']
  8. export default {
  9. inject: ['getFontSize', 'getFontFamily', 'getLineHeight', 'getTopGap', 'getBottomGap', 'getSlide', 'getHeaderShow', 'getFooterShow', 'getTotalChapter', 'getSplit', 'getPageType'],
  10. props: {
  11. windowHeight: {
  12. type: [Number, String],
  13. default: 0
  14. },
  15. windowWidth: {
  16. type: [Number, String],
  17. default: 0
  18. }
  19. },
  20. computed: {
  21. fontSize () {
  22. return this.getFontSize()
  23. },
  24. fontFamily () {
  25. return this.getFontFamily()
  26. },
  27. lineHeight () {
  28. return this.getLineHeight()
  29. },
  30. topGap () {
  31. return this.getTopGap()
  32. },
  33. bottomGap () {
  34. return this.getBottomGap()
  35. },
  36. slide () {
  37. return this.getSlide()
  38. },
  39. totalChapter () {
  40. return this.getTotalChapter()
  41. },
  42. split () {
  43. return this.getSplit()
  44. },
  45. pageType () {
  46. return this.getPageType()
  47. },
  48. //展示头部
  49. headerShow () {
  50. return this.pageType == 'scroll' ? this.getHeaderShow() : typeof this.chapter.headerShow == 'boolean' ? this.chapter.headerShow : this.getHeaderShow()//判断是否显示头部
  51. },
  52. //展示底部
  53. footerShow () {
  54. return this.pageType == 'scroll' ? this.getFooterShow() : typeof this.chapter.footerShow == 'boolean' ? this.chapter.footerShow : this.getFooterShow()//判断是否显示头部
  55. },
  56. contentWidth () {
  57. return this.windowWidth - (2 * this.slide)
  58. },
  59. contentHeight () {
  60. return this.windowHeight - this.topGap - this.bottomGap - (this.headerShow ? 30 : 0) - (this.footerShow ? 30 : 0)
  61. }
  62. },
  63. data () {
  64. return {
  65. pages: [],//渲染页面数组
  66. chapter: {},//章节内容临时存储
  67. contents: [],//内容转化数组
  68. startTags: [],//临时存储被切割的开始标签
  69. success: null,//成功回调
  70. fail: null,//失败回调
  71. }
  72. },
  73. methods: {
  74. //外层包裹div,设置公共样式(为了兼容微信小程序)
  75. getContent (content) {
  76. 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>'
  77. },
  78. start ({chapter, success, fail}) {
  79. this.chapter = chapter//记录章节内容
  80. this.success = success
  81. this.fail = fail
  82. if ( !this.chapter.content ) {
  83. this.handleSuccess()
  84. return
  85. }
  86. let content = this.chapter.content
  87. const fullStyle = 'box-sizing:border-box;overflow:hidden;height:' + this.contentHeight + 'px'//full-box的样式
  88. content = content.replace(/<full-box.*?>/g, function(item){
  89. if(item.includes('style=')){
  90. return item.replace(/style="(.*?)(?=\")/, 'style="' + fullStyle +';$1');
  91. }else{
  92. return item.replace(/\<full-box/, '<full-box style="'+fullStyle+'" $1');
  93. }
  94. })//给所有full-box添加全部高度
  95. content = content.replace(/<full-box/g, '<whole-render')//将full-box标签替换为whole-render
  96. content = content.replace(/<\/full-box>/g, '</whole-render>')//将full-box标签替换为whole-render
  97. const imgClassName = 'yingbing-img'//img类名
  98. const imgStyle = 'max-height:' + (this.contentHeight - this.lineHeight) + 'px'//img样式
  99. content = content.replace(/<img.*?>/g, function(item){
  100. if(item.includes('class=')){
  101. return item.replace(/class="(.*?)(?=\")/, 'class="$1 '+imgClassName);
  102. }else{
  103. return item.replace('<img', '<img class="'+imgClassName+'"');
  104. }
  105. })//给所有img添加class[yingbing-img] 设置img公共样式(为了兼容微信小程序)
  106. content = content.replace(/<img.*?>/g, function(item){
  107. if(item.includes('style=')){
  108. return item.replace(/style="(.*?)(?=\")/, 'style="$1;'+imgStyle);
  109. }else{
  110. return item.replace('<img', '<img style="'+imgStyle+'"');
  111. }
  112. })//给所有img添加style 设置img公共样式
  113. content = content.replace(/\r|\n/g, '<br/>')//将\n|\r替换为换行符
  114. content = content.replace(/\t/g, '')//将\t删除
  115. this.contents = this.contentToArr(content)//初始化内容,并将文本内容转为数组
  116. this.computedPage()
  117. },
  118. //将文本内容转为数组
  119. contentToArr (content) {
  120. let temp = null
  121. let originContent = content
  122. const wholeRegTags= ['whole-render', 'full-box', 'button', 'video', 'audio', 'iframe']//需要整块渲染的标签
  123. const singleRegTags = ['img', 'hr', 'input', 'video', 'audio', 'iframe']//非闭合标签
  124. const wrapTags = [], wholeTags = [], endTags = [], startTags = []
  125. //将整块渲染的标签替换为㊟符号保存
  126. const wholeTagReg = new RegExp(`<(${wholeRegTags.join('|')})>*([\\s\\S]*?)<\/(${wholeRegTags.join('|')})>|<(${singleRegTags.join('|')})[^>]+>`)
  127. while ( (temp = originContent.match(wholeTagReg)) != null ) {
  128. wholeTags.push(temp[0])
  129. originContent = originContent.substring(0, temp.index) + htmlChars[1] + originContent.substring(temp.index + temp[0].length)
  130. }
  131. //将换行标签替换为㊦符号保存
  132. const wrapTagReg = new RegExp(`<br[^>]+>`)
  133. while ( (temp = originContent.match(wrapTagReg)) != null ) {
  134. wrapTags.push(temp[0])
  135. originContent = originContent.substring(0, temp.index) + htmlChars[0] + originContent.substring(temp.index + temp[0].length)
  136. }
  137. //将结束标签替换为㊨符号保存
  138. const endTagReg = new RegExp(`<\/[^>]+>`)//结束标签
  139. while ( (temp = originContent.match(endTagReg)) != null ) {
  140. endTags.push(temp[0])
  141. originContent = originContent.substring(0, temp.index) + htmlChars[2] + originContent.substring(temp.index + temp[0].length)
  142. }
  143. //将开始标签替换为㊧符号保存
  144. const startTagReg = new RegExp(`<[^>]+>`)//开始标签
  145. while ( (temp = originContent.match(startTagReg)) != null ) {
  146. startTags.push(temp[0])
  147. originContent = originContent.substring(0, temp.index) + htmlChars[3] + originContent.substring(temp.index + temp[0].length)
  148. }
  149. //将字符内容分割为数组
  150. const arr = this.split ? [] : originContent.split('')
  151. if ( arr.length == 0 ) {//如果传入了分隔符
  152. let chars = ''//临时字符串
  153. for ( let i = 0; i < originContent.length; i++ ) {
  154. const char = originContent.charAt(i)
  155. if ( htmlChars.includes(char) ) {//如果是标签
  156. if ( chars ) arr.push(chars)//直接将先前存储的字符push进数组
  157. arr.push(char)//再将标签push进数组
  158. chars = ''//清空临时字符串
  159. } else if ( this.split.indexOf(char) > -1 ) {//如果是分隔符
  160. chars += char//将分隔符加入字符串
  161. arr.push(chars)//将字符串push进数组
  162. chars = ''//清空临时字符串
  163. } else {//其余字符先存着
  164. chars += char
  165. }
  166. }
  167. }
  168. return arr.map((char, key) => {
  169. if ( htmlChars.indexOf(char) == -1 ) return char//非标签内容直接返回
  170. else {//标签内容转化为真实标签再返回
  171. let index = -1
  172. for ( let j = 0; j < key + 1; j++ ) if ( arr[j] == char ) index++
  173. const tags = char == '㊦' ? wrapTags : char == '㊟' ? wholeTags : char == '㊨' ? endTags : startTags
  174. return index > -1 ? char + ':' + tags[index] : char
  175. }
  176. })
  177. },
  178. //计算页面
  179. computedPage () {
  180. const start = this.pages.length > 0 ? this.pages[this.pages.length-1].end : 0//获取字符开始位置
  181. //新增页面
  182. this.pages.push( Object.assign({}, this.chapter, {
  183. content: '',
  184. contents: [],
  185. type: 'text',
  186. start: start,
  187. end: 0,
  188. }) )
  189. //给页面添加内容
  190. this.addText(start)
  191. },
  192. //增加内容
  193. async addText (start) {
  194. let text = '',//渲染内容临时存储
  195. end = 0,//结束位置
  196. lt = this.startTags.length,//开始标签数量
  197. rt = 0//结束标签数量
  198. if ( this.startTags.length > 0 ) text += this.startTags.map(t => t.split('+')[1]).join('')//如果存在被切割的开始标签添加到内容最前面
  199. //截取字符 渲染
  200. for ( let i = start; i < this.contents.length; i++ ) {
  201. end = i//记录结束位置
  202. const char = this.contents[i]
  203. text += char//拼接文本
  204. if ( htmlChars.findIndex(c => char.indexOf(c)) > -1 ) {//如果是标签内容
  205. if ( char.indexOf(htmlChars[0] + ':') > -1 && lt == rt ) break;//如是换行标签并且开始标签和结束标签数量一致时直接开始渲染
  206. if ( char.indexOf(htmlChars[3] + ':') > -1 ) lt++//开始标签+1
  207. if ( char.indexOf(htmlChars[2] + ':') > -1 ) rt++//结束标签+1
  208. if ( lt == rt ) break;//如果开始标签数量等于结束标签则直接渲染
  209. }
  210. }
  211. this.startTags = []
  212. this.pages[this.pages.length-1].end = end//记录结束位置
  213. const handle = () => {
  214. this.getRect().then(data => {//计算当前高度
  215. if ( data.height <= this.contentHeight ) {//如果内容高度小于或等于窗口高度
  216. if ( end < this.contents.length - 1 ) {//如果内容还没有渲染完
  217. const subheight = this.contentHeight - data.height//页面剩余高度
  218. if ( subheight > this.lineHeight ) this.addText(end+1)//当前剩余高度大于行高,则继续增加内容
  219. else {
  220. this.pages[this.pages.length-1].end = end + 1//如果是增加内容时完成本页计算,结束位置要加1
  221. this.computedPage()//否则开始计算下一页
  222. }
  223. } else {
  224. this.pages[this.pages.length-1].end = end + 1//如果是增加内容时完成章节计算,结束位置要加1
  225. this.handleSuccess()//页面计算完毕开始返回
  226. }
  227. } else {
  228. //如果内容高度大于窗口高度开始减少内容
  229. this.subText()
  230. }
  231. })
  232. }
  233. if ( /<img/.test(text)) {
  234. const regex = /<img[^>]*src="([^"]+)"[^>]*>/g;
  235. let match;
  236. while ((match = regex.exec(text)) !== null) {
  237. const res = await this.getImageInfo(match[1])
  238. text = this.replaceImgSrc(text, match[1], res.path)
  239. }
  240. }
  241. this.pages[this.pages.length-1].content += text.replace(new RegExp(`(${htmlChars.join('|')}):`, 'g'), '')// 开始渲染内容
  242. this.$nextTick(function () { handle() })
  243. },
  244. replaceImgSrc(html, oldSrc, newSrc) {
  245. const regex = /(?<=<img[^>]*src=")[^"]*(?=")/g;
  246. return html.replace(regex, match => {
  247. if (match === oldSrc) {
  248. return newSrc;
  249. }
  250. return match;
  251. });
  252. },
  253. getImageInfo (src) {
  254. return new Promise((resolve, reject) => {
  255. uni.getImageInfo({
  256. src: src,
  257. success: res => {
  258. resolve(res)
  259. },
  260. fail: err => {
  261. reject(err)
  262. }
  263. })
  264. })
  265. },
  266. //减少内容
  267. subText () {
  268. const page = this.pages[this.pages.length - 1]//获取最后一页渲染数据
  269. let contents = this.contentToArr(page.content),//将当前页内容转化为数组
  270. end = page.end,//结束位置减1
  271. index = 0,//字符索引
  272. endTags = []//结束字符临时存储
  273. contents.reverse()//反转文本内容集合
  274. const handle = () => {
  275. let lts = [],//存储开始标签
  276. rts = []//存储结束标签
  277. end--//结束位置减1
  278. const char = contents[index]//获取文本内容最后一位
  279. if ( char.indexOf(htmlChars[2] + ':') > -1 ) {//如果最后一位是结束标签
  280. let startIndex = 0//存储结束标签对应开始标签的位置
  281. for ( let i = index; i < contents.length; i++ ) {//循环字符集合,找到结束标签对应的开始标签
  282. const char1 = contents[i]
  283. if ( char1.indexOf(htmlChars[2] + ':') > -1 ) rts.unshift(char1)//如果标签是结束标签加入rts集合
  284. if ( char1.indexOf(htmlChars[3] + ':') > -1 ) lts.push(char1)//如果标签是开始标签加入lts集合
  285. if ( lts.length == rts.length ) break//如果开始标签数量等于结束标签时中段循环
  286. startIndex = i
  287. }
  288. if ( lts.length > 0 ) this.startTags.push(startIndex + '+' + lts[lts.length-1])//将结束最后一位结束标签对应的开始标签加入startTags
  289. endTags.unshift(startIndex + '+' + char)//存储结束标签,用于最后的拼接
  290. }
  291. if ( char.indexOf(htmlChars[3] + ':') > -1 ) {//如果最后一位是开始标签
  292. const startIndex = this.startTags.findIndex(t => t.indexOf(index + '+') > -1)//获取当前开始标签在startTags中的位置
  293. const endIndex = endTags.findIndex(t => t.indexOf(index + '+') > -1)//获取当前开始标签在endTags中对应的结束标签位置
  294. this.startTags.splice(startIndex, 1)//删除记录的开始标签
  295. endTags.splice(endIndex, 1)//删除记录的当前开始标签对应的结束标签
  296. }
  297. let contents1 = JSON.parse(JSON.stringify(contents.slice(index)))//获取文本集合,并从索引位置开始截取
  298. contents1 = contents1.slice(1)//去掉最后一位
  299. contents1.reverse()//反转文本
  300. const text = contents1.join('') + endTags.map(t => t.split('+')[1]).join('')//拼接结束标签
  301. this.pages[this.pages.length - 1].content = text.replace(new RegExp(`(${htmlChars.join('|')}):`, 'g'), '')//渲染文本
  302. this.pages[this.pages.length - 1].end = end + 1//记录结束位置
  303. this.$nextTick(function () {
  304. this.getRect().then(data => {
  305. if ( data.height > this.contentHeight ) {//如果内容高度大于窗口高度
  306. index++//索引+1
  307. handle()//继续减少文本内容
  308. } else {
  309. if ( end < this.contents.length - 1 ) this.computedPage()//如果内容还没有渲染完毕,继续计算下一页
  310. else this.handleSuccess()//页面计算完毕开始返回
  311. }
  312. })
  313. })
  314. }
  315. handle()
  316. },
  317. //成功回调
  318. handleSuccess () {
  319. const slots1 = this.chapter.frontSlots || []//获取章节前置插槽
  320. const slots2 = this.chapter.backSlots || []//获取章节后置插槽
  321. slots1.reverse()//反转前置插槽
  322. //插入前置插槽
  323. slots1.forEach(name => {
  324. const start = this.pages.length > 0 ? this.pages[0].start : 2
  325. this.pages.unshift(Object.assign({}, this.chapter, {
  326. type: 'slot',
  327. content: name,
  328. start: start - 2,
  329. end: start - 1,
  330. }))
  331. })
  332. //插入后置插槽
  333. slots2.forEach(name => {
  334. const end = this.pages.length > 0 ? this.pages[this.pages.length - 1].end : -1
  335. this.pages.push(Object.assign({}, this.chapter, {
  336. type: 'slot',
  337. content: name,
  338. start: end + 1,
  339. end: end + 2,
  340. }))
  341. })
  342. this.pages = this.pages.map((p, key) => {
  343. const total = this.pages.length
  344. const current = key + 1
  345. const rate = 1 / this.totalChapter
  346. const progress = this.totalChapter ? (rate * (current / total)) + ((p.index - 1) * rate) : 0
  347. 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('|')}):`), ''))} : {})
  348. })
  349. this.success && this.success(this.pages)
  350. this.success = null
  351. this.fail = null
  352. this.pages = []
  353. this.startTags = []
  354. this.contents = []
  355. this.chapter = {}
  356. },
  357. //获取内容尺寸
  358. getRect () {
  359. return new Promise(resolve => {
  360. uni.createSelectorQuery().in(this).select('.page_' + (this.pages.length - 1)).boundingClientRect(data => {
  361. resolve(data)
  362. }).exec();
  363. })
  364. }
  365. }
  366. }
  367. </script>
  368. <style scoped>
  369. .yingbing-computed {
  370. position: absolute;
  371. top: -100vh;
  372. left: -100vw;
  373. width: 100%;
  374. height: 100%;
  375. visibility: hidden;
  376. box-sizing: border-box;
  377. display: flex;
  378. flex-direction: column;
  379. }
  380. /deep/ .yingbing-img {
  381. max-width: 100%!important;
  382. box-sizing: border-box!important;
  383. }
  384. </style>