Awesome
Hyacinth
A mobile-friendly reader powered by Vue 2.
TODO
- 分页器 (参照豆瓣阅读实现)
- 响应式
- 阅读模式 (豆瓣)
- 动态加载
- 水平:豆瓣
- 垂直:微博/Twitter
- 源文件格式
- Plain Text
- Html
- Markdown
- 章节目录
- 全文检索
- 字体样式
- HTML5 History Mode
- Progressive Web Apps
- 漢字標準格式
Demo
Build Setup
# install dependencies
npm install
# serve with hot reload at localhost:8080
npm run dev
# build for production with minification
npm run build
# run unit tests
npm run unit
# run e2e tests
npm run e2e
# run all tests
npm test
Core code
整体代码非常垃圾,远不如参照,官方的 Shopping Cart 之类的例子,遂继续重构。 在此把可能有用的代码记录下来
目标:
- 减少
- DOM 操作
- 外部库引入
- 代码量
- 封装
- 低耦合
- 可复用
实现流程
- 单页阅读器
- 解析 Markdown
- 实现分页
- 水平切页
- 动态插入
- 替换 Vue transition 方法,减少自定义 Dom 操作
- 垂直模式
- 去除 Header/Footer 连接 Body 部分
- 动态插入
初始化布局参数
- FontSize(px): 16
- LineHeight(em): 1.5
- Full(em):
- Height:
document.documentElement.clientHeight
- Width:
document.documentElement.clientWidth
- Height:
- Layout(em):
- Height:
$Height / $FontSize
- Width:
$Width / $FontSize
- Height:
计算区块
插入 Dom
浏览器自行适配,拆分段落,或者采用 JS 实现解析器(如:Flipboard/react-canvas)
this.page.map(v => v.html).join('\n');
枚举节点&拆分页面
- Page:
- Title
- Paragraphs
- Offset
- Html
const lineHeight = this.fontSize * this.lineHeight;
const contentHeight = this.contentHeight / this.lineHeight;
let pagination = 0;
let pageHeight = 0;
const $this = this;
function isPlaceholder(oElTreeel) {
if (typeof oElTreeel === 'string') return 0;
// let numOfImg = 0;
const elTree = JSON.parse(JSON.stringify(oElTreeel));
const tag = elTree.shift();
if (tag === 'img') return 2;// numOfImg += 1;
else if (tag === 'h1') {
$this.toc.push({
pagination,
title: elTree.shift(),
});
return 1;
}
if (elTree.length && typeof elTree[0] === 'object' && !(elTree[0] instanceof Array)) elTree.shift();
while (elTree.length) {
// numOfImg += isPlaceholder(elTree.shift());
const rt = isPlaceholder(elTree.shift());
if (rt) return rt;
}
// return numOfImg;
return false;
}
function nextPage(height) {
pageHeight = height;
pagination += 1;
if (!$this.pages[pagination]) $this.pages[pagination] = [];
}
[].forEach.call($this.$el.children, (v, i) => {
const paragraph = {
height: Math.ceil(v.offsetHeight / lineHeight),
offset: (lineHeight - (v.offsetHeight % lineHeight)) % lineHeight,
html: $this.book.tree[i].html,
tree: $this.book.tree[i].el,
};
// Pagination
switch (isPlaceholder(paragraph.tree)) {
case 1: {
nextPage(paragraph.height);
$this.toc.push({
title: paragraph.tree[1],
pagination,
});
$this.pages[pagination].push(paragraph);
return;
}
case 2: {
nextPage(0);
$this.pages[pagination].push(paragraph);
nextPage(0);
return;
}
default:
break;
}
pageHeight += paragraph.height;
$this.pages[pagination].push(paragraph);
while (pageHeight >= contentHeight) {
nextPage(pageHeight - contentHeight);
const oParagraph = JSON.parse(JSON.stringify(paragraph));
oParagraph.offset = -(paragraph.height - pageHeight);
$this.pages[pagination].push(oParagraph);
}
});
this.$store.dispatch('UPDATE_BOOK_TOC', { toc: this.toc.reverse() });
this.$store.dispatch('UPDATE_BOOK_PAGES', { pages: this.pages });
计算 el[i].offsetHeight
之和,构造页数组,以 $Layout.Height
为参数分离
el[i].offsetHeight
向上取$FontSize * $LineHeight
倍数
特殊占位符(Placeholder)
- H1(章节分离)
- Img(惰性加载,难以计算,独立成页)
切换节点
父元素监听 Page
对象变化,删除渲染节点,并写入需要显示的页面数据
isLoading: state => !state.book.pages.length,
page.page-reader(v-if="!$store.getters.isLoading && vertical", v-for="(page, key) in slicedPage", :key="sliced[0] + key")
PageReader(slot="bd", :index="sliced[0] + key")
transition(v-if="!$store.getters.isLoading && !vertical", :name="transition")
page.page-reader(v-if="currPos", :key="currPos")
h3(slot="hd") {{ title }}
PageReader(slot="bd", :index="currPos - 1")
span(slot="ft") {{ currPos }}
span / {{ pages.length }}
滚动加载
page.page-reader(v-if="!$store.getters.isLoading && vertical", v-for="(page, key) in slicedPage", :key="sliced[0] + key")
PageReader(slot="bd", :index="sliced[0] + key")
transition(v-if="!$store.getters.isLoading && !vertical", :name="transition")
page.page-reader(v-if="currPos", :key="currPos")
h3(slot="hd") {{ title }}
PageReader(slot="bd", :index="currPos - 1")
span(slot="ft") {{ currPos }}
span / {{ pages.length }}
在 mounted
挂载滚动监听
mounted() {
global.window.addEventListener('scroll', this.handleScroll);
},
turn
改变动画参数和游标
handleScroll() {
const scrollY = Math.floor((global.window.scrollY / this.fontSize) / this.contentHeight);
this.turn(scrollY - this.currPos);
},
turn(num) {
this.transition = (num > 0) ? 'slide-left' : 'slide-right';
if ((this.currPos + num) >= 0 && (this.currPos + num) < this.pages.length) {
this.currPos += num;
}
},
游标变化后,sliced
重新计算,slicedPage
随之计算,写入 DOM
sliced() {
const start = this.currPos >= 3 ? (this.currPos - 3) : 0;
let end = this.pages.length - 1;
end = (this.currPos + 3) < end ? (this.currPos + 3) : end;
return [
start, end, end - start,
];
},
slicedPage() {
return this.pages.slice(this.sliced[0], this.sliced[1] + 1);
},
父元素改变 padding-top
& paddingbottom
来填充滚动条
innerStyle() {
if (!this.vertical) {
return {
width: `${this.pageWidth}em`,
};
}
return {
width: `${this.pageWidth}em`,
paddingTop: `${this.contentHeight * this.sliced[0]}em`,
paddingBottom: `${this.contentHeight * ((this.pages.length - 1) - (this.sliced[0] + this.sliced[2]))}em`,
};
},