Feat: rewrite Reader in vanjs (#1194)

* setup van.js

* scrollbar

* Reader Footer

* move state to reader object

* scroll to previous progress

* image modal

* TTS bubble

* swipe gestures

* binoic reading & remove extra spacing

* tap to scroll

* tap & scroll to move page

* page reader move chapter

* display page in footer

* hide progress bar in page reader mode

* remember postion when switch reader mode

* refactor: move initial scroll out of scrollbar

* scroll bar in page reader mode

* chapter ending content

* fix: tts controller

* fix: tts highlight & stopping

* optional tts

* custom js

* update preview reader

* cleanup

* fix: css
This commit is contained in:
nyagami
2024-08-11 10:37:28 +07:00
committed by GitHub
parent 7cf8692e77
commit f6b2516643
29 changed files with 1269 additions and 1408 deletions
+2 -1
View File
@@ -1,2 +1,3 @@
**/node_modules
android
android/**/js/van.js
android/**/js/text-vibe.js
@@ -1,25 +0,0 @@
* {
touch-action: none;
}
body {
height: 100vh;
overflow: hidden;
position: relative;
}
html {
height: 100vh;
}
chapter {
/* height: calc(100% - 3% - var(--readerSettings-padding) - 2rem); */
height: 90vh;
display: flexbox;
flex-direction: column;
flex-wrap: wrap;
column-gap: 0;
column-width: 100vw;
transition: 200ms;
padding-top: calc(3% + var(--readerSettings-padding));
}
+56 -141
View File
@@ -3,7 +3,6 @@ html {
overflow-x: hidden;
padding-top: var(--StatusBar-currentHeight);
word-wrap: break-word;
padding: 0;
}
::selection {
@@ -19,7 +18,6 @@ html {
body {
margin-left: 0;
margin-right: 0;
padding: 0;
padding-bottom: 40px;
font-size: var(--readerSettings-textSize);
@@ -30,17 +28,23 @@ body {
font-family: var(--readerSettings-fontFamily);
}
chapter {
width: 100%;
display: block;
body.page-reader {
overflow: hidden;
padding: 0;
}
chapter > * {
max-width: 100vw;
#LNReader-chapter {
padding-left: var(--readerSettings-padding);
padding-right: var(--readerSettings-padding);
}
body.page-reader > #LNReader-chapter{
height: calc(90vh);
column-width: calc(100vw - var(--readerSettings-padding) * 2);
column-gap: calc(var(--readerSettings-padding) * 2);
transition: 200ms;
}
hr {
margin-top: 20px;
margin-bottom: 20px;
@@ -82,11 +86,10 @@ td {
visibility: hidden;
}
.nextButton,
.infoText {
.next-button,
.info-text {
margin-left: var(--readerSettings-padding);
margin-right: var(--readerSettings-padding);
width: calc(100% - var(--readerSettings-padding) * 2);
border-radius: 50px;
border-width: 1;
color: var(--theme-onPrimary);
@@ -94,18 +97,18 @@ td {
font-family: var(--readerSettings-fontFamily);
font-size: 16px;
border-width: 0;
user-select: none;
}
.nextButton {
.next-button {
min-height: 40px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding: 0 16px;
width: calc(100% - var(--readerSettings-padding) * 2);
}
.infoText {
.info-text {
background-color: transparent;
text-align: center;
border: none;
@@ -115,11 +118,6 @@ td {
padding-bottom: 16px;
}
.chapterCtn {
min-height: var(--chapterCtn-height);
margin-bottom: auto;
}
.d-none {
display: none;
}
@@ -159,23 +157,6 @@ td {
right: unset;
}
#TTS-Controller {
display: block;
}
#ToolWrapper.horizontal > #TTS-Controller {
margin-left: 4px;
}
#TTS-Controller button {
background: var(--theme-surface-0-9);
border: 0;
}
#TTS-Controller svg {
fill: var(--theme-onSurface);
}
#ToolWrapper > #ScrollBar {
margin-top: 8px;
width: 2.4rem;
@@ -320,115 +301,11 @@ td {
text-align: right;
}
.highlight {
color: var(--theme-onSecondary);
.highlight, .highlight *{
color: var(--theme-onSecondary) !important;
background-color: var(--theme-secondary);
}
.contextMenu {
position: fixed;
background: var(--theme-surface-0-9);
height: 0;
top: var(--top);
left: var(--left);
overflow: hidden;
-webkit-backdrop-filter: blur(1px);
backdrop-filter: blur(1px);
-webkit-animation: menuAnimation 0.4s 0s both;
animation: menuAnimation 0.4s 0s both;
transform-origin: left;
list-style: none;
margin: 4px;
padding: 0;
display: flex;
flex-direction: column;
z-index: 999999999;
}
.contextMenu-item {
padding: 4px;
}
.contextMenu-button {
color: var(--theme-onSurface);
background: 0;
border: 0;
border-radius: 4px;
white-space: nowrap;
padding: 6px 24px 6px 7px;
text-align: left;
display: flex;
align-items: center;
font-size: 14px;
width: 100%;
-webkit-animation: menuItemAnimation 0.2s 0s both;
animation: menuItemAnimation 0.2s 0s both;
font-family: var(--readerSettings-fontFamily);
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: var(--theme-rippleColor);
}
.contextMenu-button svg {
fill: var(--theme-onSurface);
}
.contextMenu-button span {
margin-left: 4px;
}
@-webkit-keyframes menuAnimation {
0% {
opacity: 0;
transform: scale(0.5);
}
100% {
height: var(--height);
opacity: 1;
border-radius: 8px;
transform: scale(1);
}
}
@keyframes menuAnimation {
0% {
opacity: 0;
transform: scale(0.5);
}
100% {
height: var(--height);
opacity: 1;
border-radius: 8px;
transform: scale(1);
}
}
@-webkit-keyframes menuItemAnimation {
0% {
opacity: 0;
transform: translateX(-10px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
@keyframes menuItemAnimation {
0% {
opacity: 0;
transform: translateX(-10px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
#Image-Modal {
position: fixed;
left: 0;
@@ -450,3 +327,41 @@ td {
display: block;
width: 100%;
}
#TTS-Controller {
position: fixed;
top: 50%;
left: 20px;
opacity: 0.5;
}
#TTS-Controller button {
background: var(--theme-surface-0-9);
outline: none;
border-width: 0;
display: flex;
justify-items: center;
align-items: center;
padding: 4px;
border-radius: 100%;
transition: 1s;
}
#TTS-Controller.active{
opacity: 1;
}
#TTS-Controller.active button {
padding: 16px;
}
#TTS-Controller svg {
fill: var(--theme-onSurface);
width: 20px;
height: 20px;
}
#TTS-Controller.active svg {
width: 24px;
height: 24px;
}
+472
View File
@@ -0,0 +1,472 @@
window.reader = new (function () {
const {
readerSettings,
chapterGeneralSettings,
novel,
chapter,
nextChapter,
batteryLevel,
autoSaveInterval,
DEBUG,
strings,
} = initialReaderConfig;
// state
this.hidden = van.state(true);
this.batteryLevel = van.state(batteryLevel);
this.readerSettings = van.state(readerSettings);
this.generalSettings = van.state(chapterGeneralSettings);
this.chapterElement = document.querySelector('#LNReader-chapter');
this.selection = window.getSelection();
this.viewport = document.querySelector('meta[name=viewport]');
this.novel = novel;
this.chapter = chapter;
this.nextChapter = nextChapter;
this.strings = strings;
this.autoSaveInterval = autoSaveInterval;
this.rawHTML = this.chapterElement.innerHTML;
//layout props
this.paddingTop = parseInt(
getComputedStyle(document.querySelector('html')).getPropertyValue(
'padding-top',
),
10,
);
this.chapterHeight = this.chapterElement.scrollHeight + this.paddingTop;
this.layoutHeight = window.screen.height;
this.layoutWidth = window.screen.width;
this.post = obj => window.ReactNativeWebView.postMessage(JSON.stringify(obj));
this.refresh = () => {
if (this.generalSettings.val.pageReader) {
this.chapterWidth = this.chapterElement.scrollWidth;
} else {
this.chapterHeight = this.chapterElement.scrollHeight + this.paddingTop;
}
};
van.derive(() => {
const settings = this.readerSettings.val;
document.documentElement.style.setProperty(
'--readerSettings-theme',
settings.theme,
);
document.documentElement.style.setProperty(
'--readerSettings-padding',
settings.padding + 'px',
);
document.documentElement.style.setProperty(
'--readerSettings-textSize',
settings.textSize + 'px',
);
document.documentElement.style.setProperty(
'--readerSettings-textColor',
settings.textColor,
);
document.documentElement.style.setProperty(
'--readerSettings-textAlign',
settings.textAlign,
);
document.documentElement.style.setProperty(
'--readerSettings-lineHeight',
settings.lineHeight,
);
document.documentElement.style.setProperty(
'--readerSettings-fontFamily',
settings.fontFamily,
);
if (settings.fontFamily) {
new FontFace(
settings.fontFamily,
'url("file:///android_asset/fonts/' + settings.fontFamily + '.ttf")',
)
.load()
.then(function (loadedFont) {
document.fonts.add(loadedFont);
});
} else {
// have no affect with a font declared in head
document.fonts.forEach(fontFace => document.fonts.delete(fontFace));
}
});
setInterval(() => {
if (!this.generalSettings.val.pageReader) {
this.post({
type: 'save',
data: parseInt(
((window.scrollY + this.layoutHeight) / this.chapterHeight) * 100,
10,
),
});
} else {
this.post({
type: 'save',
data: parseInt(
((pageReader.page.val + 1) / pageReader.totalPages.val) * 100,
),
});
}
}, this.autoSaveInterval);
if (DEBUG) {
console = new Object();
console.log = function (...data) {
reader.post({ 'type': 'console', 'msg': data?.join(' ') });
};
console.debug = console.log;
console.info = console.log;
console.warn = console.log;
console.error = console.log;
}
// end reader
})();
window.tts = new (function () {
this.readableNodeNames = [
'#text',
'B',
'I',
'SPAN',
'EM',
'BR',
'STRONG',
'A',
];
this.prevElement = null;
this.currentElement = reader.chapterElement;
this.started = false;
this.reading = false;
this.readable = element => {
ele = element ?? this.currentElement;
if (
ele.nodeName !== 'SPAN' &&
this.readableNodeNames.includes(ele.nodeName)
) {
return false;
}
if (!ele.hasChildNodes()) {
return false;
}
for (let i = 0; i < ele.childNodes.length; i++) {
if (!this.readableNodeNames.includes(ele.childNodes.item(i).nodeName)) {
return false;
}
}
return true;
};
// if can find a readable node, else stop tts
this.findNextTextNode = () => {
if (!this.started) {
this.started = true;
if (this.readable()) {
return true;
}
} else if (this.currentElement.isSameNode(reader.chapterElement)) {
return false;
}
// node is read -> next node or parent node
if (this.currentElement.isSameNode(this.prevElement)) {
this.prevElement = this.currentElement;
if (this.currentElement.nextElementSibling) {
this.currentElement = this.currentElement.nextElementSibling;
} else {
this.currentElement = this.currentElement.parentElement;
}
return this.findNextTextNode();
}
if (this.readable()) {
return true;
}
if (this.prevElement?.parentElement.isSameNode(this.currentElement)) {
// is backtracking
this.prevElement = this.currentElement;
if (this.currentElement.nextElementSibling) {
this.currentElement = this.currentElement.nextElementSibling;
} else {
this.currentElement = this.currentElement.parentElement;
}
return this.findNextTextNode();
}
if (this.currentElement.firstElementChild) {
// go deeper
this.prevElement = this.currentElement;
this.currentElement = this.currentElement.firstElementChild;
return this.findNextTextNode();
}
return false;
};
this.next = () => {
try {
this.currentElement?.classList?.remove('highlight');
if (this.findNextTextNode()) {
this.reading = true;
this.speak();
} else {
this.reading = false;
this.stop();
document.getElementById('TTS-Controller').firstElementChild.innerHTML =
volumnIcon;
}
} catch (e) {
alert(e);
}
};
this.start = element => {
this.stop();
this.currentElement = element ?? reader.chapterElement;
this.next();
};
this.resume = () => {
if (!this.reading) {
if (
this.currentElement &&
this.currentElement.id !== 'LNReader-chapter'
) {
this.speak();
this.reading = true;
} else {
this.next();
}
}
};
this.pause = () => {
this.reading = false;
reader.post({ type: 'stop-speak' });
};
this.stop = () => {
reader.post({ type: 'stop-speak' });
this.currentElement?.classList?.remove('highlight');
this.prevElement = null;
this.currentElement = reader.chapterElement;
this.started = false;
this.reading = false;
};
this.speak = () => {
this.prevElement = this.currentElement;
this.currentElement.classList.add('highlight');
reader.post({ type: 'speak', data: this.currentElement?.innerText });
};
})();
window.pageReader = new (function () {
this.page = van.state(0);
this.totalPages = van.state(0);
this.movePage = destPage => {
destPage = parseInt(destPage);
if (destPage < 0) {
return reader.post({ type: 'prev' });
}
if (destPage >= this.totalPages.val) {
return reader.post({ type: 'next' });
}
this.page.val = destPage;
reader.chapterElement.style.transform =
'translateX(-' + destPage * 100 + '%)';
};
van.derive(() => {
// ignore if initial or other states change
if (
reader.generalSettings.val.pageReader ===
reader.generalSettings.oldVal.pageReader
) {
return;
}
if (reader.generalSettings.val.pageReader) {
const ratio = Math.min(
0.99,
(window.scrollY + reader.layoutHeight) / reader.chapterHeight,
);
document.body.classList.add('page-reader');
setTimeout(() => {
reader.refresh();
this.totalPages.val = parseInt(
(reader.chapterWidth + reader.readerSettings.val.padding * 2) /
reader.layoutWidth,
);
this.movePage(this.totalPages.val * ratio);
}, 100);
} else {
reader.chapterElement.style = '';
document.body.classList.remove('page-reader');
setTimeout(() => {
reader.refresh();
window.scrollTo({
top:
(reader.chapterHeight * (this.page.val + 1)) / this.totalPages.val -
reader.layoutHeight,
behavior: 'smooth',
});
}, 100);
}
});
})();
window.addEventListener('DOMContentLoaded', async () => {
// wait for content is rendered
setTimeout(() => {
reader.refresh();
if (reader.generalSettings.val.pageReader) {
pageReader.totalPages.val = parseInt(
(reader.chapterWidth + reader.readerSettings.val.padding * 2) /
reader.layoutWidth,
);
pageReader.movePage(
parseInt(
pageReader.totalPages.val *
Math.min(0.99, reader.chapter.progress / 100),
),
);
} else {
window.scrollTo({
top:
(reader.chapterHeight * reader.chapter.progress) / 100 -
reader.layoutHeight,
behavior: 'smooth',
});
}
}, 100);
});
// click handler
(function () {
const detectTapPosition = (x, y, horizontal) => {
if (horizontal) {
if (x < 0.33) {
return 'left';
}
if (x > 0.66) {
return 'right';
}
} else {
if (y < 0.33) {
return 'top';
}
if (y > 0.66) {
return 'bottom';
}
}
return 'center';
};
document.onclick = e => {
const { clientX, clientY } = e;
const { x, y } = {
x: clientX / reader.layoutWidth,
y: clientY / reader.layoutHeight,
};
if (reader.generalSettings.val.pageReader) {
const position = detectTapPosition(x, y, true);
if (position === 'left') {
pageReader.movePage(pageReader.page.val - 1);
return;
}
if (position === 'right') {
pageReader.movePage(pageReader.page.val + 1);
return;
}
} else {
if (reader.generalSettings.val.tapToScroll) {
const position = detectTapPosition(x, y, false);
if (position === 'top') {
window.scrollBy({
top: -reader.layoutHeight * 0.75,
behavior: 'smooth',
});
return;
}
if (position === 'bottom') {
window.scrollBy({
top: reader.layoutHeight * 0.75,
behavior: 'smooth',
});
return;
}
}
}
reader.post({ type: 'hide' });
};
})();
// swipe handler
(function () {
this.initialX = null;
this.initialY = null;
reader.chapterElement.addEventListener('touchstart', e => {
this.initialX = e.changedTouches[0].screenX;
this.initialY = e.changedTouches[0].screenY;
});
reader.chapterElement.addEventListener('touchmove', e => {
if (reader.generalSettings.val.pageReader) {
const diffX =
(e.changedTouches[0].screenX - this.initialX) / reader.layoutWidth;
reader.chapterElement.style.transform =
'translateX(-' + (pageReader.page.val - diffX) * 100 + '%)';
}
});
reader.chapterElement.addEventListener('touchend', e => {
const diffX = e.changedTouches[0].screenX - this.initialX;
const diffY = e.changedTouches[0].screenY - this.initialY;
if (reader.generalSettings.val.pageReader) {
const diffXPercentage = diffX / reader.layoutWidth;
if (diffXPercentage < -0.3) {
pageReader.movePage(pageReader.page.val + 1);
} else if (diffXPercentage > 0.3) {
pageReader.movePage(pageReader.page.val - 1);
} else {
pageReader.movePage(pageReader.page.val);
}
return;
}
if (
e.target.id?.startsWith('scrollbar') ||
e.target.id === 'Image-Modal-img'
) {
return;
}
if (
reader.generalSettings.val.swipeGestures &&
Math.abs(diffX) > Math.abs(diffY) * 2 &&
Math.abs(diffX) > 180
) {
if (diffX < 0 && this.initialX >= window.innerWidth / 2) {
e.preventDefault();
reader.post({ type: 'next' });
} else if (diffX > 0 && this.initialX <= window.innerWidth / 2) {
e.preventDefault();
reader.post({ type: 'prev' });
}
}
});
})();
// text options
(function () {
van.derive(() => {
let html = reader.rawHTML;
if (reader.generalSettings.val.bionicReading) {
html = textVide.textVide(reader.rawHTML);
}
if (reader.generalSettings.val.removeExtraParagraphSpacing) {
html = html.replace(/<\s*br[^>]*>/gi, '\n').replace(/\n{2,}/g, '\n\n');
}
reader.chapterElement.innerHTML = html;
});
})();
-10
View File
@@ -1,10 +0,0 @@
const select = key => {
return document.querySelector(key);
};
const chapter = select('chapter');
const getInt = key => {
return parseInt(chapter?.getAttribute('data-' + key), 10);
};
const setAttr = (key, val) => {
chapter?.setAttribute(key, val);
};
@@ -1,65 +0,0 @@
const bounds = document.querySelector('html').getBoundingClientRect();
var page = 0;
var pages = 0;
function setPages(newPages) {
if (newPages) {
pages = newPages;
return;
}
const textWidth = chapter.scrollWidth;
const layoutWidth = parseInt(
document.querySelector('html').getBoundingClientRect().width,
10,
);
pages = Math.round(textWidth / layoutWidth) - 1;
}
function setPage(newPage) {
page = newPage ?? 0;
}
function getPages() {
return pages;
}
function getPage() {
return page;
}
function tapChapter(event) {
const { clientX, clientY } = event;
const { x, y } = { x: clientX / bounds.width, y: clientY / bounds.height };
if (y < 0.2) {
movePage('prev');
} else if (y > 0.8) {
movePage('next');
} else if (x < 0.33) {
movePage('prev');
} else if (x > 0.66) {
movePage('next');
} else {
movePage();
}
}
function movePage(panel) {
switch (panel) {
case 'next':
if (page === pages) {
reader.post({ type: 'next' });
} else {
page++;
}
break;
case 'prev':
if (page === 0) {
reader.post({ type: 'prev' });
} else {
page--;
}
break;
default:
reader.post({ type: 'hide' });
break;
}
chapter.style.transform = 'translate(-' + page * 100 + '%)';
}
+6
View File
@@ -0,0 +1,6 @@
const volumnIcon =
'<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M552-152v-75q86-23 139-93.5T744-480q0-89-53.5-158.5T552-734v-75q116 25 190 117t74 211q0 119-73.5 211.5T552-152ZM144-385v-192h144l192-192v576L288-385H144Zm408 55v-302q45 20 70.5 61t25.5 90q0 49-25.5 89.5T552-330Z"/></svg>';
const pauseIcon =
'<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M564-228v-504h168v504H564Zm-336 0v-504h168v504H228Z"/></svg>';
const resumeIcon =
'<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M240-240v-480h72v480h-72Zm144 0 384-240-384-240v480Z"/></svg>';
+62
View File
@@ -0,0 +1,62 @@
import { ChapterInfo, NovelInfo } from '@database/types';
import {
ChapterGeneralSettings,
ChapterReaderSettings,
} from '@hooks/persisted/useSettings';
import { State } from './van';
export interface Reader {
// element
chapterElement: HTMLElement;
viewport: HTMLMetaElement;
selection: Selection;
// state
hidden: State<boolean>;
generalSettings: State<ChapterGeneralSettings>;
readerSettings: State<ChapterReaderSettings>;
batteryLevel: State<number>;
novel: NovelInfo;
chapter: ChapterInfo;
nextChapter?: ChapterInfo;
autoSaveInterval: number;
rawHTML: string;
strings: {
finished: string;
nextChapter: string;
noNextChapter: string;
};
//layout props
paddingTop: number;
layoutHeight: number;
layoutWidth: number;
chapterHeight: number;
chapterWidth: number;
post: (obj: Record<string, any>) => void;
refresh: () => void;
}
interface PageReader {
page: State<number>;
totalPages: State<number>;
movePage: (page: number) => void;
}
interface TTS {
started: boolean;
reading: boolean;
start: (element?: HTMLElement) => void;
resume: () => void;
stop: () => void;
pause: () => void;
readable: (element?: HTMLElement) => void;
}
declare global {
const reader: Reader;
const tts: TTS;
const pageReader: PageReader;
}
File diff suppressed because it is too large Load Diff
-28
View File
@@ -1,28 +0,0 @@
function setup(progress, customJS) {
document
.querySelector('.chapterCtn')
.addEventListener('click', e => onTap(e));
document.addEventListener('DOMContentLoaded', startup);
async function startup() {
customJS;
//? scroll to saved position
if (reader.pageReader) {
setTimeout(() => {
setPages();
scrollHandler.onDOMCreation(progress);
}, 100);
} else {
scrollHandler.onDOMCreation(progress);
}
}
function onTap(event) {
if (!contextMenu.isOpened) {
!reader.pageReader ? reader.post({ type: 'hide' }) : tapChapter(event);
} else {
contextMenu.isOpened = false;
contextMenu.contextMenu.remove();
}
}
}
-69
View File
@@ -1,69 +0,0 @@
import {
ChapterGeneralSettings,
ChapterReaderSettings,
} from '@hooks/persisted/useSettings';
import 'typescript';
import 'typescript/lib/lib.dom';
interface Reader {
selection: Selection;
viewport: HTMLMetaElement;
footerWrapper: HTMLDivElement;
percentage: HTMLDivElement;
battery: HTMLDivElement;
time: HTMLDivElement;
paddingTop: number;
chapter: HTMLElement;
chapterHeight: number;
layoutHeight: number;
pluginId: string;
novelId: string;
chapterId: string;
saveProgressInterval: NodeJS.Timeout;
timeInterval: NodeJS.Timeout;
refresh: () => void;
post: (message: { type: string; data?: any }) => void;
updateReaderSettings: (settings: ChapterReaderSettings) => void;
updateGeneralSettings: (settings: ChapterGeneralSettings) => void;
updateBatteryLevel: (level: number) => void;
}
interface TextToSpeech {
reader: Reader;
leaf: HTMLUnknownElement;
reading: boolean;
readableTags: string[];
start: () => void;
startHere: () => void;
resume: () => void;
pause: () => void;
stop: () => void;
started: () => boolean;
}
export enum ContextMenuItem {
START_READING = 'START_READING',
START_HERE = 'START_HERE',
COPY = 'COPY',
SELECT_ALL = 'SELECT_ALL',
}
interface ContextMenu {
reader: Reader;
contextMenu: HTMLUListElement;
items: Record<ContextMenuItem, HTMLLIElement>;
renderItem: (data: {
name: string;
icon: string;
action: () => void;
}) => HTMLLIElement;
renderMenu: (items: HTMLLIElement[]) => void;
closeMenu: (menu: HTMLUListElement) => void;
init: () => void;
}
interface ImageModal {
reader: Reader;
showing: boolean;
show: (image: HTMLImageElement) => void;
hide: () => void;
}
+70
View File
@@ -0,0 +1,70 @@
import 'typescript';
import 'typescript/lib/lib.dom';
export interface State<T> {
val: T;
readonly oldVal: T;
readonly rawVal: T;
}
// Defining readonly view of State<T> for covariance.
// Basically we want StateView<string> to implement StateView<string | number>
export type StateView<T> = Readonly<State<T>>;
export type Val<T> = State<T> | T;
export type Primitive = string | number | boolean | bigint;
export type PropValue = Primitive | ((e: any) => void) | null;
export type PropValueOrDerived =
| PropValue
| StateView<PropValue>
| (() => PropValue);
export type Props = Record<string, PropValueOrDerived> & {
class?: PropValueOrDerived;
};
export type PropsWithKnownKeys<ElementType> = Partial<{
[K in keyof ElementType]: PropValueOrDerived;
}>;
export type ValidChildDomValue = Primitive | Node | null | undefined;
export type BindingFunc =
| ((dom?: Node) => ValidChildDomValue)
| ((dom?: Element) => Element);
export type ChildDom =
| ValidChildDomValue
| StateView<Primitive | null | undefined>
| BindingFunc
| readonly ChildDom[];
export type TagFunc<Result> = (
first?: (Props & PropsWithKnownKeys<Result>) | ChildDom,
...rest: readonly ChildDom[]
) => Result;
type Tags = Readonly<Record<string, TagFunc<Element>>> & {
[K in keyof HTMLElementTagNameMap]: TagFunc<HTMLElementTagNameMap[K]>;
};
declare function state<T>(): State<T>;
declare function state<T>(initVal: T): State<T>;
export interface Van {
readonly state: typeof state;
readonly derive: <T>(f: () => T) => State<T>;
readonly add: (dom: Element, ...children: readonly ChildDom[]) => Element;
readonly tags: Tags &
((namespaceURI: string) => Readonly<Record<string, TagFunc<Element>>>);
readonly hydrate: <T extends Node>(
dom: T,
f: (dom: T) => T | null | undefined,
) => T;
}
declare global {
const van: Van;
}
+1
View File
@@ -0,0 +1 @@
{let e,t,r,o,n,l,s,f,i,h,w,a,d,u,_,c,S,g,y,b,m,v,j,x,O; s = Object.getPrototypeOf,i = {},h = s(f = {isConnected:1}),w = s(s),a = (e,t,r,o)=>(e ?? (setTimeout(r,o),new Set())).add(t),d = (e,t,o)=>{let n = r; r = t; try {return e(o);} catch (e){return console.error(e),o;} finally {r = n;}},u = e=>e.filter(e=>e.t?.isConnected),_ = e=>n = a(n,e,()=>{for (let e of n){e.o = u(e.o),e.l = u(e.l);}n = l;},1e3),c = {get val(){return r?.i?.add(this),this.rawVal;},get oldVal(){return r?.i?.add(this),this.h;},set val(o){r?.u?.add(this),o !== this.rawVal && (this.rawVal = o,this.o.length + this.l.length ? (t?.add(this),e = a(e,this,x)) : this.h = o);}},S = e=>({__proto__:c,rawVal:e,h:e,o:[],l:[]}),g = (e,t)=>{let r = {i:new Set(),u:new Set()},n = {f:e},l = o; o = []; let s = d(e,r,t); s = (s ?? document).nodeType ? s : new Text(s); for (let e of r.i){r.u.has(e) || (_(e),e.o.push(n));} for (let e of o){e.t = s;} return o = l,n.t = s;},y = (e,t = S(),r)=>{let n = {i:new Set(),u:new Set()},l = {f:e,s:t}; l.t = r ?? o?.push(l) ?? f,t.val = d(e,n,t.rawVal); for (let e of n.i){n.u.has(e) || (_(e),e.l.push(l));} return t;},b = (e,...t)=>{for (let r of t.flat(1 / 0)){let t = s(r ?? 0),o = t === c ? g(()=>r.val) : t === w ? g(r) : r; o != l && e.append(o);} return e;},m = (e,t,...r)=>{let [o,...n] = s(r[0] ?? 0) === h ? r : [{},...r],f = e ? document.createElementNS(e,t) : document.createElement(t); for (let [e,r] of Object.entries(o)){let o = t=>t ? Object.getOwnPropertyDescriptor(t,e) ?? o(s(t)) : l,n = t + ',' + e,h = i[n] ?? (i[n] = o(s(f))?.set ?? 0),a = e.startsWith('on') ? (t,r)=>{let o = e.slice(2); f.removeEventListener(o,r),f.addEventListener(o,t);} : h ? h.bind(f) : f.setAttribute.bind(f,e),d = s(r ?? 0); e.startsWith('on') || d === w && (r = y(r),d = c),d === c ? g(()=>(a(r.val,r.h),f)) : a(r);} return b(f,n);},v = e=>({get:(t,r)=>m.bind(l,e,r)}),j = (e,t)=>t ? t !== e && e.replaceWith(t) : e.remove(),x = ()=>{let r = 0,o = [...e].filter(e=>e.rawVal !== e.h); do {t = new Set(); for (let e of new Set(o.flatMap(e=>e.l = u(e.l)))){y(e.f,e.s,e.t),e.t = l;}} while (++r < 100 && (o = [...t]).length); let n = [...e].filter(e=>e.rawVal !== e.h); e = l; for (let e of new Set(n.flatMap(e=>e.o = u(e.o)))){j(e.t,g(e.f,e.t)),e.t = l;} for (let e of n){e.h = e.rawVal;}},O = {tags:new Proxy(e=>new Proxy(m,v(e)),v()),hydrate:(e,t)=>j(e,g(t,e)),add:b,state:S,derive:y},window.van = O;}
+3 -2
View File
@@ -56,7 +56,7 @@
"react-native-shimmer-placeholder": "^2.0.9",
"react-native-tab-view": "^3.5.2",
"react-native-vector-icons": "^9.0.0",
"react-native-webview": "^13.2.2",
"react-native-webview": "^13.10.5",
"react-native-zip-archive": "^7.0.0",
"sanitize-html": "^2.13.0",
"urlencode": "^2.0.0"
@@ -15096,7 +15096,8 @@
},
"node_modules/react-native-webview": {
"version": "13.10.5",
"license": "MIT",
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.10.5.tgz",
"integrity": "sha512-ZfQJHyifuZop8cQB8HYpL9dxss9Cw9sOlMU+/yarfG/ugoL0rky/0tq9QmEydFMXjDSplSUb22OrRdFlqfXe0g==",
"dependencies": {
"escape-string-regexp": "2.0.0",
"invariant": "2.2.4"
+1 -1
View File
@@ -63,7 +63,7 @@
"react-native-shimmer-placeholder": "^2.0.9",
"react-native-tab-view": "^3.5.2",
"react-native-vector-icons": "^9.0.0",
"react-native-webview": "^13.2.2",
"react-native-webview": "^13.10.5",
"react-native-zip-archive": "^7.0.0",
"sanitize-html": "^2.13.0",
"urlencode": "^2.0.0"
+4
View File
@@ -85,6 +85,8 @@ export interface ChapterGeneralSettings {
verticalSeekbar: boolean;
removeExtraParagraphSpacing: boolean;
bionicReading: boolean;
tapToScroll: boolean;
TTSEnable: boolean;
}
export interface ReaderTheme {
@@ -174,6 +176,8 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = {
verticalSeekbar: true,
removeExtraParagraphSpacing: false,
bionicReading: false,
tapToScroll: false,
TTSEnable: true,
};
export const initialChapterReaderSettings: ChapterReaderSettings = {
@@ -16,7 +16,7 @@ const ChapterLoadingScreen = () => {
return (
<View style={{ backgroundColor }}>
<SkeletonLines
containerMargin={padding + '%'}
containerMargin={padding}
containerHeight={'100%'}
containerWidth={'100%'}
color={
-1
View File
@@ -121,7 +121,6 @@ export const ChapterContent = ({
html={chapterText}
nextChapter={nextChapter}
webViewRef={webViewRef}
pageReader={pageReader}
saveProgress={saveProgress}
onPress={hideHeader}
navigateChapter={navigateChapter}
@@ -31,9 +31,11 @@ const ReaderTab: React.FC = () => {
<ReaderValueChange
label={getString('readerScreen.bottomSheet.padding')}
valueKey="padding"
valueChange={0.5}
min={1}
max={10}
valueChange={2}
min={0}
max={50}
decimals={0}
unit="px"
/>
<ReaderFontPicker />
</View>
@@ -54,6 +56,7 @@ const GeneralTab: React.FC = () => {
pageReader = false,
removeExtraParagraphSpacing,
bionicReading,
tapToScroll = false,
setChapterGeneralSettings,
} = useChapterGeneralSettings();
@@ -139,6 +142,12 @@ const GeneralTab: React.FC = () => {
value={bionicReading}
theme={theme}
/>
<ReaderSheetPreferenceItem
label={getString('readerScreen.bottomSheet.tapToScroll')}
onPress={() => setChapterGeneralSettings({ tapToScroll: !tapToScroll })}
value={tapToScroll}
theme={theme}
/>
<ReaderSheetPreferenceItem
label={getString('readerScreen.bottomSheet.keepScreenOn')}
onPress={() =>
@@ -17,6 +17,7 @@ interface ReaderValueChangeProps {
decimals?: number;
min?: number;
max?: number;
unit?: string;
}
const ReaderValueChange: React.FC<ReaderValueChangeProps> = ({
@@ -27,6 +28,7 @@ const ReaderValueChange: React.FC<ReaderValueChangeProps> = ({
decimals = 1,
min = 1.3,
max = 2,
unit = '%',
}) => {
const theme = useTheme();
const { setChapterReaderSettings, ...settings } = useChapterReaderSettings();
@@ -50,7 +52,7 @@ const ReaderValueChange: React.FC<ReaderValueChangeProps> = ({
theme={theme}
/>
<Text style={[styles.value, { color: theme.onSurface }]}>
{`${((settings[valueKey] * 10) / 10).toFixed(decimals)}%`}
{`${((settings[valueKey] * 10) / 10).toFixed(decimals)}${unit}`}
</Text>
<IconButtonV2
name="plus"
+106 -193
View File
@@ -1,10 +1,5 @@
import React, { useEffect, useMemo } from 'react';
import {
Dimensions,
NativeEventEmitter,
NativeModules,
StatusBar,
} from 'react-native';
import { NativeEventEmitter, NativeModules, StatusBar } from 'react-native';
import WebView from 'react-native-webview';
import color from 'color';
@@ -24,8 +19,6 @@ import {
} from '@hooks/persisted/useSettings';
import { getBatteryLevelSync } from 'react-native-device-info';
import * as Speech from 'expo-speech';
import * as Clipboard from 'expo-clipboard';
import { showToast } from '@utils/showToast';
import { PLUGIN_STORAGE } from '@utils/Storages';
import { useChapterContext } from '../ChapterContext';
@@ -36,12 +29,11 @@ type WebViewPostEvent = {
type WebViewReaderProps = {
html: string;
nextChapter: ChapterInfo;
nextChapter?: ChapterInfo;
webViewRef: React.RefObject<WebView>;
saveProgress(percentage: number): void;
onPress(): void;
navigateChapter(position: 'NEXT' | 'PREV'): void;
pageReader: boolean;
};
const onLogMessage = (payload: { nativeEvent: { data: string } }) => {
@@ -56,23 +48,22 @@ const onLogMessage = (payload: { nativeEvent: { data: string } }) => {
}
};
const WebViewReader: React.FC<WebViewReaderProps> = props => {
const {
html,
nextChapter,
webViewRef,
pageReader,
saveProgress,
onPress,
navigateChapter,
} = props;
const { RNDeviceInfo } = NativeModules;
const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo);
const assetsUriPrefix = __DEV__
? 'http://localhost:8081/assets'
: 'file:///android_asset';
const WebViewReader: React.FC<WebViewReaderProps> = ({
html,
webViewRef,
nextChapter,
saveProgress,
onPress,
navigateChapter,
}) => {
const { novel, chapter } = useChapterContext();
const assetsUriPrefix = useMemo(
() => (__DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'),
[],
);
const { RNDeviceInfo } = NativeModules;
const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo);
const theme = useTheme();
const readerSettings = useMemo(
() =>
@@ -80,20 +71,13 @@ const WebViewReader: React.FC<WebViewReaderProps> = props => {
initialChapterReaderSettings,
[],
);
const {
showScrollPercentage,
swipeGestures,
showBatteryAndTime,
verticalSeekbar,
bionicReading,
} = useMemo(
const chapterGeneralSettings = useMemo(
() =>
getMMKVObject<ChapterGeneralSettings>(CHAPTER_GENERAL_SETTINGS) ||
initialChapterGeneralSettings,
[],
);
const batteryLevel = useMemo(getBatteryLevelSync, []);
const layoutHeight = Dimensions.get('window').height;
const plugin = getPlugin(novel?.pluginId);
const pluginCustomJS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.js`;
const pluginCustomCSS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.css`;
@@ -103,16 +87,16 @@ const WebViewReader: React.FC<WebViewReaderProps> = props => {
switch (key) {
case CHAPTER_READER_SETTINGS:
webViewRef.current?.injectJavaScript(
`reader.updateReaderSettings(${MMKVStorage.getString(
`reader.readerSettings.val = ${MMKVStorage.getString(
CHAPTER_READER_SETTINGS,
)})`,
)}`,
);
break;
case CHAPTER_GENERAL_SETTINGS:
webViewRef.current?.injectJavaScript(
`reader.updateGeneralSettings(${MMKVStorage.getString(
`reader.generalSettings.val = ${MMKVStorage.getString(
CHAPTER_GENERAL_SETTINGS,
)})`,
)}`,
);
break;
}
@@ -122,7 +106,7 @@ const WebViewReader: React.FC<WebViewReaderProps> = props => {
'RNDeviceInfo_batteryLevelDidChange',
(level: number) => {
webViewRef.current?.injectJavaScript(
`reader.updateBatteryLevel(${level})`,
`reader.batteryLevel.val = ${level}`,
);
},
);
@@ -132,21 +116,9 @@ const WebViewReader: React.FC<WebViewReaderProps> = props => {
mmkvListener.remove();
};
}, []);
const debugging = `
// Debug
console = new Object();
console.log = function(log) {
reader.post({"type": "console", "msg": log});
};
console.debug = console.log;
console.info = console.log;
console.warn = console.log;
console.error = console.log;
`;
return (
<WebView
injectedJavaScript={debugging}
ref={webViewRef}
style={{ backgroundColor: readerSettings.theme }}
allowFileAccess={true}
@@ -189,156 +161,97 @@ const WebViewReader: React.FC<WebViewReaderProps> = props => {
case 'stop-speak':
Speech.stop();
break;
case 'copy':
if (event.data && typeof event.data === 'string') {
Clipboard.setStringAsync(event.data).then(() => {
showToast(getString('common.copiedToClipboard', { name: '' }));
});
}
break;
}
}}
source={{
baseUrl: plugin?.site,
baseUrl: !chapter.isDownloaded ? plugin?.site : undefined,
headers: plugin?.imageRequestInit?.headers,
method: plugin?.imageRequestInit?.method,
body: plugin?.imageRequestInit?.body,
html: `
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
<style>
:root {
--StatusBar-currentHeight: ${StatusBar.currentHeight};
--readerSettings-theme: ${readerSettings.theme};
--readerSettings-padding: ${readerSettings.padding}%;
--readerSettings-textSize: ${readerSettings.textSize}px;
--readerSettings-textColor: ${readerSettings.textColor};
--readerSettings-textAlign: ${readerSettings.textAlign};
--readerSettings-lineHeight: ${readerSettings.lineHeight};
--readerSettings-fontFamily: ${readerSettings.fontFamily};
--theme-primary: ${theme.primary};
--theme-onPrimary: ${theme.onPrimary};
--theme-secondary: ${theme.secondary};
--theme-tertiary: ${theme.tertiary};
--theme-onTertiary: ${theme.onTertiary};
--theme-onSecondary: ${theme.onSecondary};
--theme-surface: ${theme.surface};
--theme-surface-0-9: ${color(theme.surface)
.alpha(0.9)
.toString()};
--theme-onSurface: ${theme.onSurface};
--theme-surfaceVariant: ${theme.surfaceVariant};
--theme-onSurfaceVariant: ${theme.onSurfaceVariant};
--theme-outline: ${theme.outline};
--theme-rippleColor: ${theme.rippleColor};
--chapterCtn-height: ${layoutHeight - 140};
}
@font-face {
font-family: ${readerSettings.fontFamily};
src: url("file:///android_asset/fonts/${
readerSettings.fontFamily
}.ttf");
}
</style>
${
pageReader
? `
<link rel="stylesheet" href="${assetsUriPrefix}/css/horizontal.css">
`
: ''
}
<link rel="stylesheet" href="${pluginCustomCSS}">
<style>${readerSettings.customCSS}</style>
<script>
var initSettings = {
showScrollPercentage: ${showScrollPercentage},
swipeGestures: ${swipeGestures},
showBatteryAndTime: ${showBatteryAndTime},
verticalSeekbar: ${verticalSeekbar},
bionicReading: ${bionicReading},
}
var batteryLevel = ${batteryLevel};
var autoSaveInterval = 2222;
var { NOVEL, CHAPTER } = ${JSON.stringify({
NOVEL: novel,
CHAPTER: chapter,
})}
</script>
</head>
<body>
<div class="chapterCtn">
<chapter
data-page-reader='${pageReader}'
data-plugin-id='${novel.pluginId}'
data-novel-id='${chapter.novelId}'
data-chapter-id='${chapter.id}'
>
${html}
</chapter>
<div class="hidden" id="ToolWrapper">
<div id="TTS-Controller"></div>
<div id="ScrollBar"></div>
</div>
<div id="Image-Modal">
<img id="Image-Modal-img">
</div>
<div id="reader-footer-wrapper">
<div id="reader-footer">
<div id="reader-battery" class="reader-footer-item"></div>
<div id="reader-percentage" class="reader-footer-item"></div>
<div id="reader-time" class="reader-footer-item"></div>
</div>
</div>
</div>
${
!pageReader
? `
<div class="infoText">
${getString(
'readerScreen.finished',
)}: ${chapter.name.trim()}
</div>
${
nextChapter
? `<button class="nextButton" onclick="reader.post({type:'next'})">
${getString('readerScreen.nextChapter', {
name: nextChapter.name,
})}
</button>`
: `<div class="infoText">
${getString('readerScreen.noNextChapter')}
</div>`
}`
: ''
}
</body>
<script>
// Debug
console = new Object();
console.log = function(log) {
reader.post({"type": "console", "msg": log});
};
console.debug = console.log;
console.info = console.log;
console.warn = console.log;
console.error = console.log;
</script>
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
<script src="${assetsUriPrefix}/js/default.js"></script>
<script src="${assetsUriPrefix}/js/horizontalScroll.js"></script>
<script src="${assetsUriPrefix}/js/index.js"></script>
<script src="${assetsUriPrefix}/js/setup.js"></script>
<script src="${assetsUriPrefix}/js/horizontalScroll.js"></script>
<script src="${pluginCustomJS}"></script>
<script>
setup(${chapter.progress},${readerSettings.customJS})
</script>
</html>
`,
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
<style>
:root {
--StatusBar-currentHeight: ${StatusBar.currentHeight}px;
--readerSettings-theme: ${readerSettings.theme};
--readerSettings-padding: ${readerSettings.padding}px;
--readerSettings-textSize: ${readerSettings.textSize}px;
--readerSettings-textColor: ${readerSettings.textColor};
--readerSettings-textAlign: ${readerSettings.textAlign};
--readerSettings-lineHeight: ${readerSettings.lineHeight};
--readerSettings-fontFamily: ${readerSettings.fontFamily};
--theme-primary: ${theme.primary};
--theme-onPrimary: ${theme.onPrimary};
--theme-secondary: ${theme.secondary};
--theme-tertiary: ${theme.tertiary};
--theme-onTertiary: ${theme.onTertiary};
--theme-onSecondary: ${theme.onSecondary};
--theme-surface: ${theme.surface};
--theme-surface-0-9: ${color(theme.surface)
.alpha(0.9)
.toString()};
--theme-onSurface: ${theme.onSurface};
--theme-surfaceVariant: ${theme.surfaceVariant};
--theme-onSurfaceVariant: ${theme.onSurfaceVariant};
--theme-outline: ${theme.outline};
--theme-rippleColor: ${theme.rippleColor};
}
@font-face {
font-family: ${readerSettings.fontFamily};
src: url("file:///android_asset/fonts/${
readerSettings.fontFamily
}.ttf");
}
</style>
<link rel="stylesheet" href="${pluginCustomCSS}">
<style>${readerSettings.customCSS}</style>
</head>
<body class="${
chapterGeneralSettings.pageReader ? 'page-reader' : ''
}">
<div id="LNReader-chapter">
${html}
</div>
<div id="reader-ui"></div>
</body>
<script>
var initialReaderConfig = ${JSON.stringify({
readerSettings,
chapterGeneralSettings,
novel,
chapter,
nextChapter,
batteryLevel,
autoSaveInterval: 2222,
DEBUG: __DEV__,
strings: {
finished: `${getString(
'readerScreen.finished',
)}: ${chapter.name.trim()}`,
nextChapter: getString('readerScreen.nextChapter', {
name: nextChapter?.name,
}),
noNextChapter: getString('readerScreen.noNextChapter'),
},
})}
</script>
<script src="${assetsUriPrefix}/js/icons.js"></script>
<script src="${assetsUriPrefix}/js/van.js"></script>
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
<script src="${assetsUriPrefix}/js/core.js"></script>
<script src="${assetsUriPrefix}/js/index.js"></script>
<script src="${pluginCustomJS}"></script>
<script>
${readerSettings.customJS}
</script>
</html>
`,
}}
/>
);
+5 -14
View File
@@ -40,13 +40,8 @@ export default function useChapter(webViewRef: RefObject<WebView>) {
const [[nextChapter, prevChapter], setAdjacentChapter] = useState<
ChapterInfo[]
>([]);
const {
autoScroll,
autoScrollInterval,
autoScrollOffset,
useVolumeButtons,
removeExtraParagraphSpacing,
} = useChapterGeneralSettings();
const { autoScroll, autoScrollInterval, autoScrollOffset, useVolumeButtons } =
useChapterGeneralSettings();
const { incognitoMode } = useLibrarySettings();
const [error, setError] = useState<string>();
const { tracker } = useTracker();
@@ -102,11 +97,7 @@ export default function useChapter(webViewRef: RefObject<WebView>) {
})
.catch(e => setError(e.message));
}
setChapterText(
sanitizeChapterText(text, {
removeExtraParagraphSpacing,
}),
);
setChapterText(sanitizeChapterText(text));
const [nextChap, prevChap] = await Promise.all([
getNextChapter(chapter.novelId, chapter.id),
getPrevChapter(chapter.novelId, chapter.id),
@@ -161,10 +152,10 @@ export default function useChapter(webViewRef: RefObject<WebView>) {
const hideHeader = () => {
if (!hidden) {
webViewRef.current?.injectJavaScript('toolWrapper.hide()');
webViewRef.current?.injectJavaScript('reader.hidden.val = true');
setImmersiveMode();
} else {
webViewRef.current?.injectJavaScript('toolWrapper.show()');
webViewRef.current?.injectJavaScript('reader.hidden.val = false');
showStatusAndNavBar();
}
setHidden(!hidden);
@@ -1,14 +1,7 @@
import { getString } from '@strings/translations';
import sanitizeHtml from 'sanitize-html';
interface Options {
removeExtraParagraphSpacing?: boolean;
}
export const sanitizeChapterText = (
html: string,
options?: Options,
): string => {
export const sanitizeChapterText = (html: string): string => {
let text = sanitizeHtml(html, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
'img',
@@ -30,12 +23,5 @@ export const sanitizeChapterText = (
},
allowedSchemes: ['data', 'http', 'https', 'file'],
});
if (text) {
if (options?.removeExtraParagraphSpacing) {
text = text.replace(/<\s*br[^>]*>/gi, '\n').replace(/\n{2,}/g, '\n\n');
}
} else {
text = getString('readerScreen.emptyChapterMessage');
}
return text;
return text || getString('readerScreen.emptyChapterMessage');
};
@@ -28,6 +28,7 @@ const GeneralSettings: React.FC = () => {
autoScrollInterval = 10,
autoScrollOffset = null,
bionicReading = false,
tapToScroll = false,
setChapterGeneralSettings,
} = useChapterGeneralSettings();
@@ -83,6 +84,12 @@ const GeneralSettings: React.FC = () => {
}
theme={theme}
/>
<SettingSwitch
label={getString('readerScreen.bottomSheet.tapToScroll')}
value={tapToScroll}
onPress={() => setChapterGeneralSettings({ tapToScroll: !tapToScroll })}
theme={theme}
/>
<SettingSwitch
label={getString('readerScreen.bottomSheet.pageReader')}
value={pageReader}
@@ -102,8 +102,11 @@ const ReaderThemeSettings = () => {
labelStyle={labelStyle}
label={getString('readerScreen.bottomSheet.padding')}
valueKey="padding"
valueChange={0.5}
max={10}
valueChange={2}
min={0}
max={50}
decimals={0}
unit="px"
/>
<List.Item
title={getString('readerScreen.bottomSheet.fontStyle')}
@@ -1,5 +1,9 @@
import { Button, List } from '@components';
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
import { IconButtonV2, List } from '@components';
import {
useChapterGeneralSettings,
useChapterReaderSettings,
useTheme,
} from '@hooks/persisted';
import React, { useEffect, useState } from 'react';
import VoicePickerModal from '../Modals/VoicePickerModal';
import { useBoolean } from '@hooks';
@@ -7,6 +11,7 @@ import { Portal } from 'react-native-paper';
import { StyleSheet, View, Text } from 'react-native';
import Slider from '@react-native-community/slider';
import { getAvailableVoicesAsync, Voice } from 'expo-speech';
import Switch from '@components/Switch/Switch';
export default function TextToSpeechSettings() {
const theme = useTheme();
@@ -19,6 +24,8 @@ export default function TextToSpeechSettings() {
}, []);
const { tts, setChapterReaderSettings } = useChapterReaderSettings();
const { TTSEnable = true, setChapterGeneralSettings } =
useChapterGeneralSettings();
const {
value: voiceModalVisible,
setTrue: showVoiceModal,
@@ -28,61 +35,76 @@ export default function TextToSpeechSettings() {
<>
<View style={styles.row}>
<List.SubHeader theme={theme}>Text to Speech</List.SubHeader>
<Button
mode="text"
onPress={() => {
setChapterReaderSettings({
tts: {
pitch: 1,
rate: 1,
voice: { name: 'System', language: 'System' } as Voice,
},
});
}}
title="Reset"
icon="reload"
/>
<View style={styles.row}>
<Switch
theme={theme}
value={TTSEnable}
onValueChange={() => {
setChapterGeneralSettings({
TTSEnable: !TTSEnable,
});
}}
/>
<IconButtonV2
name="reload"
theme={theme}
color={theme.primary}
onPress={() => {
setChapterReaderSettings({
tts: {
pitch: 1,
rate: 1,
voice: { name: 'System', language: 'System' } as Voice,
},
});
}}
/>
</View>
</View>
<List.Item
title={'TTS voice'}
description={tts?.voice?.name || 'System'}
onPress={showVoiceModal}
theme={theme}
/>
<List.Section>
<Text style={[styles.label, { color: theme.onSurface }]}>
Voice rate
</Text>
<Slider
style={styles.slider}
value={tts?.rate}
minimumValue={0.1}
maximumValue={5}
step={0.1}
minimumTrackTintColor={theme.primary}
maximumTrackTintColor={theme.surfaceVariant}
thumbTintColor={theme.primary}
onSlidingComplete={value =>
setChapterReaderSettings({ tts: { ...tts, rate: value } })
}
/>
<Text style={[styles.label, { color: theme.onSurface }]}>
Voice pitch
</Text>
<Slider
style={styles.slider}
value={tts?.pitch}
minimumValue={0.1}
maximumValue={5}
step={0.1}
minimumTrackTintColor={theme.primary}
maximumTrackTintColor={theme.surfaceVariant}
thumbTintColor={theme.primary}
onSlidingComplete={value =>
setChapterReaderSettings({ tts: { ...tts, pitch: value } })
}
/>
</List.Section>
{TTSEnable ? (
<>
<List.Item
title={'TTS voice'}
description={tts?.voice?.name || 'System'}
onPress={showVoiceModal}
theme={theme}
/>
<List.Section>
<Text style={[styles.label, { color: theme.onSurface }]}>
Voice rate
</Text>
<Slider
style={styles.slider}
value={tts?.rate}
minimumValue={0.1}
maximumValue={5}
step={0.1}
minimumTrackTintColor={theme.primary}
maximumTrackTintColor={theme.surfaceVariant}
thumbTintColor={theme.primary}
onSlidingComplete={value =>
setChapterReaderSettings({ tts: { ...tts, rate: value } })
}
/>
<Text style={[styles.label, { color: theme.onSurface }]}>
Voice pitch
</Text>
<Slider
style={styles.slider}
value={tts?.pitch}
minimumValue={0.1}
maximumValue={5}
step={0.1}
minimumTrackTintColor={theme.primary}
maximumTrackTintColor={theme.surfaceVariant}
thumbTintColor={theme.primary}
onSlidingComplete={value =>
setChapterReaderSettings({ tts: { ...tts, pitch: value } })
}
/>
</List.Section>
</>
) : null}
<View style={{ height: 16 }} />
<Portal>
<VoicePickerModal
@@ -1,4 +1,4 @@
import { View, ScrollView, StatusBar, Dimensions } from 'react-native';
import { View, ScrollView, StatusBar } from 'react-native';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigation } from '@react-navigation/native';
@@ -23,8 +23,6 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import color from 'color';
import { useBatteryLevel } from 'react-native-device-info';
import * as Speech from 'expo-speech';
import * as Clipboard from 'expo-clipboard';
import { showToast } from '@utils/showToast';
import TextToSpeechSettings from './Settings/TextToSpeechSettings';
export type TextAlignments =
@@ -79,34 +77,21 @@ const SettingsReaderScreen = () => {
'updatedTime': null,
};
const [hidden, setHidden] = useState(true);
const layoutHeight = Dimensions.get('window').height;
const batteryLevel = useBatteryLevel();
const readerSettings = useChapterReaderSettings();
const {
showScrollPercentage,
showBatteryAndTime,
verticalSeekbar,
bionicReading,
pageReader,
} = useChapterGeneralSettings();
const chapterGeneralSettings = useChapterGeneralSettings();
const READER_HEIGHT = 280;
const assetsUriPrefix = useMemo(
() => (__DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'),
[],
);
const dummyChapterInfo = {
sourceId: 11,
chapterId: 99,
novelId: 20,
novelName: 'novel name',
chapterName: "chapter' name",
};
const webViewCSS = `
<style>
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
<style>
:root {
--StatusBar-currentHeight: ${StatusBar.currentHeight};
--readerSettings-theme: ${readerSettings.theme};
--readerSettings-padding: ${readerSettings.padding}%;
--readerSettings-padding: ${readerSettings.padding}px;
--readerSettings-textSize: ${readerSettings.textSize}px;
--readerSettings-textColor: ${readerSettings.textColor};
--readerSettings-textAlign: ${readerSettings.textAlign};
@@ -125,17 +110,17 @@ const SettingsReaderScreen = () => {
--theme-onSurfaceVariant: ${theme.onSurfaceVariant};
--theme-outline: ${theme.outline};
--theme-rippleColor: ${theme.rippleColor};
--chapterCtn-height: ${layoutHeight - 140};
}
@font-face {
font-family: ${readerSettings.fontFamily};
src: url("file:///android_asset/fonts/${readerSettings.fontFamily}.ttf");
}
}
@font-face {
font-family: ${readerSettings.fontFamily};
src: url("file:///android_asset/fonts/${
readerSettings.fontFamily
}.ttf");
}
</style>
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
<style>
${readerSettings.customCSS}
</style>
<style>${readerSettings.customCSS}</style>
`;
const readerBackgroundColor = readerSettings.theme;
@@ -168,9 +153,13 @@ const SettingsReaderScreen = () => {
switch (event.type) {
case 'hide':
if (hidden) {
webViewRef.current?.injectJavaScript('toolWrapper.show()');
webViewRef.current?.injectJavaScript(
'reader.hidden.val = true',
);
} else {
webViewRef.current?.injectJavaScript('toolWrapper.hide()');
webViewRef.current?.injectJavaScript(
'reader.hidden.val = false',
);
}
setHidden(!hidden);
break;
@@ -191,15 +180,6 @@ const SettingsReaderScreen = () => {
case 'stop-speak':
Speech.stop();
break;
case 'copy':
if (event.data && typeof event.data === 'string') {
Clipboard.setStringAsync(event.data).then(() => {
showToast(
getString('common.copiedToClipboard', { name: '' }),
);
});
}
break;
}
}}
source={{
@@ -208,63 +188,44 @@ const SettingsReaderScreen = () => {
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
${webViewCSS}
${
pageReader
? `
<link rel="stylesheet" href="${assetsUriPrefix}/css/horizontal.css">
`
: ''
}
<script async>
var initSettings = {
showScrollPercentage: ${showScrollPercentage},
swipeGestures: false,
showBatteryAndTime: ${showBatteryAndTime},
verticalSeekbar: ${verticalSeekbar},
bionicReading: ${bionicReading},
}
var batteryLevel = ${batteryLevel};
var autoSaveInterval = 2222;
var { NOVEL, CHAPTER } = ${JSON.stringify({
NOVEL: novel,
CHAPTER: chapter,
})}
</script>
</head>
<body>
<div class="chapterCtn">
<chapter
data-page-reader='${pageReader}'
data-novel-id='${dummyChapterInfo.novelId}'
data-chapter-id='${dummyChapterInfo.chapterId}'
>
${dummyHTML}
</chapter>
<body class="${
chapterGeneralSettings.pageReader ? 'page-reader' : ''
}">
<div id="LNReader-chapter">
${dummyHTML}
</div>
<div class="hidden" id="ToolWrapper">
<div id="TTS-Controller"></div>
<div id="ScrollBar"></div>
</div>
<div id="Image-Modal">
<img id="Image-Modal-img">
</div>
<div id="reader-footer-wrapper">
<div id="reader-footer">
<div id="reader-battery" class="reader-footer-item"></div>
<div id="reader-percentage" class="reader-footer-item"></div>
<div id="reader-time" class="reader-footer-item"></div>
</div>
</div>
</body>
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
<script src="${assetsUriPrefix}/js/default.js"></script>
<script src="${assetsUriPrefix}/js/horizontalScroll.js"></script>
<script src="${assetsUriPrefix}/js/index.js"></script>
<script src="${assetsUriPrefix}/js/setup.js"></script>
<script>
setup(0,${readerSettings.customJS})
</script>
<div id="reader-ui"></div>
</body>
<script>
var initialReaderConfig = ${JSON.stringify({
readerSettings,
chapterGeneralSettings,
novel,
chapter,
nextChapter: chapter,
batteryLevel,
autoSaveInterval: 2222,
DEBUG: __DEV__,
strings: {
finished: `${getString(
'readerScreen.finished',
)}: ${chapter.name.trim()}`,
nextChapter: getString('readerScreen.nextChapter', {
name: chapter.name,
}),
noNextChapter: getString('readerScreen.noNextChapter'),
},
})}
</script>
<script src="${assetsUriPrefix}/js/icons.js"></script>
<script src="${assetsUriPrefix}/js/van.js"></script>
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
<script src="${assetsUriPrefix}/js/core.js"></script>
<script src="${assetsUriPrefix}/js/index.js"></script>
<script>
${readerSettings.customJS}
</script>
</html>
`,
}}
+2 -1
View File
@@ -427,6 +427,7 @@
"allowTextSelection": "Allow text selection",
"autoscroll": "AutoScroll",
"bionicReading": "Bionic Reading",
"tapToScroll": "Tap to scroll",
"color": "Color",
"fontStyle": "Font style",
"fullscreen": "Fullscreen",
@@ -514,4 +515,4 @@
"updatesLower": "updates",
"updatingLibrary": "Updating library"
}
}
}
+1
View File
@@ -349,6 +349,7 @@ export interface StringMap {
'readerScreen.bottomSheet.allowTextSelection': 'string';
'readerScreen.bottomSheet.autoscroll': 'string';
'readerScreen.bottomSheet.bionicReading': 'string';
'readerScreen.bottomSheet.tapToScroll': 'string';
'readerScreen.bottomSheet.color': 'string';
'readerScreen.bottomSheet.fontStyle': 'string';
'readerScreen.bottomSheet.fullscreen': 'string';