四零语境前端代码仓库
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.

442 lines
17 KiB

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