From f6b25166437607b679f01a6ea615e32e16fdd2ea Mon Sep 17 00:00:00 2001 From: nyagami Date: Sun, 11 Aug 2024 10:37:28 +0700 Subject: [PATCH] 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 --- .prettierignore | 3 +- .../app/src/main/assets/css/horizontal.css | 25 - android/app/src/main/assets/css/index.css | 197 +--- android/app/src/main/assets/js/core.js | 472 +++++++++ android/app/src/main/assets/js/default.js | 10 - .../src/main/assets/js/horizontalScroll.js | 65 -- android/app/src/main/assets/js/icons.js | 6 + android/app/src/main/assets/js/index.d.ts | 62 ++ android/app/src/main/assets/js/index.js | 995 ++++++------------ android/app/src/main/assets/js/setup.js | 28 - android/app/src/main/assets/js/type.d.ts | 69 -- android/app/src/main/assets/js/van.d.ts | 70 ++ android/app/src/main/assets/js/van.js | 1 + package-lock.json | 5 +- package.json | 2 +- src/hooks/persisted/useSettings.ts | 4 + .../ChapterLoadingScreen.tsx | 2 +- src/screens/reader/ReaderScreen.tsx | 1 - .../ReaderBottomSheet/ReaderBottomSheet.tsx | 15 +- .../ReaderBottomSheet/ReaderValueChange.tsx | 4 +- .../reader/components/WebViewReader.tsx | 299 ++---- src/screens/reader/hooks/useChapter.ts | 19 +- .../reader/utils/sanitizeChapterText.ts | 18 +- .../Settings/GeneralSettings.tsx | 7 + .../Settings/ReaderThemeSettings.tsx | 7 +- .../Settings/TextToSpeechSettings.tsx | 134 ++- .../SettingsReaderScreen.tsx | 153 +-- strings/languages/en/strings.json | 3 +- strings/types/index.ts | 1 + 29 files changed, 1269 insertions(+), 1408 deletions(-) delete mode 100644 android/app/src/main/assets/css/horizontal.css create mode 100644 android/app/src/main/assets/js/core.js delete mode 100644 android/app/src/main/assets/js/default.js delete mode 100644 android/app/src/main/assets/js/horizontalScroll.js create mode 100644 android/app/src/main/assets/js/icons.js create mode 100644 android/app/src/main/assets/js/index.d.ts delete mode 100644 android/app/src/main/assets/js/setup.js delete mode 100644 android/app/src/main/assets/js/type.d.ts create mode 100644 android/app/src/main/assets/js/van.d.ts create mode 100644 android/app/src/main/assets/js/van.js diff --git a/.prettierignore b/.prettierignore index e48f049d..5594f9ca 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ **/node_modules -android \ No newline at end of file +android/**/js/van.js +android/**/js/text-vibe.js diff --git a/android/app/src/main/assets/css/horizontal.css b/android/app/src/main/assets/css/horizontal.css deleted file mode 100644 index 6d42359f..00000000 --- a/android/app/src/main/assets/css/horizontal.css +++ /dev/null @@ -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)); -} \ No newline at end of file diff --git a/android/app/src/main/assets/css/index.css b/android/app/src/main/assets/css/index.css index 98e656a0..2614b749 100644 --- a/android/app/src/main/assets/css/index.css +++ b/android/app/src/main/assets/css/index.css @@ -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; +} \ No newline at end of file diff --git a/android/app/src/main/assets/js/core.js b/android/app/src/main/assets/js/core.js new file mode 100644 index 00000000..4cde084a --- /dev/null +++ b/android/app/src/main/assets/js/core.js @@ -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; + }); +})(); diff --git a/android/app/src/main/assets/js/default.js b/android/app/src/main/assets/js/default.js deleted file mode 100644 index 0ac415c3..00000000 --- a/android/app/src/main/assets/js/default.js +++ /dev/null @@ -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); -}; diff --git a/android/app/src/main/assets/js/horizontalScroll.js b/android/app/src/main/assets/js/horizontalScroll.js deleted file mode 100644 index 8cef11ee..00000000 --- a/android/app/src/main/assets/js/horizontalScroll.js +++ /dev/null @@ -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 + '%)'; -} diff --git a/android/app/src/main/assets/js/icons.js b/android/app/src/main/assets/js/icons.js new file mode 100644 index 00000000..54ea5603 --- /dev/null +++ b/android/app/src/main/assets/js/icons.js @@ -0,0 +1,6 @@ +const volumnIcon = + ''; +const pauseIcon = + ''; +const resumeIcon = + ''; diff --git a/android/app/src/main/assets/js/index.d.ts b/android/app/src/main/assets/js/index.d.ts new file mode 100644 index 00000000..84190e09 --- /dev/null +++ b/android/app/src/main/assets/js/index.d.ts @@ -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; + generalSettings: State; + readerSettings: State; + batteryLevel: State; + + 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) => void; + refresh: () => void; +} + +interface PageReader { + page: State; + totalPages: State; + 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; +} diff --git a/android/app/src/main/assets/js/index.js b/android/app/src/main/assets/js/index.js index 0247daad..e7d168ab 100644 --- a/android/app/src/main/assets/js/index.js +++ b/android/app/src/main/assets/js/index.js @@ -1,706 +1,337 @@ -/** - * https://fonts.google.com/icons - * Filled - * other settings are default - */ -const copyIcon = - ''; -const volumeIcon = - ''; -const selectAllIcon = - ''; -const resumeIcon = - ''; -const pauseIcon = - ''; -const selectVolumeIcon = - ''; -/** - * @type {import("./type").Reader} - */ -class Reader { - constructor(swipeHandler, toolWrapper) { - this.swipeHandler = swipeHandler; - this.toolWrapper = toolWrapper; +const { div, p, img, button } = van.tags; - this.selection = window.getSelection(); - this.viewport = document.querySelector('meta[name=viewport]'); - this.footerWrapper = document.getElementById('reader-footer-wrapper'); - this.percentage = document.getElementById('reader-percentage'); - this.battery = document.getElementById('reader-battery'); - this.time = document.getElementById('reader-time'); - this.paddingTop = parseInt( - getComputedStyle(document.querySelector('html')).getPropertyValue( - 'padding-top', - ), - 10, - ); - this.chapter = document.querySelector('chapter'); - this.rawHTML = this.chapter.innerHTML; - this.chapterHeight = this.chapter.scrollHeight + this.paddingTop; - this.layoutHeight = window.innerHeight; - this.pluginId = getInt('plugin-id'); - this.novelId = getInt('novel-id'); - this.chapterId = getInt('chapter-id'); - this.chapterWidth = this.chapter.scrollWidth; - this.layoutWidth = window.innerWidth; - this.pageReader = this.chapter.getAttribute('data-page-reader') === 'true'; - this.saveProgressInterval = setInterval(() => { - if (!this.pageReader) { - this.post({ - type: 'save', - data: parseInt( - ((window.scrollY + this.layoutHeight) / this.chapterHeight) * 100, - 10, - ), - }); - } else { - this.post({ - type: 'save', - data: parseInt((getPage() / getPages()) * 100, 10), - }); - } - }, autoSaveInterval); - this.time.innerText = new Date().toLocaleTimeString(undefined, { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }); - this.timeInterval = setInterval(() => { - this.time.innerText = new Date().toLocaleTimeString(undefined, { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }); - }, 10000); - this.updateBatteryLevel(batteryLevel); - this.updateGeneralSettings(initSettings); - } - refresh = () => { - if (!this.pageReader) { - this.chapterHeight = this.chapter.scrollHeight + this.paddingTop; - } else { - this.percentage.innerText = getPage() + 1 + '/' + (getPages() + 1); - this.chapterWidth = this.chapter.scrollWidth; - } - }; - post = obj => window.ReactNativeWebView.postMessage(JSON.stringify(obj)); - updateReaderSettings = settings => { - document.documentElement.style.setProperty( - '--readerSettings-theme', - settings.theme, - ); - document.documentElement.style.setProperty( - '--readerSettings-padding', - settings.padding + '%', - ); - 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)); - } - }; - updateGeneralSettings = settings => { - this.showScrollPercentage = settings.showScrollPercentage; - this.showBatteryAndTime = settings.showBatteryAndTime; - this.verticalSeekbar = settings.verticalSeekbar; - this.bionicReading = settings.bionicReading; - if (this.bionicReading) { - this.chapter.innerHTML = textVide.textVide(this.rawHTML); - } else { - this.chapter.innerHTML = this.rawHTML; - } - if (settings.swipeGestures) { - this.swipeHandler.enable(); - } else { - this.swipeHandler.disable(); - } - if (!this.showScrollPercentage && !this.showBatteryAndTime) { - this.footerWrapper.classList.add('d-none'); - } else { - this.footerWrapper.classList.remove('d-none'); - if (this.showScrollPercentage) { - this.percentage.classList.remove('hidden'); - } else { - this.percentage.classList.add('hidden'); - } - if (this.showBatteryAndTime) { - this.battery.classList.remove('hidden'); - this.time.classList.remove('hidden'); - } else { - this.battery.classList.add('hidden'); - this.time.classList.add('hidden'); - } - } - if (!this.verticalSeekbar) { - this.toolWrapper.$.classList.add('horizontal'); - } else { - this.toolWrapper.$.classList.remove('horizontal'); - } - }; - updateBatteryLevel = level => { - this.battery.innerText = Math.ceil(level * 100) + '%'; - }; -} +const ChapterEnding = () => { + return () => + reader.generalSettings.val.pageReader + ? div() + : div(div({ class: 'info-text' }, reader.strings.finished), () => + reader.nextChapter + ? button( + { + class: 'next-button', + onclick: () => reader.post({ type: 'next' }), + }, + reader.strings.nextChapter, + ) + : div({ class: 'info-text' }, reader.strings.noNextChapter), + ); +}; -class ToolWrapper { - constructor() { - this.$ = document.getElementById('ToolWrapper'); - this.$.onclick = e => { - e.stopPropagation(); - }; - this.tools = []; - } - hide = () => { - this.$.classList.add('hidden'); - this.visible = false; - }; - show = () => { - this.$.classList.remove('hidden'); - this.visible = true; - for (const tool of this.tools) { - tool.onShow?.(); - } - }; -} - -class ScrollHandler { - constructor(reader, toolWrapper) { - this.reader = reader; - this.toolWrapper = toolWrapper; - this.$ = document.getElementById('ScrollBar'); - this.$.innerHTML = ` -
- 0 -
-
-
-
-
-
-
-
-
- -
-
- 100 -
- `; - this.percentage = this.$.querySelector('#scrollbar-percentage'); - this.percentageMax = this.$.querySelector('#scrollbar-percentage-max'); - this.progress = this.$.querySelector('#scrollbar-progress'); - this.thumb = this.$.querySelector('#scrollbar-thumb-wrapper'); - this.slider = this.$.querySelector('#scrollbar-slider'); - this.sliderHeight = this.slider.clientHeight; - this.sliderOffsetY = this.slider.getBoundingClientRect().top; - this.lock = false; - window.onscroll = () => !this.lock && this.update(); - window.ontouchend = () => !this.lock && this.update(); - this.thumb.ontouchstart = () => (this.lock = true); - this.thumb.ontouchend = () => (this.lock = false); - this.thumb.ontouchmove = e => { - const ratio = - ((this.reader.verticalSeekbar - ? e.changedTouches[0].clientY - : e.changedTouches[0].clientX) - - this.sliderOffsetY) / - this.sliderHeight; - this.update(ratio < 0 ? 0 : ratio); - }; - } - update = ratio => { - if (this.reader.pageReader) { - setTimeout(() => this.updateHorizontal(ratio), 10); - } else { - this.updateVertical(ratio); - } - }; - updateHorizontal = ratio => { - this.reader.refresh(); - const totalPages = getPages(); - const currentPage = getPage(); - - this.percentageMax.innerHTML = totalPages + 1; - const percentage = parseInt((currentPage / totalPages) * 100, 10); - if (this.toolWrapper.visible) { - if (this.reader.verticalSeekbar) { - this.progress.style.height = percentage + '%'; - this.progress.style.width = '100%'; - } else { - this.progress.style.height = '100%'; - this.progress.style.width = percentage + '%'; - } - this.percentage.innerText = currentPage + 1; - } - if (this.lock) { - let newPage = parseInt(totalPages * ratio, 10); - newPage = newPage > totalPages ? totalPages : newPage; - setPage(newPage); - this.reader.chapter.style.transform = - 'translate(-' + newPage * 100 + '%)'; - } - if (this.reader.percentage) { - this.reader.refresh(); - } - }; - - updateVertical = ratio => { +const Scrollbar = () => { + const horizontal = van.derive( + () => !reader.generalSettings.val.verticalSeekbar, + ); + let lock = false; + const percentage = van.state(0); + const update = ratio => { if (ratio === undefined) { - ratio = - (window.scrollY + this.reader.layoutHeight) / this.reader.chapterHeight; + ratio = (window.scrollY + reader.layoutHeight) / reader.chapterHeight; } if (ratio > 1) { ratio = 1; } - const percentage = parseInt(ratio * 100, 10); - if (this.toolWrapper.visible) { - if (this.reader.verticalSeekbar) { - this.progress.style.height = percentage + '%'; - this.progress.style.width = '100%'; - } else { - this.progress.style.height = '100%'; - this.progress.style.width = percentage + '%'; - } - this.percentage.innerText = percentage; + if (reader.generalSettings.val.pageReader) { + pageReader.movePage( + parseInt(pageReader.totalPages.val * Math.min(0.99, ratio)), + ); + return; } - if (this.lock) { + percentage.val = parseInt(ratio * 100); + if (lock) { window.scrollTo({ - top: this.reader.chapterHeight * ratio - this.reader.layoutHeight, + top: reader.chapterHeight * ratio - reader.layoutHeight, behavior: 'instant', }); } - if (this.reader.percentage) { - this.reader.percentage.innerText = percentage + '%'; - } }; - refresh = () => { - if (this.reader.verticalSeekbar) { - this.sliderHeight = this.slider.clientHeight; - this.sliderOffsetY = this.slider.getBoundingClientRect().top; - } else { - this.sliderHeight = this.slider.clientWidth; - this.sliderOffsetY = this.slider.getBoundingClientRect().left; - } - }; - onShow = () => { - this.reader.refresh(); - this.refresh(); - this.update(); - }; - onDOMCreation = progress => { - this.onShow(); - if (this.reader.pageReader) { - const totalPages = getPages(); - let page = Math.round((totalPages * progress) / 100); - setPage(page >= totalPages ? totalPages : page); - this.reader.chapter.style.transform = 'translate(-' + page * 100 + '%)'; - } else { - window.scrollTo({ - top: - (this.reader.chapterHeight * progress) / 100 - - this.reader.layoutHeight, - behavior: 'smooth', - }); - } - }; -} + window.addEventListener( + 'scroll', + () => !lock && !reader.generalSettings.val.pageReader && update(), + ); + return div( + { id: 'ScrollBar' }, + div( + { class: 'scrollbar-item scrollbar-text', id: 'scrollbar-percentage' }, + () => + reader.generalSettings.val.pageReader + ? pageReader.page.val + 1 + : percentage.val, + ), + div( + { class: 'scrollbar-item', id: 'scrollbar-slider' }, + div( + { id: 'scrollbar-track' }, + div( + { + id: 'scrollbar-progress', + style: () => { + const percentageValue = reader.generalSettings.val.pageReader + ? ((pageReader.page.val + 1) / pageReader.totalPages.val) * 100 + : percentage.val; + return horizontal.val + ? `width: ${percentageValue}%; height: 100%;` + : `height: ${percentageValue}%; width: 100%;`; + }, + }, + div( + { + id: 'scrollbar-thumb-wrapper', + ontouchstart: () => { + lock = true; + }, + ontouchend: () => { + lock = false; + }, + ontouchmove: function (e) { + const slider = this.parentElement.parentElement.parentElement; + const sliderHeight = horizontal.val + ? slider.clientWidth + : slider.clientHeight; + const sliderOffsetY = horizontal.val + ? slider.getBoundingClientRect().left + : slider.getBoundingClientRect().top; + const ratio = + ((horizontal.val + ? e.changedTouches[0].clientX + : e.changedTouches[0].clientY) - + sliderOffsetY) / + sliderHeight; + update(ratio < 0 ? 0 : ratio); + }, + }, + div({ id: 'scrollbar-thumb' }), + ), + ), + ), + ), + div( + { + class: 'scrollbar-item scrollbar-text', + id: 'scrollbar-percentage-max', + }, + () => + reader.generalSettings.val.pageReader ? pageReader.totalPages.val : 100, + ), + ); +}; -class SwipeHandler { - constructor() { - this.initialX = null; - this.initialY = null; - } +const ToolWrapper = () => { + const horizontal = van.derive( + () => !reader.generalSettings.val.verticalSeekbar, + ); + return div( + { + id: 'ToolWrapper', + class: () => + `${reader.hidden.val ? 'hidden' : ''} ${ + horizontal.val ? 'horizontal' : '' + }`, + }, + Scrollbar(), + ); +}; - touchStartHandler = e => { - this.initialX = e.changedTouches[0].screenX; - this.initialY = e.changedTouches[0].screenY; - }; - - /** - * @param {TouchEvent} e - */ - touchEndHandler = e => { - let diffX = e.changedTouches[0].screenX - this.initialX; - let diffY = e.changedTouches[0].screenY - this.initialY; - if ( - e.target.id?.startsWith('scrollbar') || - e.target.id === 'Image-Modal-img' - ) { - return; - } - if (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' }); - } - } - }; - - enable = () => { - document.addEventListener('touchstart', this.touchStartHandler); - document.addEventListener('touchend', this.touchEndHandler); - }; - - disable = () => { - document.removeEventListener('touchstart', this.touchStartHandler); - document.removeEventListener('touchend', this.touchEndHandler); - }; -} - -/** - * @type {import('./type').TextToSpeech} - */ - -// Walk through all leave -> convert each leaf to list of tts elements -> read through all tts elements -class TextToSpeech { - constructor(reader) { - this.reader = reader; - this.readableNodeNames = ['#text', 'B', 'I', 'SPAN', 'EM', 'BR', 'STRONG']; - this.leaf = this.reader.chapter; - this.reading = false; - this.started = false; - this.$ = document.getElementById('TTS-Controller'); - this.$.classList.add('d-none'); - this.$.onclick = () => { - if (this.reading) { - this.pause(); - } else { - this.resume(); - } - }; - } - - start = () => { - this.started = false; - this.$.innerHTML = ``; - this.$.classList.remove('d-none'); - this.stop(); - this.leaf = this.reader.chapter; - this.next(); - }; - - startHere = () => { - this.$.innerHTML = ``; - this.$.classList.remove('d-none'); - this.stop(); - if (this.reader.selection.type === 'Range') { - this.leaf = this.reader.selection.anchorNode; - while (this.readableNodeNames.includes(this.leaf.nodeName)) { - this.leaf = this.leaf.parentNode; - } - this.reader.selection.removeAllRanges(); - this.speak(); - } else { - this.next(); - } - }; - - resume = () => { - this.$.innerHTML = ``; - this.next(); - }; - - readable() { - if ( - this.leaf.nodeName !== 'SPAN' && - this.readableNodeNames.includes(this.leaf.nodeName) - ) { - return false; - } - if (!this.leaf.hasChildNodes()) { - return false; - } - for (let i = 0; i < this.leaf.childNodes.length; i++) { - if ( - !this.readableNodeNames.includes(this.leaf.childNodes.item(i).nodeName) - ) { - return false; - } - } - return true; - } - - findNextTextNode() { - do { - if (this.reader.chapter.isSameNode(this.leaf)) { - this.leaf = this.leaf.firstElementChild; - } else { - if (this.leaf.firstElementChild) { - this.leaf = this.leaf.firstElementChild; - } else if (this.leaf.nextElementSibling) { - this.leaf = this.leaf.nextElementSibling; - } else { - this.leaf = this.leaf.parentElement; +const ImageModal = ({ src }) => { + return div( + { + id: 'Image-Modal', + class: () => (src.val ? 'show' : ''), + onclick: e => { + if (e.target.id !== 'Image-Modal-img') { + e.stopPropagation(); + src.val = ''; } - } - } while (!this.readable()); - if (this.reader.chapter.isSameNode(this.leaf)) { - return; - } - } + }, + }, + img({ + id: 'Image-Modal-img', + src: src, + alt: () => (src.val ? `Cant not render image from ${src.val}` : ''), + }), + ); +}; - next = () => { - try { - this.leaf?.classList?.remove('highlight'); - if (this.reading) { - if (this.leaf.nextElementSibling) { - this.leaf = this.leaf.nextElementSibling; - } else { - this.findNextTextNode(); - } - } else if (!this.started) { - this.findNextTextNode(); - this.started = true; - } - this.speak(); - } catch (e) { - alert(e); - } - }; - - stop = () => { - this.reader.post({ type: 'stop-speak' }); - this.$.classList.add('d-none'); - this.leaf?.classList?.remove('highlight'); - this.reading = false; - }; - - pause = () => { - this.$.innerHTML = ``; - this.reading = false; - this.reader.post({ type: 'stop-speak' }); - }; - - speak = () => { - this.reading = true; - if (this.reader.chapter.isSameNode(this.leaf)) { - return; - } - this.leaf.classList.add('highlight'); - this.reader.post({ type: 'speak', data: this.leaf?.innerText }); - }; -} - -/** - * @type {import("./type").ContextMenu} - */ -class ContextMenu { - /** - * @param {import("./type").Reader} reader - */ - constructor(reader) { - this.reader = reader; - this.items = { - START_READING: this.renderItem({ - name: 'Start Reading', - icon: volumeIcon, - action: () => { - this.closeMenu(); - tts.start(); - }, - }), - START_HERE: this.renderItem({ - name: 'Start Here', - icon: selectVolumeIcon, - action: () => { - this.closeMenu(); - tts.startHere(); - }, - }), - COPY: this.renderItem({ - name: 'Copy', - icon: copyIcon, - action: () => { - this.closeMenu(); - this.reader.post({ - type: 'copy', - data: this.reader.selection.toString(), - }); - }, - }), - SELECT_ALL: this.renderItem({ - name: 'Select All', - icon: selectAllIcon, - action: () => { - const range = document.createRange(); - range.selectNodeContents(this.reader.chapter); - this.reader.selection.removeAllRanges(); - this.reader.selection.addRange(range); - }, - }), - }; - this.commonItems = [this.items.COPY, this.items.SELECT_ALL]; - this.contextMenu = document.createElement('ul'); - this.contextMenu.classList.add('contextMenu'); - this.isOpened = false; - } - - /** - * @param {{name: string, icon: string, action: () => void}} data - * @returns - */ - renderItem(data) { - const item = document.createElement('li'); - const button = document.createElement('button'); - button.innerHTML = `${data.icon} ${data.name}`; - button.classList.add('contextMenu-button'); - item.classList.add('contextMenu-item'); - item.appendChild(button); - button.addEventListener('click', event => { - event.stopPropagation(); - data.action(); - }); - return item; - } - - renderMenu(items) { - this.contextMenu.innerHTML = ''; - items.concat(this.commonItems).forEach((item, index) => { - item.firstChild.setAttribute('style', `animation-delay: ${index * 0.1}s`); - this.contextMenu.appendChild(item); - }); - } - - closeMenu() { - if (this.isOpened) { - this.isOpened = false; - this.contextMenu.remove(); - } - } - - init() { - this.reader.chapter.addEventListener('click', () => { - this.closeMenu(this.contextMenu); - }); - document.addEventListener('contextmenu', e => { - e.preventDefault(); - if (e.target instanceof HTMLImageElement) { - if (imageModal.showing) { - imageModal.hide(); - } else { - imageModal.show(e.target); - } - return; - } - this.isOpened = true; - if (this.reader.selection.type === 'Range') { - this.renderMenu([this.items.START_HERE, this.items.START_READING]); - } else { - this.renderMenu([this.items.START_READING]); - } - const { clientX, clientY } = e; - e.target.appendChild(this.contextMenu); - const positionY = - clientY + this.contextMenu.scrollHeight >= window.innerHeight - ? window.innerHeight - this.contextMenu.scrollHeight - 20 - : clientY; - const positionX = - clientX + this.contextMenu.scrollWidth >= window.innerWidth - ? window.innerWidth - this.contextMenu.scrollWidth - 20 - : clientX; - const page = getPage(); - this.contextMenu.setAttribute( - 'style', - `--width: ${this.contextMenu.scrollWidth}px; - --height: ${this.contextMenu.scrollHeight}px; - --top: ${positionY}px; - --left: ${positionX + window.innerWidth * page}px;`, - ); - }); - } -} - -/** - * @type {import("./type").ImageModal} - */ -class ImageModal { - /** - * @param {import('./type').Reader} reader - */ - constructor(reader) { - this.reader = reader; - this.$ = document.getElementById('Image-Modal'); - this.img = this.$.querySelector('img'); - this.showing = false; - this.$.onclick = e => { - if (e.target.id !== 'Image-Modal-img') { - e.stopPropagation(); - this.hide(); - } - }; - this.$.ontouchmove = e => { - e.stopImmediatePropagation(); - }; - } - - /** - * - * @param {HTMLImageElement} image - */ - show(image) { - this.img.src = image.src; - this.img.alt = `Can not render image from ${image.src}`; - this.reader.viewport.setAttribute( +const ModalWrapper = () => { + const imgSrc = van.state(''); + const showImage = src => { + imgSrc.val = src; + reader.viewport.setAttribute( 'content', 'width=device-width, initial-scale=1.0, maximum-scale=10', ); - this.$.classList.add('show'); - this.showing = true; - } - hide() { - this.$.classList.remove('show'); - this.showing = false; - this.img.src = ''; - this.img.alt = ''; - this.reader.viewport.setAttribute( + }; + const hideImage = () => { + imgSrc.val = ''; + reader.viewport.setAttribute( 'content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0', ); - } -} + }; -try { - var swipeHandler = new SwipeHandler(); - var toolWrapper = new ToolWrapper(); - var reader = new Reader(swipeHandler, toolWrapper); - var scrollHandler = new ScrollHandler(reader, toolWrapper); - var tts = new TextToSpeech(reader); - toolWrapper.tools = [scrollHandler, tts]; - var imageModal = new ImageModal(reader); - var contextMenu = new ContextMenu(reader); - contextMenu.init(); -} catch (e) { - alert(e); -} + document.addEventListener('contextmenu', e => { + if (e.target instanceof HTMLImageElement) { + if (!imgSrc.val) { + showImage(e.target.src); + } else { + hideImage(); + } + } + }); + return div(ImageModal({ src: imgSrc })); +}; + +const Footer = () => { + const percentage = van.state(0); + const time = van.state( + new Date().toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }), + ); + window.addEventListener('scroll', () => { + let ratio = (window.scrollY + reader.layoutHeight) / reader.chapterHeight; + if (ratio > 1) { + ratio = 1; + } + percentage.val = parseInt(ratio * 100); + }); + setInterval(() => { + time.val = new Date().toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + }, 10000); + return div( + { + id: 'reader-footer-wrapper', + class: () => + reader.generalSettings.val.showBatteryAndTime || + reader.generalSettings.val.showScrollPercentage + ? '' + : 'd-none', + }, + div( + { id: 'reader-footer' }, + + div( + { + id: 'reader-battery', + class: () => + `reader-footer-item ${ + reader.generalSettings.val.showBatteryAndTime ? '' : 'hidden' + }`, + }, + () => Math.ceil(reader.batteryLevel.val * 100) + '%', + ), + div( + { + id: 'reader-percentage', + class: () => + `reader-footer-item ${ + reader.generalSettings.val.showScrollPercentage ? '' : 'hidden' + }`, + }, + () => + reader.generalSettings.val.pageReader + ? `${pageReader.page.val + 1}/${pageReader.totalPages.val}` + : percentage.val + '%', + ), + div( + { + id: 'reader-time', + class: () => + `reader-footer-item ${ + reader.generalSettings.val.showBatteryAndTime ? '' : 'hidden' + }`, + }, + time, + ), + ), + ); +}; + +const TTSController = () => { + let controllerElement = null; + let hoverElement = null; + let clientX = null; + let clientY = null; + return div( + { + id: 'TTS-Controller', + class: () => + `${reader.generalSettings.val.TTSEnable === false ? 'hidden' : ''}`, + ontouchstart: () => { + if (!controllerElement) { + controllerElement = document.getElementById('TTS-Controller'); + } + controllerElement.classList.add('active'); + controllerElement.style.transition = ''; + }, + ontouchmove: e => { + e.preventDefault(); + e.stopPropagation(); + clientX = e.changedTouches[0].clientX; + clientY = e.changedTouches[0].clientY; + controllerElement.style.left = `${clientX}px`; + controllerElement.style.top = `${clientY}px`; + const hoverElements = document.elementsFromPoint(clientX, clientY); + const newHoverElement = hoverElements.reverse().find(e => { + if (e.id.includes('scrollbar')) { + return false; + } + return tts.readable(e); + }); + hoverElement?.classList.remove('highlight'); + if (newHoverElement) { + newHoverElement.classList.add('highlight'); + hoverElement = newHoverElement; + } else { + hoverElement = null; + } + }, + ontouchend: () => { + controllerElement.style.transition = '1s'; + controllerElement.classList.remove('active'); + controllerElement.style.left = '20px'; + if (clientX && clientY) { + let top = clientY < 120 ? 120 : clientY; + if (top + 120 > reader.layoutHeight) { + top = reader.layoutHeight - 120; + } + controllerElement.style.top = `${top}px`; + if (hoverElement) { + tts.start(hoverElement); + controllerElement.firstElementChild.innerHTML = pauseIcon; + } + } + clientX = null; + clientY = null; + }, + onclick: e => { + e.stopPropagation(); + if (tts.reading) { + tts.pause(); + controllerElement.firstElementChild.innerHTML = resumeIcon; + } else { + tts.resume(); + controllerElement.firstElementChild.innerHTML = pauseIcon; + } + }, + }, + button({ innerHTML: volumnIcon }), + ); +}; + +const ReaderUI = () => { + return div( + ToolWrapper(), + TTSController(), + ModalWrapper(), + Footer(), + ChapterEnding(), + ); +}; + +van.add(document.getElementById('reader-ui'), ReaderUI()); diff --git a/android/app/src/main/assets/js/setup.js b/android/app/src/main/assets/js/setup.js deleted file mode 100644 index ce897d33..00000000 --- a/android/app/src/main/assets/js/setup.js +++ /dev/null @@ -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(); - } - } -} diff --git a/android/app/src/main/assets/js/type.d.ts b/android/app/src/main/assets/js/type.d.ts deleted file mode 100644 index 64365f9c..00000000 --- a/android/app/src/main/assets/js/type.d.ts +++ /dev/null @@ -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; - 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; -} diff --git a/android/app/src/main/assets/js/van.d.ts b/android/app/src/main/assets/js/van.d.ts new file mode 100644 index 00000000..28bbddd4 --- /dev/null +++ b/android/app/src/main/assets/js/van.d.ts @@ -0,0 +1,70 @@ +import 'typescript'; +import 'typescript/lib/lib.dom'; +export interface State { + val: T; + readonly oldVal: T; + readonly rawVal: T; +} + +// Defining readonly view of State for covariance. +// Basically we want StateView to implement StateView +export type StateView = Readonly>; + +export type Val = State | T; + +export type Primitive = string | number | boolean | bigint; + +export type PropValue = Primitive | ((e: any) => void) | null; + +export type PropValueOrDerived = + | PropValue + | StateView + | (() => PropValue); + +export type Props = Record & { + class?: PropValueOrDerived; +}; + +export type PropsWithKnownKeys = 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 + | BindingFunc + | readonly ChildDom[]; + +export type TagFunc = ( + first?: (Props & PropsWithKnownKeys) | ChildDom, + ...rest: readonly ChildDom[] +) => Result; + +type Tags = Readonly>> & { + [K in keyof HTMLElementTagNameMap]: TagFunc; +}; + +declare function state(): State; +declare function state(initVal: T): State; + +export interface Van { + readonly state: typeof state; + readonly derive: (f: () => T) => State; + readonly add: (dom: Element, ...children: readonly ChildDom[]) => Element; + readonly tags: Tags & + ((namespaceURI: string) => Readonly>>); + readonly hydrate: ( + dom: T, + f: (dom: T) => T | null | undefined, + ) => T; +} + +declare global { + const van: Van; +} diff --git a/android/app/src/main/assets/js/van.js b/android/app/src/main/assets/js/van.js new file mode 100644 index 00000000..ae58a372 --- /dev/null +++ b/android/app/src/main/assets/js/van.js @@ -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;} diff --git a/package-lock.json b/package-lock.json index 982cf106..ba690a52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 4bab2a58..1308f099 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/hooks/persisted/useSettings.ts b/src/hooks/persisted/useSettings.ts index 32693378..372e0cf8 100644 --- a/src/hooks/persisted/useSettings.ts +++ b/src/hooks/persisted/useSettings.ts @@ -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 = { diff --git a/src/screens/reader/ChapterLoadingScreen/ChapterLoadingScreen.tsx b/src/screens/reader/ChapterLoadingScreen/ChapterLoadingScreen.tsx index 49dbe865..c83d8102 100644 --- a/src/screens/reader/ChapterLoadingScreen/ChapterLoadingScreen.tsx +++ b/src/screens/reader/ChapterLoadingScreen/ChapterLoadingScreen.tsx @@ -16,7 +16,7 @@ const ChapterLoadingScreen = () => { return ( { @@ -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} /> + setChapterGeneralSettings({ tapToScroll: !tapToScroll })} + value={tapToScroll} + theme={theme} + /> diff --git a/src/screens/reader/components/ReaderBottomSheet/ReaderValueChange.tsx b/src/screens/reader/components/ReaderBottomSheet/ReaderValueChange.tsx index 6602c44e..7bc84d85 100644 --- a/src/screens/reader/components/ReaderBottomSheet/ReaderValueChange.tsx +++ b/src/screens/reader/components/ReaderBottomSheet/ReaderValueChange.tsx @@ -17,6 +17,7 @@ interface ReaderValueChangeProps { decimals?: number; min?: number; max?: number; + unit?: string; } const ReaderValueChange: React.FC = ({ @@ -27,6 +28,7 @@ const ReaderValueChange: React.FC = ({ decimals = 1, min = 1.3, max = 2, + unit = '%', }) => { const theme = useTheme(); const { setChapterReaderSettings, ...settings } = useChapterReaderSettings(); @@ -50,7 +52,7 @@ const ReaderValueChange: React.FC = ({ theme={theme} /> - {`${((settings[valueKey] * 10) / 10).toFixed(decimals)}%`} + {`${((settings[valueKey] * 10) / 10).toFixed(decimals)}${unit}`} ; 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 = 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 = ({ + 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 = props => { initialChapterReaderSettings, [], ); - const { - showScrollPercentage, - swipeGestures, - showBatteryAndTime, - verticalSeekbar, - bionicReading, - } = useMemo( + const chapterGeneralSettings = useMemo( () => getMMKVObject(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 = 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 = props => { 'RNDeviceInfo_batteryLevelDidChange', (level: number) => { webViewRef.current?.injectJavaScript( - `reader.updateBatteryLevel(${level})`, + `reader.batteryLevel.val = ${level}`, ); }, ); @@ -132,21 +116,9 @@ const WebViewReader: React.FC = 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 ( = 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: ` - - - - - - ${ - pageReader - ? ` - - ` - : '' - } - - - - - -
- - ${html} - - -
- -
- -
- ${ - !pageReader - ? ` -
- ${getString( - 'readerScreen.finished', - )}: ${chapter.name.trim()} -
- ${ - nextChapter - ? `` - : `
- ${getString('readerScreen.noNextChapter')} -
` - }` - : '' - } - - - - - - - - - - - - `, + + + + + + + + + + +
+ ${html} +
+
+ + + + + + + + + + + `, }} /> ); diff --git a/src/screens/reader/hooks/useChapter.ts b/src/screens/reader/hooks/useChapter.ts index dfdae5a1..5807aa69 100644 --- a/src/screens/reader/hooks/useChapter.ts +++ b/src/screens/reader/hooks/useChapter.ts @@ -40,13 +40,8 @@ export default function useChapter(webViewRef: RefObject) { 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(); const { tracker } = useTracker(); @@ -102,11 +97,7 @@ export default function useChapter(webViewRef: RefObject) { }) .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) { 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); diff --git a/src/screens/reader/utils/sanitizeChapterText.ts b/src/screens/reader/utils/sanitizeChapterText.ts index cd8cda20..a6783404 100644 --- a/src/screens/reader/utils/sanitizeChapterText.ts +++ b/src/screens/reader/utils/sanitizeChapterText.ts @@ -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'); }; diff --git a/src/screens/settings/SettingsReaderScreen/Settings/GeneralSettings.tsx b/src/screens/settings/SettingsReaderScreen/Settings/GeneralSettings.tsx index 95b310ae..1e8b7e76 100644 --- a/src/screens/settings/SettingsReaderScreen/Settings/GeneralSettings.tsx +++ b/src/screens/settings/SettingsReaderScreen/Settings/GeneralSettings.tsx @@ -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} /> + setChapterGeneralSettings({ tapToScroll: !tapToScroll })} + theme={theme} + /> { labelStyle={labelStyle} label={getString('readerScreen.bottomSheet.padding')} valueKey="padding" - valueChange={0.5} - max={10} + valueChange={2} + min={0} + max={50} + decimals={0} + unit="px" /> Text to Speech -