reader-shell 1.0.5 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -4
- package/cli.js +6 -1
- package/package.json +14 -6
- package/src/config.js +7 -3
- package/src/epubReader.js +51 -25
- package/src/txtReader.js +4 -3
- package/src/ui.js +239 -144
- package/.claude/settings.local.json +0 -14
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# 终端电子书阅读器
|
|
2
2
|
|
|
3
|
-
一个基于 Node.js
|
|
3
|
+
一个基于 Node.js 和 Ink 的全屏终端电子书阅读器,支持导入 epub/txt 文件、记住阅读进度、快捷键翻页。
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
7
|
## 功能特性
|
|
8
|
-
- 支持导入 epub 电子书
|
|
8
|
+
- 支持导入 epub、txt 电子书
|
|
9
|
+
- Codex CLI 风格的全屏阅读界面
|
|
10
|
+
- 根据终端尺寸自动调整正文宽度和页高
|
|
9
11
|
- 记住每本书的阅读进度(包括章节和章节内滚动位置)
|
|
10
12
|
- 支持快捷键翻页、章节内滚动
|
|
11
13
|
- 支持老板键一键隐藏内容
|
|
@@ -13,6 +15,8 @@
|
|
|
13
15
|
|
|
14
16
|
## 安装依赖
|
|
15
17
|
|
|
18
|
+
需要 Node.js 22.6 或更高版本。
|
|
19
|
+
|
|
16
20
|
```bash
|
|
17
21
|
npm install
|
|
18
22
|
```
|
|
@@ -33,6 +37,9 @@ node src/ui.js ./books/三体.epub
|
|
|
33
37
|
- `←` 或 `w`:上一章
|
|
34
38
|
- `↑` 或 `s`:章节内向上滚动
|
|
35
39
|
- `↓` 或 `d`:章节内向下滚动
|
|
40
|
+
- `PageUp` / `PageDown`:整页滚动
|
|
41
|
+
- `Home` / `End`:跳到章节开头/结尾
|
|
42
|
+
- `Space`:向下翻页
|
|
36
43
|
- `a`:老板键(快速隐藏/恢复内容)
|
|
37
44
|
- `q` 或 `Ctrl+C`:退出阅读器
|
|
38
45
|
|
|
@@ -46,8 +53,8 @@ node src/ui.js ./books/三体.epub
|
|
|
46
53
|
|
|
47
54
|
## 依赖说明
|
|
48
55
|
- [epub](https://www.npmjs.com/package/epub) 电子书解析
|
|
49
|
-
- [
|
|
50
|
-
- [
|
|
56
|
+
- [Ink](https://www.npmjs.com/package/ink) 终端 UI
|
|
57
|
+
- [React](https://www.npmjs.com/package/react) 组件与状态管理
|
|
51
58
|
|
|
52
59
|
## 许可协议
|
|
53
60
|
MIT
|
package/cli.js
CHANGED
|
@@ -6,4 +6,9 @@ if (!filePath) {
|
|
|
6
6
|
console.error('用法: reader-shell <文件路径>');
|
|
7
7
|
process.exit(1);
|
|
8
8
|
}
|
|
9
|
-
|
|
9
|
+
try {
|
|
10
|
+
await startReader(path.resolve(filePath));
|
|
11
|
+
} catch (error) {
|
|
12
|
+
console.error(error instanceof Error ? error.message : error);
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reader-shell",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"main": "cli.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -10,13 +10,21 @@
|
|
|
10
10
|
"author": "",
|
|
11
11
|
"license": "ISC",
|
|
12
12
|
"description": "",
|
|
13
|
+
"files": [
|
|
14
|
+
"cli.js",
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.6.0"
|
|
19
|
+
},
|
|
13
20
|
"dependencies": {
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
21
|
+
"epub": "^2.1.1",
|
|
22
|
+
"iconv-lite": "^0.7.0",
|
|
23
|
+
"ink": "^7.1.1",
|
|
24
|
+
"react": "^19.3.0",
|
|
25
|
+
"wrap-ansi": "^10.0.2"
|
|
18
26
|
},
|
|
19
27
|
"bin": {
|
|
20
|
-
"reader-shell": "
|
|
28
|
+
"reader-shell": "cli.js"
|
|
21
29
|
}
|
|
22
30
|
}
|
package/src/config.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export const config = {
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
readingWidth: 88,
|
|
3
|
+
colors: {
|
|
4
|
+
accent: "cyan",
|
|
5
|
+
border: "gray",
|
|
6
|
+
text: "white",
|
|
7
|
+
},
|
|
4
8
|
bossContent: ` @ multi (webpack)-dev-server/client?http://localhost:8081/sockjs-node (webpack)/hot/dev-server.js ./src/main.js
|
|
5
9
|
|
|
6
10
|
warning in ./src/pages/reader/index.vue?vue&type=template&id=6430da5c&scoped=true&
|
|
@@ -15,4 +19,4 @@ export const config = {
|
|
|
15
19
|
@ multi (webpack)-dev-server/client?http://localhost:8081/sockjs-node (webpack)/hot/dev-server.js ./src/main.js
|
|
16
20
|
|
|
17
21
|
`
|
|
18
|
-
};
|
|
22
|
+
};
|
package/src/epubReader.js
CHANGED
|
@@ -1,35 +1,61 @@
|
|
|
1
1
|
import EPub from 'epub';
|
|
2
2
|
|
|
3
|
+
function decodeHtmlEntities(text) {
|
|
4
|
+
const entities = {
|
|
5
|
+
amp: '&',
|
|
6
|
+
apos: "'",
|
|
7
|
+
gt: '>',
|
|
8
|
+
lt: '<',
|
|
9
|
+
nbsp: ' ',
|
|
10
|
+
quot: '"'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
return text.replace(/&(#(?:x[0-9a-f]+|\d+)|[a-z]+);/gi, (match, entity) => {
|
|
14
|
+
if (entity[0] === '#') {
|
|
15
|
+
const isHex = entity[1].toLowerCase() === 'x';
|
|
16
|
+
const value = Number.parseInt(entity.slice(isHex ? 2 : 1), isHex ? 16 : 10);
|
|
17
|
+
return Number.isNaN(value) || value > 0x10FFFF
|
|
18
|
+
? match
|
|
19
|
+
: String.fromCodePoint(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return entities[entity.toLowerCase()] ?? match;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function htmlToReadableText(html) {
|
|
27
|
+
const text = html
|
|
28
|
+
.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, '')
|
|
29
|
+
.replace(/<br\s*\/?\s*>/gi, '\n')
|
|
30
|
+
.replace(/<\/(?:p|div|section|article|blockquote|h[1-6]|li)>/gi, '\n\n')
|
|
31
|
+
.replace(/<li[^>]*>/gi, '• ')
|
|
32
|
+
.replace(/<[^>]+>/g, '');
|
|
33
|
+
|
|
34
|
+
return decodeHtmlEntities(text)
|
|
35
|
+
.replace(/\r\n?/g, '\n')
|
|
36
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
37
|
+
.replace(/\n[ \t]+/g, '\n')
|
|
38
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
39
|
+
.trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
3
42
|
/**
|
|
4
43
|
* 解析epub文件,返回章节文本数组
|
|
5
44
|
* @param {string} epubPath epub文件路径
|
|
6
45
|
* @returns {Promise<string[]>} 章节文本数组
|
|
7
46
|
*/
|
|
8
|
-
function parseEpub(epubPath) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
} else {
|
|
21
|
-
let text = data.toString('utf-8');
|
|
22
|
-
chapters[idx] = text.replace(/<[^>]+>/g, '').replace(/\r\n/g, '');
|
|
23
|
-
}
|
|
24
|
-
loaded++;
|
|
25
|
-
if (loaded === chapterIds.length) {
|
|
26
|
-
resolve(chapters);
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
epub.parse();
|
|
32
|
-
});
|
|
47
|
+
async function parseEpub(epubPath) {
|
|
48
|
+
const epub = new EPub(epubPath);
|
|
49
|
+
await epub.parse();
|
|
50
|
+
|
|
51
|
+
return Promise.all(epub.flow.map(async ({ id }) => {
|
|
52
|
+
try {
|
|
53
|
+
const text = await epub.getChapterRaw(id);
|
|
54
|
+
return htmlToReadableText(text);
|
|
55
|
+
} catch {
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
}));
|
|
33
59
|
}
|
|
34
60
|
|
|
35
61
|
export { parseEpub };
|
package/src/txtReader.js
CHANGED
|
@@ -150,8 +150,9 @@ function parseTxt(txtPath) {
|
|
|
150
150
|
chapters = chapters.map(chapter => {
|
|
151
151
|
return chapter
|
|
152
152
|
.replace(/\r\n/g, '\n') // 统一换行符
|
|
153
|
-
.replace(
|
|
154
|
-
.replace(/\
|
|
153
|
+
.replace(/[ \t]+\n/g, '\n') // 去除行尾空白
|
|
154
|
+
.replace(/\n[ \t]+/g, '\n') // 去除空行中的空白
|
|
155
|
+
.replace(/\n{3,}/g, '\n\n') // 合并过多的空行
|
|
155
156
|
.trim();
|
|
156
157
|
}).filter(chapter => chapter.length > 0);
|
|
157
158
|
|
|
@@ -162,4 +163,4 @@ function parseTxt(txtPath) {
|
|
|
162
163
|
});
|
|
163
164
|
}
|
|
164
165
|
|
|
165
|
-
export { parseTxt, detectEncoding, readFileWithCorrectEncoding };
|
|
166
|
+
export { parseTxt, detectEncoding, readFileWithCorrectEncoding };
|
package/src/ui.js
CHANGED
|
@@ -1,179 +1,274 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { pathToFileURL } from "url";
|
|
3
|
+
import React, { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { Box, Text, render, useApp, useInput, useWindowSize } from "ink";
|
|
5
|
+
import wrapAnsi from "wrap-ansi";
|
|
3
6
|
import { parseEpub } from "./epubReader.js";
|
|
4
7
|
import { parseTxt } from "./txtReader.js";
|
|
5
|
-
import path from "path";
|
|
6
8
|
import { getBookProgress, setBookProgress } from "./progressStore.js";
|
|
7
9
|
import { config } from "./config.js";
|
|
8
|
-
import fs from 'fs';
|
|
9
|
-
|
|
10
|
-
// 默认字体颜色
|
|
11
|
-
let fontColor = config.fontColor;
|
|
12
|
-
const colorFns = {
|
|
13
|
-
white: chalk.white,
|
|
14
|
-
green: chalk.green,
|
|
15
|
-
cyan: chalk.cyan,
|
|
16
|
-
magenta: chalk.magenta,
|
|
17
|
-
yellow: chalk.yellow,
|
|
18
|
-
};
|
|
19
10
|
|
|
20
|
-
|
|
21
|
-
const ext = path.extname(filePath).toLowerCase();
|
|
22
|
-
let chapters;
|
|
11
|
+
const h = React.createElement;
|
|
23
12
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
13
|
+
function formatChapterParagraphs(content) {
|
|
14
|
+
const lines = content
|
|
15
|
+
.replace(/\r\n?/g, "\n")
|
|
16
|
+
.split("\n")
|
|
17
|
+
.map((line) => line.trim())
|
|
18
|
+
.filter(Boolean);
|
|
31
19
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
20
|
+
return lines.map((line, index) => {
|
|
21
|
+
const isHeading = /^第[一二三四五六七八九十百千万零〇0-9]+[章节卷部篇回]/.test(line)
|
|
22
|
+
|| index === 0 && line.length <= 30;
|
|
35
23
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
title: config.title,
|
|
24
|
+
return {
|
|
25
|
+
text: isHeading ? line : ` ${line}`,
|
|
26
|
+
isHeading,
|
|
27
|
+
};
|
|
41
28
|
});
|
|
29
|
+
}
|
|
42
30
|
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
top: 0,
|
|
46
|
-
left: 0,
|
|
47
|
-
width: "100%",
|
|
48
|
-
height: "70%",
|
|
49
|
-
border: "none",
|
|
50
|
-
content: config.bossContent
|
|
51
|
-
});
|
|
31
|
+
function buildDisplayLines(content, width) {
|
|
32
|
+
const result = [];
|
|
52
33
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
tags: true,
|
|
60
|
-
scrollable: true,
|
|
61
|
-
alwaysScroll: true,
|
|
62
|
-
keys: true,
|
|
63
|
-
focused: true,
|
|
64
|
-
mouse: true,
|
|
65
|
-
vi: true,
|
|
66
|
-
border: "none",
|
|
67
|
-
style: {
|
|
68
|
-
fg: fontColor,
|
|
69
|
-
border: { fg: "cyan" },
|
|
70
|
-
},
|
|
71
|
-
});
|
|
34
|
+
for (const paragraph of formatChapterParagraphs(content)) {
|
|
35
|
+
const wrappedLines = wrapAnsi(paragraph.text, width, {
|
|
36
|
+
hard: true,
|
|
37
|
+
trim: false,
|
|
38
|
+
wordWrap: true,
|
|
39
|
+
}).split("\n");
|
|
72
40
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
41
|
+
result.push(...wrappedLines.map((text) => ({
|
|
42
|
+
text,
|
|
43
|
+
isHeading: paragraph.isHeading,
|
|
44
|
+
})));
|
|
45
|
+
result.push({ text: "", isHeading: false });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (result.length > 0) {
|
|
49
|
+
result.pop();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ProgressBar({ value, width }) {
|
|
56
|
+
const safeValue = Math.max(0, Math.min(1, value));
|
|
57
|
+
const completed = Math.round(width * safeValue);
|
|
58
|
+
|
|
59
|
+
return h(Text, null,
|
|
60
|
+
h(Text, { color: config.colors.accent }, "━".repeat(completed)),
|
|
61
|
+
h(Text, { dimColor: true }, "─".repeat(width - completed)),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
85
64
|
|
|
86
|
-
|
|
87
|
-
|
|
65
|
+
function BossView({ rows }) {
|
|
66
|
+
const visibleContent = config.bossContent
|
|
67
|
+
.trim()
|
|
68
|
+
.split("\n")
|
|
69
|
+
.slice(0, Math.max(1, rows - 3))
|
|
70
|
+
.join("\n");
|
|
88
71
|
|
|
89
|
-
|
|
90
|
-
|
|
72
|
+
return h(Box, {
|
|
73
|
+
height: rows,
|
|
74
|
+
flexDirection: "column",
|
|
75
|
+
paddingX: 2,
|
|
76
|
+
paddingTop: 1,
|
|
77
|
+
},
|
|
78
|
+
h(Text, { color: "yellow" }, visibleContent),
|
|
79
|
+
h(Box, { marginTop: 1 }, h(Text, { dimColor: true }, "Press a to return")),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function ReaderApp({ chapters, filePath, initialProgress }) {
|
|
84
|
+
const { exit } = useApp();
|
|
85
|
+
const { columns, rows } = useWindowSize();
|
|
86
|
+
const [chapter, setChapter] = useState(() => Math.min(
|
|
87
|
+
Math.max(initialProgress?.chapter ?? 0, 0),
|
|
88
|
+
Math.max(chapters.length - 1, 0),
|
|
89
|
+
));
|
|
90
|
+
const [scroll, setScroll] = useState(() => Math.max(initialProgress?.scroll ?? 0, 0));
|
|
91
|
+
const [isDisguised, setIsDisguised] = useState(false);
|
|
92
|
+
|
|
93
|
+
const panelWidth = Math.min(config.readingWidth + 6, Math.max(20, columns - 2));
|
|
94
|
+
const contentWidth = Math.max(12, panelWidth - 6);
|
|
95
|
+
const viewportHeight = Math.max(3, rows - 9);
|
|
96
|
+
const lines = useMemo(
|
|
97
|
+
() => buildDisplayLines(chapters[chapter] || "", contentWidth),
|
|
98
|
+
[chapter, chapters, contentWidth],
|
|
99
|
+
);
|
|
100
|
+
const maxScroll = Math.max(0, lines.length - viewportHeight);
|
|
101
|
+
const safeScroll = Math.min(scroll, maxScroll);
|
|
102
|
+
const visibleLines = lines.slice(safeScroll, safeScroll + viewportHeight);
|
|
103
|
+
const progress = chapters.length === 0
|
|
104
|
+
? 0
|
|
105
|
+
: (chapter + (maxScroll === 0 ? 1 : safeScroll / maxScroll)) / chapters.length;
|
|
106
|
+
const progressWidth = Math.max(8, Math.min(28, panelWidth - 22));
|
|
91
107
|
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
lastDisplayedChapter = currentChapter;
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (scroll > maxScroll) {
|
|
110
|
+
setScroll(maxScroll);
|
|
96
111
|
}
|
|
112
|
+
}, [maxScroll, scroll]);
|
|
97
113
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
} | ←/w:prev →/e:next ↑/s:up ↓/d:down a:boss q:exit`
|
|
102
|
-
);
|
|
103
|
-
// 恢复滚动位置
|
|
104
|
-
box.setScroll(currentScroll);
|
|
105
|
-
screen.render();
|
|
106
|
-
}
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
setBookProgress(filePath, { chapter, scroll: safeScroll });
|
|
116
|
+
}, [chapter, filePath, safeScroll]);
|
|
107
117
|
|
|
108
|
-
function
|
|
109
|
-
|
|
118
|
+
function changeChapter(offset) {
|
|
119
|
+
setChapter((current) => Math.min(Math.max(current + offset, 0), chapters.length - 1));
|
|
120
|
+
setScroll(0);
|
|
110
121
|
}
|
|
111
122
|
|
|
112
|
-
function
|
|
113
|
-
|
|
114
|
-
currentScroll = 0;
|
|
115
|
-
saveProgress();
|
|
116
|
-
render(true); // 切换章节时强制刷新内容
|
|
123
|
+
function moveScroll(offset) {
|
|
124
|
+
setScroll((current) => Math.min(Math.max(current + offset, 0), maxScroll));
|
|
117
125
|
}
|
|
118
126
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
});
|
|
125
|
-
screen.key(["left", "w"], () => {
|
|
126
|
-
if (currentChapter > 0) {
|
|
127
|
-
changeChapter(-1)
|
|
127
|
+
useInput((input, key) => {
|
|
128
|
+
if (input === "q") {
|
|
129
|
+
setBookProgress(filePath, { chapter, scroll: safeScroll });
|
|
130
|
+
exit();
|
|
131
|
+
return;
|
|
128
132
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
changeChapter(-1)
|
|
134
|
-
return
|
|
133
|
+
|
|
134
|
+
if (input === "a") {
|
|
135
|
+
setIsDisguised((value) => !value);
|
|
136
|
+
return;
|
|
135
137
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
currentScroll++;
|
|
140
|
-
if (currentScroll > box.getScrollHeight()) {
|
|
141
|
-
changeChapter(1)
|
|
142
|
-
return
|
|
138
|
+
|
|
139
|
+
if (isDisguised) {
|
|
140
|
+
return;
|
|
143
141
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
142
|
+
|
|
143
|
+
if (key.leftArrow || input === "w") {
|
|
144
|
+
changeChapter(-1);
|
|
145
|
+
} else if (key.rightArrow || input === "e") {
|
|
146
|
+
changeChapter(1);
|
|
147
|
+
} else if (key.upArrow || input === "s") {
|
|
148
|
+
if (safeScroll === 0 && chapter > 0) {
|
|
149
|
+
changeChapter(-1);
|
|
150
|
+
} else {
|
|
151
|
+
moveScroll(-1);
|
|
152
|
+
}
|
|
153
|
+
} else if (key.downArrow || input === "d") {
|
|
154
|
+
if (safeScroll === maxScroll && chapter < chapters.length - 1) {
|
|
155
|
+
changeChapter(1);
|
|
156
|
+
} else {
|
|
157
|
+
moveScroll(1);
|
|
158
|
+
}
|
|
159
|
+
} else if (key.pageUp) {
|
|
160
|
+
moveScroll(-(viewportHeight - 1));
|
|
161
|
+
} else if (key.pageDown || input === " ") {
|
|
162
|
+
moveScroll(viewportHeight - 1);
|
|
163
|
+
} else if (key.home) {
|
|
164
|
+
setScroll(0);
|
|
165
|
+
} else if (key.end) {
|
|
166
|
+
setScroll(maxScroll);
|
|
156
167
|
}
|
|
157
|
-
render(false) // 老板键切换时不强制刷新内容
|
|
158
168
|
});
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
|
|
170
|
+
if (isDisguised) {
|
|
171
|
+
return h(BossView, { rows });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const lineNodes = Array.from({ length: viewportHeight }, (_, index) => {
|
|
175
|
+
const line = visibleLines[index];
|
|
176
|
+
return h(Text, {
|
|
177
|
+
key: safeScroll + index,
|
|
178
|
+
bold: line?.isHeading,
|
|
179
|
+
color: line?.isHeading ? config.colors.accent : config.colors.text,
|
|
180
|
+
wrap: "truncate-end",
|
|
181
|
+
}, line?.text || " ");
|
|
162
182
|
});
|
|
163
183
|
|
|
164
|
-
|
|
165
|
-
|
|
184
|
+
return h(Box, {
|
|
185
|
+
width: columns,
|
|
186
|
+
height: rows,
|
|
187
|
+
flexDirection: "column",
|
|
188
|
+
paddingX: columns > 40 ? 1 : 0,
|
|
189
|
+
},
|
|
190
|
+
h(Box, {
|
|
191
|
+
width: panelWidth,
|
|
192
|
+
alignSelf: "center",
|
|
193
|
+
justifyContent: "space-between",
|
|
194
|
+
},
|
|
195
|
+
h(Text, { wrap: "truncate-middle" },
|
|
196
|
+
h(Text, { bold: true, color: config.colors.accent }, "reader-shell"),
|
|
197
|
+
h(Text, { dimColor: true }, ` ${path.basename(filePath)}`),
|
|
198
|
+
),
|
|
199
|
+
h(Text, { dimColor: true, wrap: "truncate-end" }, `${chapter + 1} / ${chapters.length}`),
|
|
200
|
+
),
|
|
201
|
+
h(Box, {
|
|
202
|
+
width: panelWidth,
|
|
203
|
+
height: viewportHeight + 4,
|
|
204
|
+
alignSelf: "center",
|
|
205
|
+
flexDirection: "column",
|
|
206
|
+
borderStyle: "round",
|
|
207
|
+
borderColor: config.colors.border,
|
|
208
|
+
paddingX: 2,
|
|
209
|
+
paddingY: 1,
|
|
210
|
+
marginTop: 1,
|
|
211
|
+
}, ...lineNodes),
|
|
212
|
+
h(Box, {
|
|
213
|
+
width: panelWidth,
|
|
214
|
+
alignSelf: "center",
|
|
215
|
+
justifyContent: "space-between",
|
|
216
|
+
marginTop: 1,
|
|
217
|
+
},
|
|
218
|
+
h(Box, null,
|
|
219
|
+
h(ProgressBar, { value: progress, width: progressWidth }),
|
|
220
|
+
h(Text, { dimColor: true }, ` ${Math.round(progress * 100)}%`),
|
|
221
|
+
),
|
|
222
|
+
h(Text, { dimColor: true }, `${safeScroll + 1} / ${Math.max(lines.length, 1)}`),
|
|
223
|
+
),
|
|
224
|
+
h(Box, { width: panelWidth, alignSelf: "center" },
|
|
225
|
+
h(Text, { dimColor: true, wrap: "truncate-end" }, "↑↓ scroll PgUp/PgDn page ←→ chapter a hide q quit"),
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function loadChapters(filePath) {
|
|
231
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
232
|
+
|
|
233
|
+
if (ext === ".epub") {
|
|
234
|
+
return parseEpub(filePath);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (ext === ".txt") {
|
|
238
|
+
return parseTxt(filePath);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
throw new Error(`不支持的文件格式: ${ext}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function startReader(filePath) {
|
|
245
|
+
const loadedChapters = await loadChapters(filePath);
|
|
246
|
+
const chapters = loadedChapters.length > 0
|
|
247
|
+
? loadedChapters
|
|
248
|
+
: ["(没有可显示的内容)"];
|
|
249
|
+
const instance = render(h(ReaderApp, {
|
|
250
|
+
chapters,
|
|
251
|
+
filePath,
|
|
252
|
+
initialProgress: getBookProgress(filePath),
|
|
253
|
+
}), {
|
|
254
|
+
alternateScreen: true,
|
|
255
|
+
incrementalRendering: true,
|
|
166
256
|
});
|
|
167
257
|
|
|
168
|
-
|
|
258
|
+
return instance.waitUntilExit();
|
|
169
259
|
}
|
|
170
260
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
process.
|
|
261
|
+
const isDirectRun = process.argv[1]
|
|
262
|
+
&& import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
263
|
+
|
|
264
|
+
if (isDirectRun) {
|
|
265
|
+
const filePath = process.argv[2];
|
|
266
|
+
if (!filePath) {
|
|
267
|
+
console.error("用法: node src/ui.js <文件路径>");
|
|
268
|
+
process.exitCode = 1;
|
|
269
|
+
} else {
|
|
270
|
+
await startReader(path.resolve(filePath));
|
|
271
|
+
}
|
|
176
272
|
}
|
|
177
|
-
startReader(path.resolve(filePath));
|
|
178
273
|
|
|
179
|
-
export { startReader };
|
|
274
|
+
export { buildDisplayLines, formatChapterParagraphs, startReader };
|