├── .gitignore
├── README.md
├── dist
└── kabi-novel.html
├── gulpfile.js
├── package.json
├── pnpm-lock.yaml
├── src
├── article
│ └── article.ts
├── bookshelf
│ └── bookshelf.ts
├── catalogue
│ └── catalogue.ts
├── common
│ ├── api
│ │ └── api.ts
│ ├── bar
│ │ ├── bar.less
│ │ └── bar.ts
│ ├── bind
│ │ └── bind.ts
│ ├── common.ts
│ ├── debugger
│ │ └── debugger.ts
│ ├── layout
│ │ └── layout.ts
│ ├── message
│ │ ├── message.less
│ │ └── message.ts
│ ├── modal
│ │ ├── modal.less
│ │ └── modal.ts
│ ├── pagination
│ │ ├── pagination.less
│ │ └── pagination.ts
│ ├── router
│ │ └── router.ts
│ └── store
│ │ └── store.ts
├── config
│ └── config.ts
├── global.d.ts
├── index.html
├── main.ts
└── style.less
├── temp
├── bundle.js
├── kabi-novel.html
└── style.css
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | node_modules/
3 | **/node_modules
4 | **/node_modules/
5 |
6 | .pnpm-debug.log
7 | .vscode/
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 简介
2 | 阅读APP网页版的kindle适配
3 | 也相当于写了一套旧内核的简易开发框架,看上的可以搬去写点旧设备的简单页面
4 | 为爱发电
5 | 回馈社区
6 | 如有bug
7 | 多多包涵
8 |
9 | # 困难
10 | kindle浏览器内核老旧,许多新东西用不了
11 | 个人kindle缓存限制大小为2600000字节,约2.48M
12 | 不支持元素滚动条
13 | 不支持Multiple-column多列布局
14 | 不支持calc样式计算
15 | 性能低下,大量数据处理时卡顿
16 | kindle浏览器自带无法取消的滚动条
17 | kindle浏览器输入以及双击会强制缩放
18 | 未进行逐字排版,难以计算进度
19 | 滚动条分页+自定义行距字号易断字
20 | github被墙,没有免费的
21 |
22 | # 解决的问题
23 | 放弃滚动翻页,尝试点击切屏
24 | 放弃css自动分页,尝试滚动条分页
25 | 骚操作排版防止断字
26 | 骚操作计算进度,与app同步进度
27 | 目录改为虚拟分页,提高页面性能
28 |
29 | # 使用方法
30 |
31 | ## 在线试用
32 | 试用渠道(资源有限仅供检测设备可用性,不稳定请轻薅):
33 | [github pages试用渠道](https://cyx7788414.github.io/kabi-novel.html)
34 | 试用成功后,有能力的朋友,请自行部署kabi-novel.html文件至个人域名或本地内网以免丢失缓存
35 | 如果有大佬开放了免费公众服务,可以与我联系更新文档
36 | ### 优点
37 | 不会丢失缓存
38 | ### 缺点
39 | 初始化时需要联网
40 |
41 | ## 静态使用
42 | 将dist目录中的kabi-novel.html拷入kindle根目录
43 | 启动kindle浏览器,地址栏输入file:///mnt/us/kabi-novel.html
44 | 可自行更名,添加书签
45 | ### 优点
46 | 无需外网,可内网使用
47 | ### 缺点
48 | 每次关闭浏览器后缓存丢失,下次使用需要重新配置
49 |
50 | ## 使用流程
51 | 初始化时在配置页输入服务端地址,并进行校验
52 | 在配置页检测缓存容量,以获得容量告警
53 | 在配置页调整字号行距(仅对小说正文有效)
54 | 进入书架,初始化时需手动点击刷新才有数据(为了避免自动刷新误删本地缓存)
55 | 点击书籍,开始阅读
56 | 点击目录,可跳章,管理本书缓存
57 |
58 | ## 说明
59 | 保持与服务端联网,可以同步进度
60 | 离线时进度保存在本地,再次联网刷新书架时,本地与服务端之间进度根据时间戳匹配,取其新者
61 | 每次翻页/跳章时尝试联网保存进度
62 |
63 | # 仍然存在的问题
64 | 配置页输入服务端地址时仍然会缩放,需要手动恢复
65 | 快速点击多次仍然会触发双击自动缩放,需要手动恢复
66 | 配置页检测缓存容量仍然卡顿
67 | 无法取消的右侧滚动条
68 |
69 |
--------------------------------------------------------------------------------
/dist/kabi-novel.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
12 | kabi novel
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
39 |
40 |
43 |
44 |
45 |
46 |
47 |
书架
48 |
目录
49 |
刷新
50 |
51 |
55 |
56 |
57 |
58 |
61 |
62 |
63 |
64 |
65 |
书架
66 |
文章
67 |
刷新
68 |
缓存
69 |
70 |
74 |
75 |
76 |
77 |
78 |
85 |
95 |
105 |
115 |
121 |
122 |
123 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | const { dest, series, src, parallel, watch } = require("gulp");
2 | const { createProject } = require("gulp-typescript");
3 | const browserify = require("browserify");
4 | const tsify = require("tsify");
5 | const source = require('vinyl-source-stream');
6 | const inject = require('gulp-inject');
7 | const clean = require('gulp-clean');
8 | const less = require('gulp-less');
9 | const cssmin = require('gulp-minify-css');
10 | const uglify = require('gulp-uglify');
11 | const buffer = require('vinyl-buffer');
12 | const rename = require('gulp-rename');
13 |
14 | let debug = false;
15 |
16 | function cleanDir(cb) {
17 | return src(debug?['temp/*']:['dist/*', 'temp/*']).pipe(clean());
18 | }
19 |
20 | function buildLess(cb) {
21 | return src('src/style.less')
22 | .pipe(less())
23 | .pipe(cssmin())
24 | .pipe(dest('temp'));
25 | }
26 |
27 | function buildTs(cb) {
28 | let a = browserify({
29 | basedir: '.',
30 | debug: debug,
31 | entries: ['src/main.ts'],
32 | cache: {},
33 | packageCache: {}
34 | })
35 | .plugin(tsify)
36 | .bundle()
37 | .pipe(source('bundle.js'))
38 | .pipe(buffer());
39 | if (!debug) {
40 | a = a.pipe(uglify());
41 | }
42 | // .pipe(uglify())
43 | return a.pipe(dest("temp"));
44 | }
45 |
46 | function collect(cb) {
47 | var target = src('./src/index.html');
48 | var sources = src(['./temp/**/*.js', './temp/**/*.css'], { read: true });
49 |
50 | return target.pipe(inject(sources, {
51 | transform: function (filePath, file) {
52 | if (filePath.slice(-3) === '.js') {
53 | return '';
54 | }
55 | if (filePath.slice(-4) === '.css') {
56 | return '';
57 | }
58 | return file.contents.toString('utf8');
59 | }
60 | }))
61 | .pipe(rename('kabi-novel.html'))
62 | .pipe(dest(debug?'temp':'dist'));
63 | }
64 |
65 | const task = series(cleanDir, parallel(buildTs, buildLess), collect);
66 |
67 | function setFlag(cb) {
68 | debug = true;
69 | watch('src', task);
70 | task();
71 | cb();
72 | }
73 |
74 | exports.default = task;
75 | exports.debug = setFlag;
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "kabi-novel",
3 | "version": "1.0.0",
4 | "description": "",
5 | "scripts": {
6 | "build": "gulp",
7 | "debug": "gulp debug",
8 | "test": "echo \"Error: no test specified\" && exit 1"
9 | },
10 | "keywords": [],
11 | "author": "",
12 | "license": "ISC",
13 | "devDependencies": {
14 | "@babel/core": "^7.0.0",
15 | "babel-core": "^6.26.3",
16 | "babel-preset-es2015": "^6.24.1",
17 | "babelify": "^10.0.0",
18 | "browserify": "^17.0.0",
19 | "gulp": "^4.0.2",
20 | "gulp-clean": "^0.4.0",
21 | "gulp-inject": "^5.0.5",
22 | "gulp-less": "^5.0.0",
23 | "gulp-minify-css": "^1.2.4",
24 | "gulp-rename": "^2.0.0",
25 | "gulp-sourcemaps": "^3.0.0",
26 | "gulp-typescript": "6.0.0-alpha.1",
27 | "gulp-uglify": "^3.0.2",
28 | "gulp-util": "^3.0.8",
29 | "less": "^4.1.3",
30 | "tsify": "^5.0.4",
31 | "typescript": "^4.9.5",
32 | "vinyl-buffer": "^1.0.1",
33 | "vinyl-source-stream": "^2.0.0",
34 | "watchify": "^4.0.0"
35 | },
36 | "dependencies": {
37 | "lz-string": "^1.5.0"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/article/article.ts:
--------------------------------------------------------------------------------
1 | import Catalogue from "../catalogue/catalogue";
2 | import Bar from "../common/bar/bar";
3 | import { Book, CatalogueItem, changeValueWithNewObj, Progress } from "../common/common";
4 | import Pagination from "../common/pagination/pagination";
5 |
6 | class Article {
7 | element: HTMLElement;
8 | bar: Bar;
9 | pagination: Pagination;
10 |
11 | currentBook: Book;
12 |
13 | progress: Progress;
14 |
15 | catalogue: CatalogueItem[] = [];
16 |
17 | content: string;
18 |
19 | loading: boolean = false;
20 |
21 | constructor() {
22 | this.element = document.querySelector('.page.article');
23 |
24 | this.pagination = new Pagination({
25 | root: this.element.querySelector('.content')
26 | });
27 |
28 | this.bar = new Bar({
29 | element: this.element.querySelector('.bar'),
30 | pagination: this.pagination
31 | });
32 |
33 | window.Bind.bindView(this.element.querySelector('.content-inner'), this, 'content', (content: string) => {
34 | if (!content) {
35 | return '';
36 | }
37 |
38 | let html = `
39 |
41 | `;
42 | content.split('\n').map(v => {
43 | return v.trim()
44 | }).filter(v => !!v).forEach(v => {
45 | html += `
46 | ${v}
47 | `;
48 | });
49 | window.setTimeout(() => {
50 | this.pagination.checkPage();
51 | this.setPageByProgress();
52 | });
53 | return html;
54 | });
55 |
56 | window.Bind.bind(this, 'progress', (newV: any, oldV: any) => {
57 | if (!oldV) {
58 | return;
59 | }
60 | window.Store.setObj(`p_${this.currentBook.id}`, newV);
61 | if (this.progress.pos > this.content.length) {
62 | return;
63 | }
64 | window.Api.saveProgress(this.currentBook, this.progress);
65 | });
66 |
67 | const current: HTMLElement = this.element.querySelector('.current-info');
68 | const changeInfo = () => {
69 | return `${this.currentBook?.name} - ${this.currentBook?.author} - ${this.progress?.title}`;
70 | };
71 | window.Bind.bindView(current, this, 'currentBook', changeInfo);
72 | window.Bind.bindView(current, this, 'progress', changeInfo);
73 | window.Bind.bindView(current, this, 'catalogue', changeInfo);
74 |
75 | let content: HTMLElement = this.element.querySelector('.content');
76 | let contentInner: HTMLElement = content.querySelector('.content-inner');
77 | window.Bind.bindStyle(contentInner, window.Layout, 'fontSize', 'fontSize', (v: any) => `${v}px`);
78 | window.Bind.bindStyle(contentInner, window.Layout, 'lineHeight', 'lineHeight', (v: any) => `${v}px`);
79 | window.Bind.bindStyle(content, window.Layout, 'lineHeight', 'height', (v: any) => {
80 | if (!this.element.offsetHeight) {
81 | return '';
82 | }
83 | let base = this.element.offsetHeight - 230 - 20;
84 | let oo = base % window.Layout.lineHeight;
85 | if (oo < 10) {
86 | oo += window.Layout.lineHeight;
87 | }
88 | let height = base - oo + 20;
89 | current.style.height = `${oo}px`;
90 | current.style.lineHeight = `${oo}px`;
91 | window.setTimeout(() => this.pagination.checkPage());
92 | return `${height}px`;
93 | });
94 |
95 | let func = () => {
96 | let current = window.Store.get('current');
97 | this.currentBook = window.BookShelf.bookMap[current];
98 | if (!this.currentBook) {
99 | if (window.Router.current === 'article') {
100 | window.Router.go('bookshelf');
101 | }
102 | return;
103 | }
104 |
105 | window.Layout.lineHeight = window.Layout.lineHeight;
106 |
107 | this.catalogue = window.Store.getObj(`c_${current}`) || [];
108 |
109 | this.progress = window.Store.getObj(`p_${this.currentBook.id}`);
110 |
111 | this.getContent();
112 | };
113 |
114 | window.Router.cbMap.article = func;
115 | func();
116 | }
117 |
118 | getContent(): void {
119 | this.content = window.Store.get(`a_${this.currentBook.id}_${this.progress.index}`) || '';
120 | let cb = () => {
121 | window.setTimeout(() => {
122 | window.Catalogue?.doCache(5);
123 | });
124 | };
125 | if (!this.content) {
126 | this.getArticle(cb);
127 | } else {
128 | cb();
129 | }
130 | }
131 |
132 | pageChange(num: 1 | -1): void {
133 | let target = this.pagination.pageIndex + num;
134 | if (target < 0 || target >= this.pagination.pageLimit) {
135 | let index = this.progress.index + num;
136 | let pos = num === -1?999999999999:0;// to the end
137 | this.progress = changeValueWithNewObj(this.progress, {index: index, title: this.catalogue[index].title, time: new Date().getTime(), pos: pos});
138 | this.getContent();
139 | } else {
140 | this.pagination.setPage(target);
141 | this.getPagePos(target);
142 | }
143 | }
144 |
145 | getPagePos(target: number): void {
146 | let top = target * this.pagination.pageStep;
147 | let ps = this.element.querySelectorAll('.content-inner p');
148 | let str = '';
149 | for (let i = 0; i < ps.length; i++) {
150 | if ((ps[i] as HTMLElement).offsetTop >= top) {
151 | str = ps[i].innerHTML;
152 | break;
153 | }
154 | }
155 | let pos = this.content.indexOf(str);
156 | this.progress = changeValueWithNewObj(this.progress, {time: new Date().getTime(), pos: pos});
157 | }
158 |
159 | setPageByProgress(): void {
160 | let target = this.content.slice(0, this.progress.pos).split('\n').length - 1;
161 | let ele: HTMLElement = this.element.querySelectorAll('.content-inner p')[target] as HTMLElement;
162 | let top = ele.offsetTop;
163 | let index = Math.floor(top / this.pagination.pageStep);
164 | this.pagination.setPage(index);
165 | if (this.progress.pos > this.content.length) {//reset to right
166 | this.getPagePos(index);
167 | }
168 | }
169 |
170 | getArticle(cb?: Function): void {
171 | if (this.loading === true) {
172 | window.Message.add({content: '正在加载章节内容'});
173 | return;
174 | }
175 | this.loading = true;
176 | window.Api.getArticle(this.currentBook.source, this.progress.index, {
177 | success: (res: any) => {
178 | this.loading = false;
179 | this.content = res.data;
180 | window.Store.set(`a_${this.currentBook.id}_${this.progress.index}`, this.content);
181 | cb && cb();
182 | },
183 | error: (err: any) => {
184 | this.loading = false;
185 | }
186 | });
187 | }
188 | };
189 |
190 | export default Article;
--------------------------------------------------------------------------------
/src/bookshelf/bookshelf.ts:
--------------------------------------------------------------------------------
1 | import Bar from '../common/bar/bar';
2 | import { Book, getObject, getSpecialParent, Progress } from '../common/common';
3 | import Pagination from '../common/pagination/pagination';
4 |
5 | class BookShelf {
6 | element: HTMLElement;
7 | bar: Bar;
8 | pagination: Pagination;
9 |
10 | bookMap: {[key: string]: Book} = {};
11 | bookList: Book[] = [];
12 |
13 |
14 | loading: boolean = false;
15 |
16 | pageHeight: number;
17 |
18 | constructor() {
19 | this.element = document.querySelector('.page.bookshelf');
20 |
21 | this.pagination = new Pagination({
22 | root: this.element.querySelector('.content')
23 | });
24 |
25 | this.bar = new Bar({
26 | element: this.element.querySelector('.bar'),
27 | pagination: this.pagination
28 | });
29 |
30 | this.bookList = window.Store.getObj('bookshelf') || [];
31 | // this.bookList = window.Store.getByHead('b_').map(v => JSON.parse(window.Store.get(v) || ''));//wait
32 |
33 | window.Bind.bindView(this.element.querySelector('.book-list'), this, 'bookList', (bookList: Book[], oldV: Book[] = []) => {
34 | this.compareBookList(bookList, oldV);
35 | let height = (this.element.querySelector('.pagination-box') as HTMLElement).offsetHeight / 4;
36 | let imgWidth = height * 3 / 4;
37 | let width = Math.floor((this.element.querySelector('.book-list') as HTMLElement).offsetWidth / 2);
38 | let html = `
39 |
44 | `;
45 | bookList.forEach(book => {
46 | let date = new Date(book.latestChapterTime);
47 | let progress: Progress = window.Store.getObj(`p_${book.id}`);
48 | html += `
49 |
50 |
51 |

52 |
53 |
54 |
${book.name}
55 |
${book.author}
56 |
${progress.title}
57 |
${book.latestChapterTitle}
58 |
更新时间:${date.getFullYear()}-${date.getMonth() + 1}-${date.getDay()}
59 |
60 |
61 | `;
62 | });
63 | window.setTimeout(() => {
64 | this.pagination.checkPage();
65 | });
66 | return html;
67 | });
68 |
69 | window.Router.cbMap.bookshelf = () => {
70 | this.bookList = [].concat(this.bookList);
71 | };
72 |
73 | }
74 |
75 | bookDelete(book: Book, onlySource?: boolean): void {
76 | if (!onlySource) {
77 | window.Store.del(`p_${book.id}`);
78 | }
79 | window.Store.del(`c_${book.id}`);
80 | window.Store.getByHead(`a_${book.id}`).forEach(v => window.Store.del(v));
81 | }
82 |
83 | compareBookList(newV: Book[], oldV: Book[]): void {
84 | let oldMap = this.bookMap;
85 | this.bookMap = {};
86 | newV.forEach(book => {
87 | this.bookMap[book.id] = book;
88 | if (oldMap[book.id]) {
89 | if (book.source !== oldMap[book.id].source) {
90 | this.bookDelete(oldMap[book.id], true);
91 | }
92 | delete oldMap[book.id];
93 | }
94 | });
95 | Object.keys(oldMap).forEach((id: string) => {
96 | this.bookDelete(oldMap[id]);
97 | });
98 | }
99 |
100 | getBookShelf(): void {
101 | if (this.loading === true) {
102 | window.Message.add({content: '正在加载书架数据'});
103 | return;
104 | }
105 | this.loading = true;
106 | window.Api.getBookshelf({
107 | success: (res: any) => {
108 | this.loading = false;
109 | let bookList: Book[] = res.data.map((book: any) => {
110 | let id = window.Store.compress(`${book.name}_${book.author}`);
111 | let keys: string[] = ['name', 'author', 'coverUrl', 'customCoverUrl', 'latestChapterTime', 'latestChapterTitle'];
112 | let pobj: Progress = getObject(book, [], {
113 | index: book.durChapterIndex,
114 | pos: book.durChapterPos,
115 | time: new Date(book.durChapterTime).getTime(),
116 | title: book.durChapterTitle
117 | });
118 | let old = window.Store.getObj(`p_${id}`);
119 | if (!old || old.time < pobj.time) {
120 | window.Store.setObj(`p_${id}`, pobj);
121 | }
122 | return getObject(book, keys, {
123 | id: id,
124 | source: book.bookUrl
125 | });
126 | });
127 | this.bookList = [].concat(bookList);
128 | window.Store.setObj('bookshelf', this.bookList);
129 | },
130 | error: (err: any) => {
131 | this.loading = false;
132 | }
133 | });
134 | }
135 |
136 | clickItem(event: Event): void {
137 | let item = getSpecialParent((event.target || event.srcElement) as HTMLElement, (ele: HTMLElement) => {
138 | return ele.classList.contains('book-item');
139 | });
140 | let id = item.getAttribute('key');
141 | window.Store.set('current', id);
142 | window.Router.go('article');
143 | }
144 | };
145 |
146 | export default BookShelf;
--------------------------------------------------------------------------------
/src/catalogue/catalogue.ts:
--------------------------------------------------------------------------------
1 | import Bar from "../common/bar/bar";
2 | import { Book, CatalogueItem, changeValueWithNewObj, getSpecialParent, Progress } from "../common/common";
3 | import Pagination from "../common/pagination/pagination";
4 |
5 | class Catalogue {
6 | element: HTMLElement;
7 | bar: Bar;
8 | pagination: Pagination;
9 |
10 | currentBook: Book;
11 | progress: Progress;
12 |
13 | linePerPage: number;
14 |
15 | list: CatalogueItem[] = [];
16 | pageList: CatalogueItem[] = [];
17 |
18 | oo: number = 10;
19 |
20 | loading: boolean = false;
21 |
22 | cacheFlag: boolean = false;
23 |
24 | constructor() {
25 | this.element = document.querySelector('.page.catalogue');
26 |
27 | this.pagination = new Pagination({
28 | root: this.element.querySelector('.content'),
29 | fake: true,
30 | pageChange: (index: number) => {
31 | let start = index * this.linePerPage;
32 | this.pageList = this.list.slice(start, start + this.linePerPage);
33 | }
34 | });
35 |
36 | this.bar = new Bar({
37 | element: this.element.querySelector('.bar'),
38 | pagination: this.pagination
39 | });
40 |
41 | const current: HTMLElement = this.element.querySelector('.current-info');
42 | window.Bind.bind(this, 'list', (list: CatalogueItem[]) => {
43 | if (!this.linePerPage) {
44 | return;
45 | }
46 | this.pagination.checkPage(Math.ceil(list.length / this.linePerPage));
47 | this.pagination.setPage(Math.floor(this.progress.index / this.linePerPage));
48 | });
49 | window.Bind.bindView(this.element.querySelector('.article-list'), this, 'pageList', (list: CatalogueItem[]) => {
50 | let html = `
51 |
54 | `;
55 | list.forEach((article) => {
56 | let current = article.index === this.progress.index?'current':'';
57 | let cached = window.Store.has(`a_${this.currentBook.id}_${article.index}`)?'cached':'';
58 | html += `
59 | ${article.title}
60 | `;
61 | });
62 | return html;
63 | });
64 |
65 | window.Bind.bindView(current, this, 'currentBook', () => {
66 | return `${this.currentBook?.name} - ${this.currentBook?.author}`;
67 | });
68 |
69 | window.Bind.bind(this, 'progress', (newV: any, oldV: any) => {
70 | window.Store.setObj(`p_${this.currentBook.id}`, newV);
71 | });
72 |
73 |
74 | let func = () => {
75 | this.checkCurrent();
76 |
77 | this.checkHeight();
78 | };
79 |
80 | window.Router.cbMap.catalogue = func;
81 | func();
82 | }
83 |
84 | checkCurrent(): void {
85 | this.currentBook = window.BookShelf.bookMap[window.Store.get('current')];
86 |
87 | if (!this.currentBook) {
88 | if (window.Router.current === 'catalogue') {
89 | window.Router.go('bookshelf');
90 | }
91 | return;
92 | }
93 |
94 | this.progress = window.Store.getObj(`p_${this.currentBook.id}`);
95 |
96 | this.list = window.Store.getObj(`c_${this.currentBook.id}`) || [];
97 |
98 | if (this.list.length === 0) {
99 | this.getCatalogue();
100 | }
101 | }
102 |
103 | checkHeight(): void {
104 | let height = this.element.offsetHeight - 230 - 20;
105 | let oo = height % 80;
106 | if (oo < 10) {
107 | oo += 80;
108 | }
109 | this.oo = oo;
110 | this.linePerPage = Math.round((height - oo) / 80) * 2;
111 | const current: HTMLElement = this.element.querySelector('.current-info');
112 | const content: HTMLElement = this.element.querySelector('.content');
113 | current.style.height = `${oo}px`;
114 | current.style.lineHeight = `${oo}px`;
115 | content.style.height = `${height - oo + 20}px`;
116 | }
117 |
118 |
119 | getCatalogue(): void {
120 | if (this.loading === true) {
121 | window.Message.add({content: '正在加载目录数据'});
122 | return;
123 | }
124 | this.loading = true;
125 | window.Api.getCatalogue(this.currentBook.source, {
126 | success: (res: any) => {
127 | this.loading = false;
128 | this.list = res.data.map((v: any) => {
129 | return {
130 | index: v.index,
131 | title: v.title
132 | };
133 | });
134 | window.Store.setObj(`c_${this.currentBook.id}`, this.list);
135 | },
136 | error: (err: any) => {
137 | this.loading = false;
138 | }
139 | });
140 | }
141 |
142 | clickItem(event: Event): void {
143 | let item = getSpecialParent((event.target || event.srcElement) as HTMLElement, (ele: HTMLElement) => {
144 | return ele.classList.contains('article-item');
145 | });
146 | let index = parseInt(item.getAttribute('key'));
147 | this.progress = changeValueWithNewObj(this.progress, {index: index, title: this.list[index].title, time: new Date().getTime(), pos: 0});
148 | window.setTimeout(() => {
149 | window.Router.go('article');
150 | });
151 | }
152 |
153 | makeCache(start: number, end: number): void {
154 | if (start > end) {
155 | this.cacheFlag = false;
156 | window.Message.add({
157 | content: '缓存任务完成'
158 | });
159 | return;
160 | }
161 | if (window.Store.has(`a_${this.currentBook.id}_${start}`)) {
162 | this.makeCache(start + 1, end);
163 | return;
164 | }
165 | window.Api.getArticle(this.currentBook.source, start, {
166 | success: (res: any) => {
167 | window.Store.set(`a_${this.currentBook.id}_${start}`, res.data);
168 | this.element.querySelector(`.article-item[key="${start}"]`)?.classList.add('cached');
169 | this.makeCache(start + 1, end);
170 | },
171 | error: (err: any) => {
172 | window.Message.add({
173 | content: `缓存章节《${this.list[start].title}》失败`
174 | });
175 | this.makeCache(start + 1, end);
176 | }
177 | });
178 | }
179 |
180 | doCache(val: number | 'end' | 'all'): void {
181 | if (this.cacheFlag) {
182 | window.Message.add({
183 | content: '正在缓存,请勿重复操作'
184 | });
185 | return;
186 | }
187 | this.checkCurrent();
188 | this.cacheFlag = true;
189 | let start = this.progress?.index;
190 | let last = this.list[this.list.length - 1]?.index || 0;
191 | if (val === 'all') {
192 | start = 0;
193 | }
194 | if (typeof val === 'number') {
195 | last = Math.min(last, start + val);
196 | }
197 | this.makeCache(start, last);
198 | }
199 |
200 | deleteCache(type: 'readed' | 'all'): void {
201 | if (this.cacheFlag) {
202 | window.Message.add({
203 | content: '正在缓存,禁用删除操作'
204 | });
205 | return;
206 | }
207 | window.Store.getByHead(`a_${this.currentBook.id}_`).filter(v => !(type === 'readed' && parseInt(v.split('_')[2]) >= this.progress.index)).forEach(v => {
208 | window.Store.del(v);
209 | this.element.querySelector(`.article-item[key="${v.split('_')[2]}"]`)?.classList.remove('cached');
210 | });
211 | window.Message.add({
212 | content: '删除指定缓存完成'
213 | });
214 | }
215 |
216 | cache(): void {
217 | window.Modal.add({
218 | content: `
219 |
228 | 缓存20章
229 | 缓存50章
230 | 缓存100章
231 | 缓存200章
232 | 缓存未读
233 | 缓存全文
234 | 删除已读
235 | 删除全部
236 | `,
237 | })
238 | }
239 | };
240 |
241 | export default Catalogue;
--------------------------------------------------------------------------------
/src/common/api/api.ts:
--------------------------------------------------------------------------------
1 | import { Book, Progress } from "../common";
2 |
3 | class Api {
4 | url: string;
5 |
6 | apiMap: {[key: string]: string} = {
7 | bookshelf: '/getBookshelf',
8 | catalogue: '/getChapterList',
9 | article: '/getBookContent',
10 | save: '/saveBookProgress'
11 | };
12 |
13 | private _checkXHR: XMLHttpRequest;
14 |
15 | constructor() {
16 | if (window.Api) {
17 | throw Error('api has been inited');
18 | }
19 | window.Api = this;
20 |
21 | this.url = window.Store.get('url') || '';
22 | }
23 |
24 | saveProgress(book: Book, progress: Progress, cb?: {success?: Function, error?: Function}): void {
25 | this.post(this.url + this.apiMap.save, {
26 | author: book.author,
27 | durChapterIndex: progress.index,
28 | durChapterPos: progress.pos,
29 | durChapterTime: progress.time,
30 | durChapterTitle: progress.title,
31 | name: book.name
32 | }, {
33 | success: (data: any) => {
34 | cb && cb.success && cb.success(data);
35 | },
36 | error: (err: any) => {
37 | console.log(err);
38 | cb && cb.error && cb.error(err);
39 | window.Message.add({content: '保存阅读进度到服务端失败'});
40 | }
41 | })
42 | }
43 |
44 | getArticle(url: string, index: number, cb?: {success?: Function, error?: Function}): void {
45 | this.get(this.url + this.apiMap.article, {url: url, index: index}, {
46 | success: (data: any) => {
47 | cb && cb.success && cb.success(data);
48 | },
49 | error: (err: any) => {
50 | console.log(err);
51 | cb && cb.error && cb.error(err);
52 | window.Message.add({content: '获取章节内容失败'});
53 | }
54 | });
55 | }
56 |
57 | getCatalogue(url: string, cb?: {success?: Function, error?: Function}): void {
58 | this.get(this.url + this.apiMap.catalogue, {url: url}, {
59 | success: (data: any) => {
60 | cb && cb.success && cb.success(data);
61 | },
62 | error: (err: any) => {
63 | console.log(err);
64 | cb && cb.error && cb.error(err);
65 | window.Message.add({content: '获取目录内容失败'});
66 | }
67 | });
68 | }
69 |
70 | getBookshelf(cb?: {success?: Function, error?: Function}): void {
71 | this.get(this.url + this.apiMap.bookshelf, {}, {
72 | success: (data: any) => {
73 | cb && cb.success && cb.success(data);
74 | },
75 | error: (err: any) => {
76 | console.log(err);
77 | cb && cb.error && cb.error(err);
78 | window.Message.add({content: '获取书架内容失败'});
79 | }
80 | });
81 | }
82 |
83 | post(url: string, data: { [key: string]: any }, cb?: {success?: Function, error?: Function, check?: boolean}) {
84 | return this.http('POST', url, data, cb);
85 | }
86 |
87 | get(url: string, data: { [key: string]: any }, cb?: {success?: Function, error?: Function, check?: boolean}) {
88 | return this.http('GET', url, data, cb);
89 | }
90 |
91 | // get(url: string, data: { [key: string]: any }, cb?: {success?: Function, error?: Function, check?: boolean}) {
92 | // if (!this.url && !(cb && cb.check)) {
93 | // window.Message.add({content: '当前未配置服务器地址'});
94 | // cb && cb.error && cb.error(null);
95 | // return;
96 | // }
97 |
98 | // // 创建 XMLHttpRequest,相当于打开浏览器
99 | // const xhr = new XMLHttpRequest()
100 |
101 | // // 打开一个与网址之间的连接 相当于输入网址
102 | // // 利用open()方法,第一个参数是对数据的操作,第二个是接口
103 | // xhr.open("GET", `${url}?${Object.keys(data).map(v => `${v}=${data[v]}`).join('&')}`);
104 |
105 | // // 通过连接发送请求 相当于点击回车或者链接
106 | // xhr.send(null);
107 |
108 | // // 指定 xhr 状态变化事件处理函数 相当于处理网页呈现后的操作
109 | // // 全小写
110 | // xhr.onreadystatechange = function () {
111 | // // 通过readyState的值来判断获取数据的情况
112 | // if (this.readyState === 4) {
113 | // // 响应体的文本 responseText
114 | // let response;
115 | // try {
116 | // response = JSON.parse(this.responseText);
117 | // } catch(e) {
118 | // response = this.responseText;
119 | // }
120 | // if (this.status === 200 && response.isSuccess) {
121 | // cb && cb.success && cb.success(response);
122 | // } else {
123 | // cb && cb.error && cb.error(response);
124 | // }
125 | // }
126 | // }
127 |
128 | // return xhr;
129 | // }
130 |
131 | http(method: 'GET' | 'POST',url: string, data: { [key: string]: any }, cb?: {success?: Function, error?: Function, check?: boolean}) {
132 | if (!this.url && !(cb && cb.check)) {
133 | window.Message.add({content: '当前未配置服务器地址'});
134 | cb && cb.error && cb.error(null);
135 | return;
136 | }
137 |
138 | // 创建 XMLHttpRequest,相当于打开浏览器
139 | const xhr = new XMLHttpRequest();
140 |
141 | // 打开一个与网址之间的连接 相当于输入网址
142 | // 利用open()方法,第一个参数是对数据的操作,第二个是接口
143 | // xhr.open(method, `${url}?${Object.keys(data).map(v => `${v}=${data[v]}`).join('&')}`);
144 | let param: string = Object.keys(data).map(v => `${v}=${data[v]}`).join('&');
145 | xhr.open(method, method === 'GET'?`${url}?${param}`:url);
146 |
147 | if (method === 'POST') {
148 | xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
149 | }
150 |
151 | // 通过连接发送请求 相当于点击回车或者链接
152 | xhr.send(method === 'GET'?null:JSON.stringify(data));
153 |
154 | // 指定 xhr 状态变化事件处理函数 相当于处理网页呈现后的操作
155 | // 全小写
156 | xhr.onreadystatechange = function () {
157 | // 通过readyState的值来判断获取数据的情况
158 | if (this.readyState === 4) {
159 | // 响应体的文本 responseText
160 | let response;
161 | try {
162 | response = JSON.parse(this.responseText);
163 | } catch(e) {
164 | response = this.responseText;
165 | }
166 | if (this.status === 200 && response.isSuccess) {
167 | cb && cb.success && cb.success(response);
168 | } else {
169 | cb && cb.error && cb.error(response);
170 | }
171 | }
172 | }
173 |
174 | return xhr;
175 | }
176 |
177 | setUrl(url: string) {
178 | this.url = url;
179 | window.Store.set('url', url);
180 | }
181 |
182 | checkUrl(url: string) {
183 | if (this._checkXHR) {
184 | this._checkXHR.abort();
185 | }
186 | this._checkXHR = this.get(url + this.apiMap.bookshelf, {}, {
187 | success: (data: any) => {
188 | window.Message.add({content: '服务器地址测试成功'});
189 | this.setUrl(url);
190 | },
191 | error: (err: any) => {
192 | console.log(err);
193 | window.Message.add({content: '服务器地址测试失败'});
194 | },
195 | check: true
196 | });
197 | }
198 | };
199 |
200 | export default Api;
--------------------------------------------------------------------------------
/src/common/bar/bar.less:
--------------------------------------------------------------------------------
1 | .bar {
2 | height: @barHeight;
3 | position: absolute;
4 | bottom: @controlHeight;
5 | width: 100%;
6 | background-color: #666;
7 | text-align: center;
8 |
9 | .bar-progress {
10 | display: inline-block;
11 | height: 100%;
12 | background-color: #000;
13 | float: left;
14 | }
15 | .bar-text {
16 | position: absolute;
17 | width: 100%;
18 | height: 100%;
19 | line-height: @barHeight;
20 | color: #fff;
21 | }
22 | }
--------------------------------------------------------------------------------
/src/common/bar/bar.ts:
--------------------------------------------------------------------------------
1 | import Pagination from "../pagination/pagination";
2 |
3 | class Bar {
4 | element: HTMLElement;
5 | pagination: Pagination;
6 | percent: number;
7 |
8 | constructor(config: {
9 | element: HTMLElement,
10 | pagination: Pagination
11 | }) {
12 | this.element = config.element;
13 | this.pagination = config.pagination;
14 | this.percent = 0;
15 |
16 | this.element.innerHTML = `
17 |
18 | /
19 | `;
20 |
21 | let index: HTMLElement = this.element.querySelector('.bar-current');
22 | let total: HTMLElement = this.element.querySelector('.bar-total');
23 | let progress: HTMLElement = this.element.querySelector('.bar-progress');
24 |
25 | window.Bind.bindView(index, this.pagination, 'pageIndex', (value: number) => {
26 | let v = value + 1;
27 | this.percent = v / this.pagination.pageLimit;
28 | return v;
29 | });
30 | window.Bind.bindView(total, this.pagination, 'pageLimit', (value: number) => {
31 | this.percent = (this.pagination.pageIndex + 1) / value;
32 | return value;
33 | });
34 |
35 | window.Bind.bindStyle(progress, this, 'percent', 'width', (v: any) => `${v * 100}%`);
36 |
37 | this.element.onclick =(event: MouseEvent) => {
38 | let width = this.element.offsetWidth;
39 | let x = event.pageX;
40 | let index = Math.floor(this.pagination.pageLimit * x / width);
41 | this.pagination.setPage(index);
42 | };
43 | }
44 | };
45 |
46 | export default Bar;
--------------------------------------------------------------------------------
/src/common/bind/bind.ts:
--------------------------------------------------------------------------------
1 | class Bind {
2 | cbMap: any = {};
3 | objIndex: number = 0;
4 | objMap: any = {};
5 |
6 | constructor() {
7 | if (window.Bind) {
8 | throw Error('bind has been inited');
9 | }
10 | window.Bind = this;
11 | }
12 |
13 | private handleObj(obj: any, prop: string) {
14 | if (!obj.hasOwnProperty('_bindId')) {
15 | obj._bindId = this.objIndex++;
16 | }
17 | if (this.cbMap[obj._bindId + prop]) {
18 | return;
19 | }
20 | let index = '_' + prop;
21 | obj[index] = obj[prop];
22 | this.cbMap[obj._bindId + prop] = [];
23 | Object.defineProperty(obj, prop, {
24 | get: () => {
25 | return obj[index];
26 | },
27 | set: (value: any) => {
28 | let temp = obj[index];
29 | obj[index] = value;
30 | this.run(obj, prop, value, temp);
31 | }
32 | })
33 | }
34 |
35 | bindInput(element: HTMLInputElement, obj: any, prop: string) {
36 | if (!element) {
37 | throw new Error('element is null');
38 | }
39 | this.bind(obj, prop, (newV: any, oldV: any) => {
40 | element.value = newV;
41 | }, true);
42 | element.onchange = (event: InputEvent) => {
43 | obj[prop] = (event.target as HTMLInputElement).value;
44 | };
45 | }
46 |
47 | bindStyle(element: HTMLElement, obj: any, prop: string, target: any, handle?: Function) {
48 | if (!element) {
49 | throw new Error('element is null');
50 | }
51 | this.bind(obj, prop, (newV: any, oldV: any) => {
52 | element.style[target] = handle?handle(newV, oldV):newV;
53 | }, true);
54 | }
55 |
56 | bindView(element: HTMLElement, obj: any, prop: string, formatter?: Function) {
57 | if (!element) {
58 | throw new Error('element is null');
59 | }
60 | this.bind(obj, prop, (newV: any, oldV: any) => {
61 | element.innerHTML = formatter?formatter(newV, oldV):newV;
62 | }, true);
63 | }
64 |
65 | bind(obj: any, prop: string, callback: Function, immediately?: boolean) {
66 | this.handleObj(obj, prop);
67 | this.cbMap[obj._bindId + prop].push(callback);
68 | immediately && callback(obj[prop], undefined);
69 | }
70 |
71 | run(obj: any, prop: string, newV?: any, oldV?: any) {
72 | this.cbMap[obj._bindId + prop].forEach((callback: Function) => {
73 | try {
74 | callback(newV, oldV);
75 | } catch (error) {
76 | throw error;
77 | }
78 | });
79 | }
80 | };
81 |
82 | export default Bind;
--------------------------------------------------------------------------------
/src/common/common.ts:
--------------------------------------------------------------------------------
1 | function strToDom(str: string): HTMLCollection {
2 | let div = document.createElement('div');
3 | div.innerHTML = str;
4 | return div.children;
5 | }
6 |
7 | function makeDisplayText(time: number): string {
8 | let text = '测试文本';
9 |
10 | let result = new Array(time + 1).join(text);
11 |
12 | return result;
13 | }
14 |
15 | function getSpecialParent(ele: HTMLElement,checkFun: Function): HTMLElement | null {
16 | if (ele && ele !== document as unknown && checkFun(ele)) {
17 | return ele;
18 | }
19 | let parent = ele.parentElement || ele.parentNode;
20 | return parent?getSpecialParent(parent as HTMLElement, checkFun):null;
21 | }
22 |
23 | function getObject(source: any, keys: string[], others?: {[key: string]: any}): any {
24 | let obj: any = {};
25 | keys.forEach(key => {
26 | obj[key] = source[key];
27 | });
28 | others && Object.keys(others).forEach(key => {
29 | obj[key] = others[key];
30 | });
31 | return obj;
32 | }
33 |
34 | function changeValueWithNewObj(obj: any, target: {[key: string]: any}): any {
35 | let result = JSON.parse(JSON.stringify(obj));
36 | Object.keys(target).forEach(v => {
37 | result[v] = target[v];
38 | });
39 | return result;
40 | }
41 |
42 | interface Book {
43 | id: string;
44 | source: string;
45 | name: string;
46 | author: string;
47 | bookUrl: string;
48 | coverUrl: string;
49 | customCoverUrl: string;
50 | durChapterTitle: string;
51 | latestChapterTime: string;
52 | latestChapterTitle: string;
53 | }
54 |
55 | interface CatalogueItem {
56 | index: number;
57 | title: string;
58 | }
59 |
60 | interface Progress {
61 | index: number;
62 | pos: number;
63 | time: number;
64 | title: string;
65 | }
66 |
67 |
68 | export { strToDom, makeDisplayText, getSpecialParent, getObject, changeValueWithNewObj, Book, CatalogueItem, Progress };
--------------------------------------------------------------------------------
/src/common/debugger/debugger.ts:
--------------------------------------------------------------------------------
1 | class Debugger {
2 | constructor() {
3 | window.onerror = function (error) {
4 | console.error(error);
5 |
6 | window.Modal && window.Modal.add({
7 | content: error.toString()
8 | });
9 | }
10 | }
11 | };
12 |
13 | export default Debugger;
--------------------------------------------------------------------------------
/src/common/layout/layout.ts:
--------------------------------------------------------------------------------
1 | interface LayoutInterface {
2 | fontSize: number;
3 | lineHeight: number;
4 | };
5 |
6 | class Layout {
7 |
8 | fontSize: number;
9 |
10 | lineHeight: number;
11 |
12 | limit: LayoutInterface = {
13 | fontSize: 20,
14 | lineHeight: 24
15 | };
16 | base: LayoutInterface = {
17 | fontSize: 30,
18 | lineHeight: 40
19 | };
20 |
21 | constructor() {
22 | if (window.Layout) {
23 | throw Error('layout has been inited');
24 | }
25 | window.Layout = this;
26 |
27 | this.fontSize = parseInt(window.Store.get('fontSize') || this.base.fontSize.toString());
28 | this.lineHeight = parseInt(window.Store.get('lineHeight') || this.base.lineHeight.toString());
29 | }
30 |
31 | set(target: 'fontSize' | 'lineHeight', value?: number): void {
32 | this[target] = value || this.base[target];
33 | window.Store.set(target, this[target].toString());
34 | }
35 |
36 | add(target: 'fontSize' | 'lineHeight', num: number): void {
37 | let current = this[target];
38 | current += num;
39 |
40 | if (current < this.limit[target]) {
41 | current = this.limit[target];
42 | }
43 |
44 | this.set(target, current);
45 | }
46 |
47 | reset(target?: 'fontSize' | 'lineHeight'): void {
48 | if (target) {
49 | this.set(target);
50 | return;
51 | }
52 | this.set('fontSize');
53 | this.set('lineHeight');
54 | }
55 | };
56 |
57 | export default Layout;
--------------------------------------------------------------------------------
/src/common/message/message.less:
--------------------------------------------------------------------------------
1 | .message {
2 | text-align: center;
3 | padding: 5px;
4 | .message-content {
5 | display: inline-block;
6 | height: 60px;
7 | background-color: #666;
8 | font-size: 50px;
9 | line-height: 60px;
10 | color: #fff;
11 | border-radius: 8px;
12 | }
13 | }
--------------------------------------------------------------------------------
/src/common/message/message.ts:
--------------------------------------------------------------------------------
1 | import { strToDom } from '../common';
2 |
3 | interface MessageOption {
4 | content: string;
5 | onOk?: Function;
6 | onCancle?: Function;
7 | banAutoRemove?: boolean;
8 | };
9 |
10 | class MessageItem {
11 | body: Element;
12 | constructor(option: MessageOption) {
13 | let str = `
14 |
18 | `;
19 | let message: Element = strToDom(str)[0];
20 | this.body = message;
21 | let content: HTMLDivElement = message.querySelector('.message-content');
22 | content.innerHTML = option.content;
23 |
24 | content.onclick = () => {
25 | option.onOk && option.onOk();
26 | this.remove();
27 | };
28 |
29 | if (option.banAutoRemove) {
30 | return;
31 | }
32 |
33 | window.setTimeout(() => {
34 | option.onCancle && option.onCancle();
35 | this.remove();
36 | }, 2000);
37 | }
38 |
39 | remove() {
40 | let parent = this.body.parentElement;
41 | parent && parent.removeChild(this.body);
42 | }
43 | };
44 |
45 | class Message {
46 | element: HTMLElement;
47 | list: any[];
48 | constructor() {
49 | if (window.Message) {
50 | throw Error('modal has been inited');
51 | }
52 | this.list = [];
53 | window.Message = this;
54 | this.element = document.querySelector('.message-box');
55 | }
56 |
57 | add(option: MessageOption) {
58 | let item = new MessageItem(option);
59 | this.list.push(item);
60 | this.element.appendChild(item.body);
61 | return item;
62 | }
63 |
64 | remove(item: MessageItem): void {
65 | item.remove();
66 | let index = this.list.indexOf(item);
67 | this.list.splice(index, 1);
68 | }
69 |
70 | clear(): void {
71 | this.list = [];
72 | this.element.innerHTML = '';
73 | }
74 | };
75 |
76 | export default Message;
--------------------------------------------------------------------------------
/src/common/modal/modal.less:
--------------------------------------------------------------------------------
1 | .modal {
2 | position: absolute;
3 | min-width: 600px;
4 | min-height: 600px;
5 | left: 50px;
6 | right: 50px;
7 | top: 40%;
8 | // margin-left: -300px;
9 | margin-top: -400px;
10 | border: 1px solid #ccc;
11 | background-color: #fff;
12 |
13 | .modal-content {
14 | min-height: 500px;
15 | padding: 10px;
16 | word-break: break-all;
17 | font-size: 20px;
18 | line-height: 40px;
19 |
20 | * {
21 | word-break: break-all;
22 | }
23 | }
24 | .modal-footer {
25 | height: 100px;
26 | text-align: center;
27 | border-top: 1px solid #ccc;
28 | line-height: 100px;
29 |
30 | .button {
31 | height: 90px;
32 | margin: 0 20px;
33 | vertical-align: middle;
34 | line-height: 90px;
35 | }
36 | }
37 | }
--------------------------------------------------------------------------------
/src/common/modal/modal.ts:
--------------------------------------------------------------------------------
1 | import { strToDom } from '../common';
2 |
3 |
4 | interface ModalOption {
5 | content: string | HTMLElement;
6 | onOk?: Function;
7 | onCancel?: Function;
8 | zIndex?: number;
9 | };
10 |
11 | class ModalItem {
12 | body: Element;
13 | zIndex: number;
14 | constructor(option: ModalOption) {
15 | this.zIndex = option.zIndex;
16 | let str = `
17 |
25 | `;
26 | let modal: Element = strToDom(str)[0];
27 | this.body = modal;
28 | let content: HTMLDivElement = modal.querySelector('.modal-content');
29 | let btnConfirm: HTMLButtonElement = modal.querySelector('.modal-confirm');
30 | let btnCancel: HTMLButtonElement = modal.querySelector('.modal-cancel');
31 | if (typeof option.content === 'string') {
32 | content.innerHTML = option.content;
33 | } else {
34 | content.appendChild(option.content);
35 | }
36 | btnCancel.onclick = () => {
37 | option.onCancel && option.onCancel();
38 | this.remove();
39 | };
40 |
41 | btnConfirm.onclick = () => {
42 | option.onOk && option.onOk();
43 | this.remove();
44 | };
45 | }
46 |
47 | remove() {
48 | let parent = this.body.parentElement;
49 | parent.removeChild(this.body);
50 | }
51 | };
52 |
53 |
54 | class Modal {
55 | element: HTMLElement;
56 | list: ModalItem[] = [];
57 | constructor() {
58 | if (window.Modal) {
59 | throw Error('modal has been inited');
60 | }
61 | window.Modal = this;
62 | this.element = document.querySelector('.modal-box');
63 | }
64 |
65 | add(option: ModalOption): ModalItem {
66 | if (!('zIndex' in option)) {
67 | let length = this.list.length;
68 | option.zIndex = (length?this.list[length - 1].zIndex:100) + 1;
69 | }
70 | let item = new ModalItem(option);
71 | this.list.push(item);
72 | this.element.appendChild(item.body);
73 | return item;
74 | }
75 |
76 | remove(item: ModalItem): void {
77 | item.remove();
78 | let index = this.list.indexOf(item);
79 | this.list.splice(index, 1);
80 | }
81 |
82 | clear(): void {
83 | this.list = [];
84 | this.element.innerHTML = '';
85 | }
86 | }
87 |
88 | export default Modal;
--------------------------------------------------------------------------------
/src/common/pagination/pagination.less:
--------------------------------------------------------------------------------
1 | .pagination-box {
2 | height: 100%;
3 | overflow: hidden;
4 |
5 | .pagination-body {
6 | min-height: 100%;
7 | }
8 | }
--------------------------------------------------------------------------------
/src/common/pagination/pagination.ts:
--------------------------------------------------------------------------------
1 | class Pagination {
2 | root: HTMLElement;
3 | box: HTMLElement;
4 | padding: HTMLElement;
5 |
6 | pageStep: number;
7 |
8 | pageIndex: number = 0;
9 |
10 | pageLimit: number = 1;
11 |
12 | pagePadding: number = 0;
13 |
14 | fakePage: boolean = false;
15 |
16 | constructor(config: {
17 | root: HTMLElement,
18 | fake?: boolean
19 | pageChange?: Function
20 | }) {
21 | this.root = config.root;
22 | this.handleHtml(config.root);
23 |
24 | this.fakePage = config.fake || false;
25 |
26 | this.pageStep = this.box.offsetHeight;
27 |
28 | this.checkPage();
29 |
30 | window.Bind.bindStyle(this.padding, this, 'pagePadding', 'height', (v: any) => `${v}px`);
31 | window.Bind.bind(this, 'pageIndex', (value: number) => {
32 | if (this.fakePage) {
33 | config?.pageChange(value);
34 | return;
35 | }
36 | this.box.scrollTop = this.pageStep * value;
37 | });
38 | }
39 |
40 | private handleHtml(root: HTMLElement) {
41 | let inner = root.innerHTML;
42 | root.innerHTML = `
43 | `;
49 | let content: HTMLElement = root.querySelector('.pagination-content');
50 | content.innerHTML = inner;
51 | this.box = root.querySelector('.pagination-box');
52 | this.padding = root.querySelector('.pagination-padding');
53 | }
54 |
55 | checkPage(limit?: number): void {
56 | this.pageStep = this.box.offsetHeight;
57 | if (this.fakePage) {
58 | this.pageLimit = limit || 1;
59 | this.pagePadding = 0;
60 | return;
61 | }
62 | this.pageLimit = Math.ceil(this.box.scrollHeight / this.pageStep) || 1;
63 | this.pagePadding = this.pageStep * this.pageLimit - this.box.scrollHeight;
64 | }
65 |
66 | setPage(num: number) {
67 | let target = num;
68 | if (num < 0) {
69 | target = 0;
70 | }
71 | if (num >= this.pageLimit) {
72 | target = this.pageLimit - 1;
73 | }
74 | this.pageIndex = target;
75 | }
76 |
77 | pageChange(add: number) {
78 | this.setPage(this.pageIndex + add);
79 | }
80 | };
81 |
82 | export default Pagination;
--------------------------------------------------------------------------------
/src/common/router/router.ts:
--------------------------------------------------------------------------------
1 | class Router {
2 |
3 | current: string;
4 |
5 | pages: string[] = [];
6 |
7 | cbMap: {[key: string]: Function} = {};
8 |
9 | constructor(pages: string[]) {
10 | if (window.Router) {
11 | throw Error('router has been inited');
12 | }
13 | window.Router = this;
14 |
15 | this.pages = pages;
16 |
17 | let func = (event?: HashChangeEvent) => {
18 | let hash = window.location.hash;
19 | let index = hash.lastIndexOf('#');
20 | if (index > -1) {
21 | hash = hash.slice(index + 1);
22 | }
23 | if (this.pages.length === 0) {
24 | return;
25 | }
26 | if (this.pages.indexOf(hash) === -1) {
27 | window.location.hash = this.pages[0];
28 | return;
29 | }
30 |
31 | this.switchPage(hash);
32 | };
33 | window.onhashchange = func;
34 | func();
35 | }
36 |
37 | private switchPage(str: string) {
38 | document.querySelector('.show')?.classList.remove('show');
39 | document.querySelector(`.${str}`)?.classList.add('show');
40 | this.current = str;
41 | this.cbMap[str] && this.cbMap[str]();
42 | }
43 |
44 | go(target: string): void {
45 | window.location.hash = target;
46 | }
47 | }
48 |
49 | export default Router;
--------------------------------------------------------------------------------
/src/common/store/store.ts:
--------------------------------------------------------------------------------
1 | // import * as LzString from 'lz-string';
2 | import { compress, decompress } from 'lz-string';
3 | import { Book } from '../common';
4 |
5 | // prefix map
6 | // a article
7 | // b book
8 | // c catalogue
9 | // p progress
10 |
11 | class Store {
12 | data: any;
13 |
14 | limitChecking: boolean = false;
15 | limit: number = 0;
16 |
17 | usage: number = 0;
18 |
19 | percent: number = 0;
20 |
21 | compress: Function = compress;
22 | decompress: Function = decompress;
23 |
24 | checkFlag: number;
25 |
26 | constructor() {
27 | if (window.Store) {
28 | throw Error('store has been inited');
29 | }
30 | window.Store = this;
31 | this.limit = parseInt(this.get('limit') || '0');
32 |
33 | this.checkUsage();
34 | if (this.limit === 0) {
35 | // this.checkLimit();
36 | window.Message.add({content: '缓存未初始化请手动检测'});
37 | }
38 | }
39 |
40 | bookDelete(book: Book, onlySource?: boolean): void {
41 | if (!onlySource) {
42 | this.del(`p_${book.id}`);
43 | }
44 | this.del(`c_${book.id}`);
45 | this.getByHead(`a_${book.id}`).forEach(v => this.del(v));
46 | }
47 |
48 | del(key: string): void {
49 | localStorage.removeItem(key);
50 | this.checkUsage();
51 | }
52 |
53 | has(key: string): boolean {
54 | return localStorage.hasOwnProperty(key);
55 | }
56 |
57 | getObj(key: string): any | null {
58 | return JSON.parse(this.get(key));
59 | }
60 |
61 | setObj(key: string, value: any, cb?: {success?: Function, fail?: Function}): void {
62 | this.set(key, JSON.stringify(value), cb);
63 | }
64 |
65 | set(key: string, value: string, cb?: {success?: Function, fail?: Function}): void {
66 | try {
67 | // let ckey = compress(key);
68 | let cvalue = compress(value);
69 | // localStorage.setItem(ckey, cvalue);
70 | localStorage.setItem(key, cvalue);
71 | this.checkUsage();
72 | cb && cb.success && cb.success();
73 | } catch(e) {
74 | window.Message.add({content: '缓存失败,空间不足'});
75 | cb && cb.fail && cb.fail();
76 | }
77 | }
78 |
79 | get(key: string): string | null {
80 | // let store = localStorage.getItem(compress(key));
81 | let store = localStorage.getItem(key);
82 | if (store) {
83 | return decompress(store);
84 | }
85 | return null;
86 | }
87 |
88 | getByHead(head: string): string[] {
89 | return Object.keys(localStorage).filter(v => v.indexOf(head) === 0);
90 | }
91 |
92 | checkUsage(): void {
93 | if (this.checkFlag) {
94 | window.clearTimeout(this.checkFlag);
95 | }
96 | this.checkFlag = window.setTimeout(() => {
97 | this.usage = Object.keys(localStorage).map(v => v + localStorage.getItem(v)).join('').length;
98 | this.percent = this.limit?Math.round(this.usage / (this.limit) * 100):0;
99 | if (this.percent > 95) {
100 | window.Message.add({
101 | content: `缓存已使用${this.percent}%,请注意`
102 | });
103 | }
104 | }, 500);
105 | }
106 |
107 | checkLimit(): void {
108 | window.Message.add({content: '正在检测缓存容量'});
109 | if (this.limitChecking) {
110 | return;
111 | }
112 | this.limitChecking = true;
113 |
114 | window.setTimeout(() => {
115 |
116 | let base = this.usage;
117 | let addLength = 1000000;
118 | let index = 0;
119 |
120 | while (addLength > 2) {
121 | try {
122 | let key = `_test${index++}`;
123 | if (addLength < key.length) {break;}
124 | localStorage.setItem(key, new Array(addLength - key.length + 1).join('a'));
125 | base += addLength;
126 | } catch(e) {
127 | console.log(e);
128 | index--;
129 | addLength = Math.round(addLength / 2);
130 | }
131 | }
132 | this.limit = base;
133 |
134 | this.getByHead('_test').forEach(v => {
135 | this.del(v)
136 | });
137 |
138 | this.set('limit', this.limit.toString());
139 |
140 | this.limitChecking = false;
141 |
142 | window.Message.add({content: '检测完成'});
143 | }, 1000);
144 | }
145 | };
146 |
147 | export default Store;
--------------------------------------------------------------------------------
/src/config/config.ts:
--------------------------------------------------------------------------------
1 | import {makeDisplayText} from '../common/common';
2 |
3 | class Config {
4 | element: HTMLElement;
5 |
6 | displayText: string;
7 |
8 | url: string;
9 |
10 | constructor() {
11 | this.element = document.querySelector('.page.config');
12 |
13 | this.url = window.Api.url;
14 |
15 | this.displayText = makeDisplayText(200);
16 |
17 | window.Bind.bindInput(this.element.querySelector('.url input'), this, 'url');
18 |
19 | window.Bind.bindView(this.element.querySelector('.store-usage'), window.Store, 'usage');
20 | window.Bind.bindView(this.element.querySelector('.store-total'), window.Store, 'limit');
21 | window.Bind.bindView(this.element.querySelector('.store-percent'), window.Store, 'percent', (v: number) => ` ( ${v}% )`);
22 |
23 | window.Bind.bindView(this.element.querySelector('.font-size'), window.Layout, 'fontSize');
24 | window.Bind.bindView(this.element.querySelector('.line-height'), window.Layout, 'lineHeight');
25 |
26 | let display: HTMLElement = this.element.querySelector('.display .text p');
27 | window.Bind.bindView(display, this, 'displayText');
28 | window.Bind.bindStyle(display, window.Layout, 'fontSize', 'fontSize', (v: any) => `${v}px`);
29 | window.Bind.bindStyle(display, window.Layout, 'lineHeight', 'lineHeight', (v: any) => `${v}px`);
30 |
31 | if (!this.url) {
32 | window.Message.add({content: '当前未配置服务器地址'});
33 | } else {
34 | this.checkUrl();
35 | }
36 | }
37 |
38 |
39 | checkUrl() {
40 | window.Api.checkUrl(this.url);
41 | }
42 | };
43 |
44 | export default Config;
--------------------------------------------------------------------------------
/src/global.d.ts:
--------------------------------------------------------------------------------
1 | import Bind from "./common/bind/bind";
2 | import Message from "./common/message/message";
3 | import Modal from "./common/modal/modal";
4 | import Router from "./common/router/router";
5 | import Store from "./common/store/store";
6 | import Config from "./config/config";
7 | import Layout from "./common/layout/layout";
8 | import Api from './common/api/api';
9 | import BookShelf from "./bookshelf/bookshelf";
10 | import Article from './article/article';
11 | import Catalogue from './catalogue/catalogue';
12 |
13 | declare global {
14 | interface Window {
15 | init?: Function;
16 |
17 | Bind?: Bind;
18 | Modal?: Modal;
19 | Message?: Message;
20 | Router?: Router;
21 | Store?: Store;
22 | Layout?: Layout;
23 | Api?: Api;
24 |
25 | Config?: Config;
26 | BookShelf?: BookShelf;
27 | Article?: Article;
28 | Catalogue?: Catalogue;
29 |
30 | }
31 | }
32 |
33 | export { };
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
12 | kabi novel
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
37 |
38 |
41 |
42 |
43 |
44 |
45 |
书架
46 |
目录
47 |
刷新
48 |
49 |
53 |
54 |
55 |
56 |
59 |
60 |
61 |
62 |
63 |
书架
64 |
文章
65 |
刷新
66 |
缓存
67 |
68 |
72 |
73 |
74 |
75 |
76 |
83 |
93 |
103 |
113 |
119 |
120 |
121 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import BookShelf from './bookshelf/bookshelf';
2 | import Config from './config/config';
3 | import Router from './common/router/router';
4 | import Debugger from './common/debugger/debugger';
5 | import Modal from './common/modal/modal';
6 | import Message from './common/message/message';
7 | import Store from './common/store/store';
8 | import Bind from './common/bind/bind';
9 | import Layout from './common/layout/layout';
10 | import Api from './common/api/api';
11 | import Article from './article/article';
12 | import Catalogue from './catalogue/catalogue';
13 |
14 | const pages: string[] = ['config', 'bookshelf', 'article', 'catalogue'];
15 |
16 | function init() {
17 | new Debugger();
18 |
19 | new Bind();
20 |
21 | new Modal();
22 | new Message();
23 |
24 | new Router(pages);
25 |
26 | new Store();
27 |
28 | new Layout();
29 |
30 | new Api();
31 |
32 | document.querySelector('.global-style').innerHTML = `
33 |
38 | `;
39 |
40 | window.Config = new Config();
41 |
42 | window.BookShelf = new BookShelf();
43 |
44 | window.Catalogue = new Catalogue();
45 |
46 | window.Article = new Article();
47 |
48 | }
49 |
50 | window.init = init;
51 |
52 |
53 |
54 | window.ondblclick = function(event: Event) {
55 | event.preventDefault();
56 | }
--------------------------------------------------------------------------------
/src/style.less:
--------------------------------------------------------------------------------
1 | html, body {
2 | width: 100%;
3 | height: 100%;
4 | overflow: hidden;
5 | margin: 0;
6 | padding: 0;
7 | background-color: #fff;
8 | position: relative;
9 | font-size: 20px;
10 | line-height: 24px;
11 |
12 | * {
13 | box-sizing: border-box;
14 | }
15 |
16 | .button {
17 | min-width: 150px;
18 | padding: 0 10px;
19 | font-size: 60px;
20 | display: inline-block;
21 | border: 1px solid #333;
22 | text-align: center;
23 | justify-content: center;
24 | }
25 | }
26 |
27 | .page {
28 | width: 100%;
29 | height: 100%;
30 | display: none;
31 | position: relative;
32 |
33 | @scrollHack: 40px;
34 | @barHeight: 50px;
35 | @controlHeight: 140px + @scrollHack;
36 |
37 | &.show {
38 | display: block;
39 | }
40 |
41 | .content {
42 | // height: 100%;
43 | // padding-bottom: @controlHeight + @barHeight;
44 | // padding-top: @scrollHack;
45 | padding: 10px;
46 | padding-right: 15px;
47 |
48 | @import url(common/pagination/pagination.less);
49 | }
50 |
51 | .current-info {
52 | height: 20px;
53 | padding: 0 20px;
54 | line-height: 20px;
55 | }
56 |
57 | @import url(common/bar/bar.less);
58 |
59 |
60 | .control {
61 | height: @controlHeight;
62 | background-color: #eee;
63 | position: absolute;
64 | bottom: 0;
65 | width: 100%;
66 | padding-top: 20px;
67 |
68 | .left-box {
69 | float: left;
70 | }
71 |
72 | .right-box {
73 | float: right;
74 | }
75 |
76 | .button {
77 | line-height: @controlHeight - 20px;
78 | height: 100%;
79 | margin: 0 10px;
80 | }
81 | }
82 | }
83 |
84 | .article {
85 | .content {
86 | .content-inner {
87 | >p {
88 | margin: 0;
89 | text-indent: 2em;
90 | }
91 | }
92 | }
93 | }
94 |
95 | .catalogue {
96 | .article-list {
97 | font-size: 0;
98 | .article-item {
99 | display: inline-block;
100 | width: 50%;
101 | font-size: 40px;
102 | // line-height: 80px;
103 | white-space: nowrap;
104 | text-overflow: ellipsis;
105 | overflow: hidden;
106 | vertical-align: middle;
107 | color: #999;
108 | padding: 0 10px;
109 |
110 | &.current {
111 | color: #fff !important;
112 | background-color: #000;
113 | }
114 |
115 | &.cached {
116 | color: #000;
117 | font-weight: bold;
118 | }
119 | }
120 | }
121 | }
122 |
123 | .bookshelf {
124 |
125 | .book-list {
126 | font-size: 0;
127 | .book-item {
128 | display: inline-block;
129 | width: 50%;
130 | padding: 10px;
131 | min-height: 200px;
132 | vertical-align: middle;
133 | font-size: 24px;
134 | line-height: 40px;
135 | margin: 0;
136 |
137 | .book-cover {
138 | display: inline-block;
139 | height: 100%;
140 | border: 1px solid #ccc;
141 | background-size: 100%;
142 | background-position: center;
143 | vertical-align: middle;
144 |
145 | img {
146 | width: 100%;
147 | height: 100%;
148 | }
149 | }
150 |
151 | .book-info {
152 | display: inline-block;
153 | height: 100%;
154 | padding: 10px;
155 | vertical-align: middle;
156 |
157 | >div {
158 | overflow: hidden;
159 | text-overflow: ellipsis;
160 | white-space: nowrap;
161 |
162 | &:first-child {
163 | font-size: 32px;
164 | font-weight: bold;
165 | }
166 | }
167 | }
168 | }
169 | }
170 | }
171 |
172 | .config {
173 | .content {
174 | padding: 20px 40px;
175 | font-size: 30px;
176 | line-height: 40px;
177 | height: 100%;
178 |
179 | .button {
180 | margin-left: 10px;
181 | }
182 |
183 | .right {
184 | float: right;
185 | }
186 | }
187 | .form-item {
188 | padding: 10px 0;
189 |
190 | .label {
191 |
192 |
193 | &::after {
194 | content: ":";
195 | }
196 | }
197 |
198 | .text {
199 | line-height: 60px;
200 |
201 | input {
202 | font-size: 50px;
203 | line-height: 60px;
204 |
205 | width: 70%;
206 | }
207 | }
208 |
209 | &.display {
210 | .text {
211 | height: 500px;
212 | border: 1px solid #333;
213 | padding: 10px;
214 | line-height: 20px;
215 | font-size: 16px;
216 | overflow: hidden;
217 |
218 | p {
219 | margin: 0;
220 | text-indent: 2em;
221 | }
222 | }
223 | }
224 |
225 | }
226 | }
227 |
228 | .modal-box {
229 | @import url(common/modal/modal.less);
230 | }
231 |
232 | .message-box {
233 | position: fixed;
234 | top: 0;
235 | left: 0;
236 | right: 0;
237 | @import url(common/message/message.less);
238 | }
--------------------------------------------------------------------------------
/temp/style.css:
--------------------------------------------------------------------------------
1 | .page,body,html{position:relative;height:100%;width:100%}body,html{overflow:hidden;margin:0;padding:0;background-color:#fff;font-size:20px;line-height:24px}body *,html *{box-sizing:border-box}body .button,html .button{min-width:150px;padding:0 10px;font-size:60px;display:inline-block;border:1px solid #333;text-align:center;justify-content:center}.page{display:none}.page.show{display:block}.page .content{padding:10px 15px 10px 10px}.page .content .pagination-box{height:100%;overflow:hidden}.page .content .pagination-box .pagination-body{min-height:100%}.page .current-info{height:20px;padding:0 20px;line-height:20px}.page .bar{height:50px;position:absolute;bottom:180px;width:100%;background-color:#666;text-align:center}.page .bar .bar-progress{display:inline-block;height:100%;background-color:#000;float:left}.page .bar .bar-text{position:absolute;width:100%;height:100%;line-height:50px;color:#fff}.page .control{height:180px;background-color:#eee;position:absolute;bottom:0;width:100%;padding-top:20px}.page .control .left-box{float:left}.config .content .right,.page .control .right-box{float:right}.page .control .button{line-height:160px;height:100%;margin:0 10px}.article .content .content-inner>p{margin:0;text-indent:2em}.catalogue .article-list{font-size:0}.catalogue .article-list .article-item{display:inline-block;width:50%;font-size:40px;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;vertical-align:middle;color:#999;padding:0 10px}.catalogue .article-list .article-item.current{color:#fff!important;background-color:#000}.catalogue .article-list .article-item.cached{color:#000;font-weight:700}.bookshelf .book-list{font-size:0}.bookshelf .book-list .book-item{display:inline-block;width:50%;padding:10px;min-height:200px;vertical-align:middle;font-size:24px;line-height:40px;margin:0}.bookshelf .book-list .book-item .book-cover{display:inline-block;height:100%;border:1px solid #ccc;background-size:100%;background-position:center;vertical-align:middle}.bookshelf .book-list .book-item .book-cover img{width:100%;height:100%}.bookshelf .book-list .book-item .book-info{display:inline-block;height:100%;padding:10px;vertical-align:middle}.bookshelf .book-list .book-item .book-info>div{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bookshelf .book-list .book-item .book-info>div:first-child{font-size:32px;font-weight:700}.config .content{padding:20px 40px;font-size:30px;line-height:40px;height:100%}.config .content .button{margin-left:10px}.config .form-item{padding:10px 0}.config .form-item .label::after{content:":"}.config .form-item .text{line-height:60px}.config .form-item .text input{font-size:50px;line-height:60px;width:70%}.config .form-item.display .text{height:500px;border:1px solid #333;padding:10px;line-height:20px;font-size:16px;overflow:hidden}.config .form-item.display .text p{margin:0;text-indent:2em}.modal-box .modal{position:absolute;min-width:600px;min-height:600px;left:50px;right:50px;top:40%;margin-top:-400px;border:1px solid #ccc;background-color:#fff}.modal-box .modal .modal-content{min-height:500px;padding:10px;word-break:break-all;font-size:20px;line-height:40px}.modal-box .modal .modal-content *{word-break:break-all}.modal-box .modal .modal-footer{height:100px;text-align:center;border-top:1px solid #ccc;line-height:100px}.modal-box .modal .modal-footer .button{height:90px;margin:0 20px;vertical-align:middle;line-height:90px}.message-box{position:fixed;top:0;left:0;right:0}.message-box .message{text-align:center;padding:5px}.message-box .message .message-content{display:inline-block;height:60px;background-color:#666;font-size:50px;line-height:60px;color:#fff;border-radius:8px}
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [
3 | "src/main.ts"
4 | ],
5 | "compilerOptions": {
6 | "noImplicitAny": true,
7 | "target": "es5"
8 | },
9 | "include": [
10 | "src/*.d.ts"
11 | ]
12 | }
--------------------------------------------------------------------------------