1607f80a0a
* feat: volume navigation and disable animation settings for paged reading * fix: transition style removal * fix: pageReader incorrect initialization * fix: remove unneeded check * chore: update Vietnamese translations for paged reading settings
1031 lines
30 KiB
JavaScript
1031 lines
30 KiB
JavaScript
window.onUserInteraction = (() => {
|
|
let groupStart = 0;
|
|
// Group multiple interactions within a short time frame to avoid excessive calls
|
|
const groupTime = 3000;
|
|
return () => {
|
|
let now = Date.now();
|
|
if (now - groupStart < groupTime) {
|
|
// We're inside the group
|
|
} else {
|
|
// We're the first in a while
|
|
groupStart = now;
|
|
reader.post({ type: 'interaction' });
|
|
}
|
|
};
|
|
})();
|
|
|
|
/* eslint-disable no-console */
|
|
window.reader = new (function () {
|
|
const {
|
|
readerSettings,
|
|
chapterGeneralSettings,
|
|
novel,
|
|
chapter,
|
|
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);
|
|
/**
|
|
* Bumped whenever the app pushes new adjacent chapters. The chapter is
|
|
* rendered before its neighbours are known, so any UI that depends on them
|
|
* has to read this state to re-render when they arrive.
|
|
*/
|
|
this.adjacentVersion = van.state(0);
|
|
|
|
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 = undefined;
|
|
this.prevChapter = undefined;
|
|
this.strings = strings;
|
|
|
|
/** Called by the app once the neighbouring chapters have been resolved. */
|
|
this.setAdjacentChapters = ({ nextChapter, prevChapter, strings: texts }) => {
|
|
this.nextChapter = nextChapter ?? undefined;
|
|
this.prevChapter = prevChapter ?? undefined;
|
|
Object.assign(this.strings, texts);
|
|
this.adjacentVersion.val++;
|
|
};
|
|
this.autoSaveInterval = autoSaveInterval;
|
|
this.rawHTML = this.chapterElement.innerHTML;
|
|
|
|
//layout props
|
|
this.paddingTop = parseInt(
|
|
getComputedStyle(document.querySelector('body')).getPropertyValue(
|
|
'padding-top',
|
|
),
|
|
10,
|
|
);
|
|
this.chapterHeight = this.chapterElement.scrollHeight + this.paddingTop;
|
|
this.layoutHeight = window.innerHeight;
|
|
this.layoutWidth = window.innerWidth;
|
|
|
|
this.layoutEvent = undefined;
|
|
this.chapterEndingVisible = van.state(false);
|
|
|
|
this.post = obj => window.ReactNativeWebView.postMessage(JSON.stringify(obj));
|
|
this.refresh = () => {
|
|
this.layoutHeight = window.innerHeight;
|
|
this.layoutWidth = window.innerWidth;
|
|
this.paddingTop =
|
|
parseFloat(
|
|
getComputedStyle(document.body).getPropertyValue('padding-top'),
|
|
) || 0;
|
|
if (this.generalSettings.val.pageReader) {
|
|
this.chapterWidth = this.chapterElement.scrollWidth;
|
|
} else {
|
|
this.chapterHeight = this.chapterElement.scrollHeight + this.paddingTop;
|
|
}
|
|
};
|
|
|
|
let loadedFontFamily = readerSettings.fontFamily || '';
|
|
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 && settings.fontFamily !== loadedFontFamily) {
|
|
loadedFontFamily = settings.fontFamily;
|
|
new FontFace(
|
|
settings.fontFamily,
|
|
'url("file:///android_asset/fonts/' + settings.fontFamily + '.ttf")',
|
|
)
|
|
.load()
|
|
.then(function (loadedFont) {
|
|
document.fonts.add(loadedFont);
|
|
schedulePageCalculation();
|
|
});
|
|
} else if (!settings.fontFamily && loadedFontFamily) {
|
|
loadedFontFamily = '';
|
|
// have no affect with a font declared in head
|
|
document.fonts.forEach(fontFace => document.fonts.delete(fontFace));
|
|
}
|
|
schedulePageCalculation();
|
|
});
|
|
|
|
document.onscrollend = () => {
|
|
onUserInteraction();
|
|
if (!this.generalSettings.val.pageReader) {
|
|
this.post({
|
|
type: 'save',
|
|
data: parseInt(
|
|
((window.scrollY + this.layoutHeight) / this.chapterHeight) * 100,
|
|
10,
|
|
),
|
|
});
|
|
}
|
|
};
|
|
|
|
document.onpointerdown = () => onUserInteraction();
|
|
document.onpointermove = () => onUserInteraction();
|
|
document.onpointerup = () => onUserInteraction();
|
|
|
|
if (DEBUG) {
|
|
// eslint-disable-next-line no-global-assign, no-new-object
|
|
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',
|
|
'MARK',
|
|
];
|
|
this.prevElement = null;
|
|
this.currentElement = reader.chapterElement;
|
|
this.started = false;
|
|
this.reading = false;
|
|
this.elementsRead = 0;
|
|
this.totalElements = 0;
|
|
this.allReadableElements = []; // Store all readable elements at start
|
|
this.textQueue = []; // Flat list of normalized text for native fallback
|
|
|
|
this.readable = element => {
|
|
const 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;
|
|
};
|
|
|
|
this.normalizeText = text => {
|
|
if (!text) return '';
|
|
const normalized = text
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.replace(/^["'“”‘’]+|["'“”‘’]+$/g, '')
|
|
.replace(/\s*([.,!?;:])\s*/g, '$1 ')
|
|
.trim();
|
|
|
|
const dashOnlyText = normalized.replace(/\s/g, '');
|
|
if (
|
|
dashOnlyText.length >= 3 &&
|
|
/^[\-‐‑‒–—―−⁓⸺⸻﹘﹣-]+$/u.test(dashOnlyText)
|
|
) {
|
|
return '';
|
|
}
|
|
|
|
return normalized;
|
|
};
|
|
|
|
// if can find a readable node, else stop tts
|
|
// FIXED: Added proper boundary checks to prevent stack overflow
|
|
this.findNextTextNode = (depth = 0) => {
|
|
// Prevent deep recursion
|
|
if (depth > 500) {
|
|
console.warn('TTS: findNextTextNode max depth reached');
|
|
return false;
|
|
}
|
|
|
|
if (this.currentElement.isSameNode(reader.chapterElement) && this.started) {
|
|
return false;
|
|
} else {
|
|
this.started = true;
|
|
}
|
|
|
|
// Safety check: ensure currentElement is valid
|
|
if (!this.currentElement || !this.currentElement.nodeName) {
|
|
return false;
|
|
}
|
|
|
|
// is read, have to go next or go back
|
|
if (this.currentElement.isSameNode(this.prevElement)) {
|
|
this.prevElement = this.currentElement;
|
|
if (this.currentElement.nextElementSibling) {
|
|
this.currentElement = this.currentElement.nextElementSibling;
|
|
return this.findNextTextNode(depth + 1);
|
|
} else if (
|
|
this.currentElement.parentElement &&
|
|
!this.currentElement.parentElement.isSameNode(document.body) &&
|
|
!this.currentElement.parentElement.isSameNode(document.documentElement)
|
|
) {
|
|
this.currentElement = this.currentElement.parentElement;
|
|
return this.findNextTextNode(depth + 1);
|
|
} else {
|
|
return false;
|
|
}
|
|
} else {
|
|
// can read? read it
|
|
if (this.readable()) {
|
|
return true;
|
|
}
|
|
if (
|
|
!this.prevElement?.parentElement?.isSameNode(this.currentElement) &&
|
|
this.currentElement.firstElementChild
|
|
) {
|
|
// go deep
|
|
this.prevElement = this.currentElement;
|
|
this.currentElement = this.currentElement.firstElementChild;
|
|
return this.findNextTextNode(depth + 1);
|
|
} else if (this.currentElement.nextElementSibling) {
|
|
this.prevElement = this.currentElement;
|
|
this.currentElement = this.currentElement.nextElementSibling;
|
|
return this.findNextTextNode(depth + 1);
|
|
} else if (
|
|
this.currentElement.parentElement &&
|
|
!this.currentElement.parentElement.isSameNode(document.body) &&
|
|
!this.currentElement.parentElement.isSameNode(document.documentElement)
|
|
) {
|
|
this.prevElement = this.currentElement;
|
|
this.currentElement = this.currentElement.parentElement;
|
|
return this.findNextTextNode(depth + 1);
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
};
|
|
|
|
this.next = () => {
|
|
if (!this.started) return;
|
|
reader.post({ type: 'tts-command', data: { command: 'next' } });
|
|
};
|
|
|
|
this.previous = () => {
|
|
if (!this.started) return;
|
|
reader.post({ type: 'tts-command', data: { command: 'previous' } });
|
|
};
|
|
|
|
this.start = element => {
|
|
const startElement = element ?? reader.chapterElement;
|
|
|
|
const readableEntries = this.getAllReadableElements(reader.chapterElement)
|
|
.map(readableElement => ({
|
|
element: readableElement,
|
|
text: this.normalizeText(readableElement.innerText),
|
|
}))
|
|
.filter(entry => !!entry.text);
|
|
this.allReadableElements = readableEntries.map(entry => entry.element);
|
|
this.totalElements = this.allReadableElements.length;
|
|
this.textQueue = readableEntries.map(entry => entry.text);
|
|
|
|
const requestedIndex =
|
|
element && element !== reader.chapterElement
|
|
? this.allReadableElements.indexOf(startElement)
|
|
: 0;
|
|
const startIndex = requestedIndex >= 0 ? requestedIndex : 0;
|
|
|
|
this.started = this.totalElements > 0;
|
|
this.reading = this.started;
|
|
this.setActiveIndex(startIndex);
|
|
reader.post({
|
|
type: 'tts-queue',
|
|
data: {
|
|
queue: this.textQueue,
|
|
startIndex,
|
|
},
|
|
});
|
|
};
|
|
|
|
// Get all readable elements in order
|
|
this.getAllReadableElements = element => {
|
|
const elements = [];
|
|
const traverse = el => {
|
|
if (!el) return;
|
|
if (this.readable(el)) {
|
|
elements.push(el);
|
|
// innerText already includes readable descendants, so descending any
|
|
// further would add overlapping text to the speech queue.
|
|
return;
|
|
}
|
|
for (let i = 0; i < el.children.length; i++) {
|
|
traverse(el.children[i]);
|
|
}
|
|
};
|
|
traverse(element);
|
|
return elements;
|
|
};
|
|
|
|
this.resume = () => {
|
|
if (!this.started) return;
|
|
reader.post({ type: 'tts-command', data: { command: 'play' } });
|
|
};
|
|
|
|
this.pause = () => {
|
|
if (!this.started) return;
|
|
reader.post({ type: 'tts-command', data: { command: 'pause' } });
|
|
};
|
|
|
|
this.rewind = () => {
|
|
if (!this.started) return;
|
|
reader.post({ type: 'tts-command', data: { command: 'replay' } });
|
|
};
|
|
|
|
this.seekTo = index => {
|
|
if (!this.started || !this.allReadableElements.length) return;
|
|
const targetIndex = Math.max(0, Math.min(index, this.totalElements - 1));
|
|
reader.post({
|
|
type: 'tts-command',
|
|
data: { command: 'seekTo', index: targetIndex },
|
|
});
|
|
};
|
|
|
|
this.stop = () => {
|
|
reader.post({ type: 'tts-command', data: { command: 'stop' } });
|
|
this.reset();
|
|
};
|
|
|
|
this.reset = () => {
|
|
this.currentElement?.classList?.remove('highlight');
|
|
this.prevElement = null;
|
|
this.currentElement = reader.chapterElement;
|
|
this.started = false;
|
|
this.reading = false;
|
|
this.elementsRead = 0;
|
|
this.totalElements = 0;
|
|
this.allReadableElements = [];
|
|
this.textQueue = [];
|
|
const playPauseButton = document.getElementById('TTS-PlayPause');
|
|
if (playPauseButton) playPauseButton.innerHTML = resumeIcon;
|
|
const progress = document.getElementById('TTS-Progress');
|
|
if (progress) progress.textContent = '';
|
|
};
|
|
|
|
this.setActiveIndex = index => {
|
|
if (!this.allReadableElements.length) return;
|
|
const targetIndex = Math.max(0, Math.min(index, this.totalElements - 1));
|
|
this.currentElement?.classList?.remove('highlight');
|
|
this.currentElement = this.allReadableElements[targetIndex];
|
|
this.elementsRead = targetIndex + 1;
|
|
this.started = true;
|
|
this.scrollToElement(this.currentElement);
|
|
this.currentElement.classList.add('highlight');
|
|
const progress = document.getElementById('TTS-Progress');
|
|
if (progress) {
|
|
progress.textContent = `${targetIndex + 1}/${this.totalElements}`;
|
|
}
|
|
};
|
|
|
|
this.setPlaybackState = state => {
|
|
this.reading = state === 'playing';
|
|
if (state === 'error') {
|
|
this.reset();
|
|
return;
|
|
}
|
|
const playPauseButton = document.getElementById('TTS-PlayPause');
|
|
if (playPauseButton) {
|
|
playPauseButton.innerHTML = this.reading ? pauseIcon : resumeIcon;
|
|
}
|
|
};
|
|
|
|
this.complete = () => {
|
|
this.reading = false;
|
|
if (
|
|
reader.readerSettings.val.tts?.autoPageAdvance === true &&
|
|
reader.nextChapter
|
|
) {
|
|
reader.post({ type: 'next', autoStartTTS: true });
|
|
return;
|
|
}
|
|
this.reset();
|
|
};
|
|
|
|
this.isElementInViewport = element => {
|
|
if (!element) return false;
|
|
const rect = element.getBoundingClientRect();
|
|
const windowHeight =
|
|
window.innerHeight || document.documentElement.clientHeight;
|
|
const windowWidth =
|
|
window.innerWidth || document.documentElement.clientWidth;
|
|
|
|
return (
|
|
rect.top >= 0 &&
|
|
rect.left >= 0 &&
|
|
rect.bottom <= windowHeight &&
|
|
rect.right <= windowWidth
|
|
);
|
|
};
|
|
|
|
// UPDATED: Scroll to top or center based on settings with padding for notch/camera
|
|
this.scrollToElement = element => {
|
|
if (!element) return;
|
|
// Check if element is partially visible (at least some part is in viewport)
|
|
const rect = element.getBoundingClientRect();
|
|
if (reader.generalSettings.val.pageReader) {
|
|
const relativePage = Math.floor(
|
|
(rect.left + rect.width / 2) / reader.layoutWidth,
|
|
);
|
|
pageReader.movePage(
|
|
Math.max(
|
|
0,
|
|
Math.min(
|
|
pageReader.totalPages.val - 1,
|
|
pageReader.page.val + relativePage,
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
const windowHeight =
|
|
window.innerHeight || document.documentElement.clientHeight;
|
|
const isPartiallyVisible =
|
|
rect.top < windowHeight &&
|
|
rect.bottom > 0 &&
|
|
rect.left < window.innerWidth &&
|
|
rect.right > 0;
|
|
|
|
// Only scroll if element is not visible or barely visible
|
|
if (!isPartiallyVisible || rect.top < 0 || rect.bottom > windowHeight) {
|
|
// Check scrollToTop setting (default to true for better reading experience)
|
|
const scrollToTop = reader.readerSettings.val.tts?.scrollToTop !== false;
|
|
|
|
if (scrollToTop) {
|
|
// Scroll to top with padding for notch/camera (80px from top)
|
|
const elementTop =
|
|
element.getBoundingClientRect().top + window.pageYOffset;
|
|
const offsetPosition = elementTop - 80; // 80px padding for notch/camera
|
|
|
|
window.scrollTo({
|
|
top: offsetPosition,
|
|
behavior: 'smooth',
|
|
});
|
|
} else {
|
|
// Center scroll (original behavior)
|
|
element.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'center',
|
|
inline: 'nearest',
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
this.speak = () => {
|
|
this.rewind();
|
|
};
|
|
})();
|
|
|
|
// Watch for TTSEnable changes and stop TTS when disabled
|
|
van.derive(() => {
|
|
if (!reader.generalSettings.val.TTSEnable && window.tts) {
|
|
if (tts.reading || tts.started) {
|
|
tts.stop();
|
|
}
|
|
}
|
|
});
|
|
|
|
var pageReader;
|
|
window.pageReader = pageReader = new (function () {
|
|
const config =
|
|
typeof initialPageReaderConfig === 'undefined'
|
|
? {}
|
|
: initialPageReaderConfig;
|
|
this.page = van.state(0);
|
|
this.totalPages = van.state(0);
|
|
this.ignoreClickUntil = 0;
|
|
this.chapterNavigationPending = false;
|
|
this.chapterEndingVisible = van.state(
|
|
config.nextChapterScreenVisible === true,
|
|
);
|
|
this.chapterEnding = document.getElementsByClassName('transition-chapter')[0];
|
|
|
|
this.showChapterEnding = (bool, instant, left) => {
|
|
if (!this.chapterEnding) {
|
|
this.chapterEnding =
|
|
document.getElementsByClassName('transition-chapter')[0];
|
|
if (!this.chapterEnding) return;
|
|
}
|
|
const noAnim = Boolean(
|
|
reader.generalSettings?.val?.pageReaderDisableAnimation,
|
|
);
|
|
this.chapterEnding.style.transition = 'unset';
|
|
if (bool) {
|
|
this.chapterEnding.style.transform = `translateX(${left ? -200 : 0}vw)`;
|
|
requestAnimationFrame(() => {
|
|
if (!instant && !noAnim) {
|
|
this.chapterEnding.style.transition = 'transform 200ms';
|
|
}
|
|
this.chapterEnding.style.transform = 'translateX(-100vw)';
|
|
});
|
|
this.chapterEndingVisible.val = true;
|
|
} else {
|
|
if (!instant && !noAnim) {
|
|
this.chapterEnding.style.transition = 'transform 200ms';
|
|
}
|
|
this.chapterEnding.style.transform = `translateX(${left ? -200 : 0}vw)`;
|
|
this.chapterEndingVisible.val = false;
|
|
}
|
|
};
|
|
|
|
this.movePage = (destPage, { interaction = true, save = true } = {}) => {
|
|
if (interaction) {
|
|
onUserInteraction();
|
|
}
|
|
if (this.chapterEndingVisible.val) {
|
|
if (this.chapterNavigationPending) {
|
|
return;
|
|
}
|
|
if (destPage < 0) {
|
|
this.showChapterEnding(false);
|
|
return;
|
|
}
|
|
this.showChapterEnding(false, false, true);
|
|
return;
|
|
}
|
|
destPage = parseInt(destPage, 10);
|
|
const noAnim = Boolean(
|
|
reader.generalSettings?.val?.pageReaderDisableAnimation,
|
|
);
|
|
if (destPage < 0) {
|
|
if (!reader.prevChapter) return;
|
|
document.getElementsByClassName('transition-chapter')[0].innerText =
|
|
reader.prevChapter.name;
|
|
this.showChapterEnding(true, noAnim, true);
|
|
this.chapterNavigationPending = true;
|
|
setTimeout(
|
|
() => {
|
|
reader.post({ type: 'prev' });
|
|
},
|
|
noAnim ? 0 : 200,
|
|
);
|
|
return;
|
|
}
|
|
if (destPage >= this.totalPages.val) {
|
|
if (!reader.nextChapter) return;
|
|
document.getElementsByClassName('transition-chapter')[0].innerText =
|
|
reader.nextChapter.name;
|
|
this.showChapterEnding(true, noAnim);
|
|
this.chapterNavigationPending = true;
|
|
setTimeout(
|
|
() => {
|
|
reader.post({ type: 'next' });
|
|
},
|
|
noAnim ? 0 : 200,
|
|
);
|
|
return;
|
|
}
|
|
this.page.val = destPage;
|
|
reader.chapterElement.style.transform =
|
|
'translateX(-' + destPage * 100 + '%)';
|
|
|
|
const newProgress = parseInt(
|
|
((this.page.val + 1) / this.totalPages.val) * 100,
|
|
10,
|
|
);
|
|
|
|
if (save && newProgress > reader.chapter.progress) {
|
|
reader.chapter.progress = newProgress;
|
|
reader.post({
|
|
type: 'save',
|
|
data: newProgress,
|
|
});
|
|
}
|
|
};
|
|
|
|
this.repaginate = ratio => {
|
|
const previousTotal = this.totalPages.val;
|
|
const currentRatio =
|
|
previousTotal > 0 ? (this.page.val + 1) / previousTotal : 0;
|
|
const positionRatio = Number.isFinite(ratio)
|
|
? ratio
|
|
: currentRatio || reader.chapter.progress / 100;
|
|
|
|
reader.refresh();
|
|
const chapterStyle = getComputedStyle(reader.chapterElement);
|
|
const horizontalPadding =
|
|
(parseFloat(chapterStyle.paddingLeft) || 0) +
|
|
(parseFloat(chapterStyle.paddingRight) || 0);
|
|
this.totalPages.val = Math.max(
|
|
1,
|
|
Math.floor(
|
|
(reader.chapterWidth + horizontalPadding) /
|
|
(reader.chapterElement.clientWidth || reader.layoutWidth) +
|
|
0.001,
|
|
),
|
|
);
|
|
|
|
if (this.chapterEndingVisible.val) {
|
|
return;
|
|
}
|
|
|
|
const destination = Math.min(
|
|
this.totalPages.val - 1,
|
|
Math.max(
|
|
0,
|
|
Math.round(
|
|
this.totalPages.val * Math.min(1, Math.max(0, positionRatio)),
|
|
) - 1,
|
|
),
|
|
);
|
|
this.movePage(destination, { interaction: false, save: false });
|
|
};
|
|
|
|
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');
|
|
requestAnimationFrame(() => this.repaginate(ratio));
|
|
} else {
|
|
const ratio =
|
|
this.totalPages.val > 0 ? (this.page.val + 1) / this.totalPages.val : 0;
|
|
reader.chapterElement.style.removeProperty('transform');
|
|
reader.chapterElement.style.removeProperty('transition');
|
|
document.body.classList.remove('page-reader');
|
|
requestAnimationFrame(() => {
|
|
reader.refresh();
|
|
window.scrollTo({
|
|
top: reader.chapterHeight * ratio - reader.layoutHeight,
|
|
behavior: 'smooth',
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
van.derive(() => {
|
|
document.body.classList.toggle(
|
|
'no-animation',
|
|
Boolean(reader.generalSettings.val.pageReaderDisableAnimation),
|
|
);
|
|
reader.chapterElement?.style.removeProperty('transition');
|
|
const chapterEnding =
|
|
this.chapterEnding ||
|
|
document.getElementsByClassName('transition-chapter')[0];
|
|
chapterEnding?.style.removeProperty('transition');
|
|
});
|
|
})();
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
if (pageReader.chapterEndingVisible.val) {
|
|
pageReader.showChapterEnding(true, true);
|
|
}
|
|
});
|
|
|
|
/** Scroll offset the reading position was restored to, in scroll mode. */
|
|
let restoredScrollTop = null;
|
|
let positionRestored = false;
|
|
|
|
function calculatePages(behavior = 'instant') {
|
|
reader.refresh();
|
|
|
|
if (reader.generalSettings.val.pageReader) {
|
|
pageReader.repaginate(reader.chapter.progress / 100);
|
|
} else {
|
|
restoredScrollTop =
|
|
(reader.chapterHeight * reader.chapter.progress) / 100 -
|
|
reader.layoutHeight;
|
|
window.scrollTo({ top: restoredScrollTop, behavior });
|
|
}
|
|
}
|
|
|
|
let pageCalculationFrame;
|
|
let pendingPageRatio;
|
|
function schedulePageCalculation(ratio) {
|
|
if (Number.isFinite(ratio)) {
|
|
pendingPageRatio = ratio;
|
|
}
|
|
if (
|
|
pageCalculationFrame ||
|
|
!window.pageReader ||
|
|
!reader.generalSettings.val.pageReader
|
|
) {
|
|
return;
|
|
}
|
|
pageCalculationFrame = requestAnimationFrame(() => {
|
|
pageCalculationFrame = undefined;
|
|
const nextRatio = pendingPageRatio;
|
|
pendingPageRatio = undefined;
|
|
pageReader.repaginate(nextRatio);
|
|
});
|
|
}
|
|
|
|
const ro = new ResizeObserver(() => {
|
|
if (pageReader.totalPages.val && reader.generalSettings.val.pageReader) {
|
|
schedulePageCalculation();
|
|
}
|
|
});
|
|
ro.observe(reader.chapterElement);
|
|
reader.chapterElement.addEventListener(
|
|
'load',
|
|
() => schedulePageCalculation(),
|
|
true,
|
|
);
|
|
|
|
let viewportResizeTimer;
|
|
window.addEventListener('resize', () => {
|
|
clearTimeout(viewportResizeTimer);
|
|
viewportResizeTimer = setTimeout(() => {
|
|
if (reader.generalSettings.val.pageReader) {
|
|
schedulePageCalculation();
|
|
} else {
|
|
reader.refresh();
|
|
}
|
|
}, 100);
|
|
});
|
|
|
|
/**
|
|
* Fonts and images can still change the chapter height after the position was
|
|
* restored. Correct it when that happens - but only while the reader is sitting
|
|
* exactly where it was put, otherwise this would yank the page from under
|
|
* someone who has already started reading.
|
|
*/
|
|
const correctReadingPosition = () => {
|
|
if (reader.generalSettings.val.pageReader || restoredScrollTop === null) {
|
|
return;
|
|
}
|
|
|
|
const previousHeight = reader.chapterHeight;
|
|
reader.refresh();
|
|
if (
|
|
reader.chapterHeight !== previousHeight &&
|
|
Math.abs(window.scrollY - Math.max(0, restoredScrollTop)) < 4
|
|
) {
|
|
calculatePages();
|
|
}
|
|
};
|
|
|
|
const restoreReadingPosition = () => {
|
|
requestAnimationFrame(() =>
|
|
setTimeout(() => {
|
|
positionRestored = true;
|
|
calculatePages();
|
|
// Deliberately not awaited before restoring: `document.fonts.ready` does
|
|
// not resolve until the document has finished loading, which is what this
|
|
// is trying to avoid waiting for.
|
|
document.fonts.ready.then(correctReadingPosition);
|
|
}, 0),
|
|
);
|
|
};
|
|
|
|
// Restore as soon as the chapter itself is parsed and styled. Waiting for
|
|
// `load` means waiting for every image in the chapter, which can take seconds -
|
|
// the reader would sit at the top of the chapter until then.
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', restoreReadingPosition, {
|
|
once: true,
|
|
});
|
|
} else {
|
|
restoreReadingPosition();
|
|
}
|
|
|
|
window.addEventListener('load', () => {
|
|
if (!positionRestored) {
|
|
restoreReadingPosition();
|
|
return;
|
|
}
|
|
|
|
if (reader.generalSettings.val.pageReader) {
|
|
schedulePageCalculation();
|
|
} else {
|
|
correctReadingPosition();
|
|
}
|
|
});
|
|
|
|
// 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 => {
|
|
if (Date.now() < pageReader.ignoreClickUntil) {
|
|
return;
|
|
}
|
|
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.transition = 'unset';
|
|
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) {
|
|
if (Math.abs(diffX) > 8 || Math.abs(diffY) > 8) {
|
|
pageReader.ignoreClickUntil = Date.now() + 400;
|
|
}
|
|
reader.chapterElement.style.transition = reader.generalSettings.val
|
|
.pageReaderDisableAnimation
|
|
? 'none'
|
|
: 'transform 200ms';
|
|
reader.chapterElement.style.removeProperty('transition');
|
|
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 (
|
|
diffY > 80 &&
|
|
Math.abs(diffY) > Math.abs(diffX) * 2 &&
|
|
window.scrollY <= 0
|
|
) {
|
|
e.preventDefault();
|
|
reader.post({ type: 'refresh' });
|
|
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 () {
|
|
// What the chapter element currently holds. The document is delivered with
|
|
// the untransformed chapter already parsed, so writing the same markup back
|
|
// would re-parse and re-layout the whole chapter (and restart image loads)
|
|
// for nothing - which is exactly what happens when neither transform is on.
|
|
let appliedHTML = reader.rawHTML;
|
|
|
|
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*|[\u200b]\s*)+(?=<\/?p[> ])/g, '')
|
|
.replace(/<br>\s*<br>\s*(?:<br>\s*)+/g, '<br><br>') //force max 2 consecutive <br>, chaining regex
|
|
.replace(
|
|
/<br>\s*<br>[^]+/,
|
|
_ =>
|
|
`${
|
|
/\/p>/.test(_)
|
|
? _.replace(
|
|
/<br>\s*<br>(?:(?=\s*<\/?p[> ])|(?<=<\/?p(?:>| [^>]+>)<br>\s*<br>))\s*/g,
|
|
'',
|
|
)
|
|
: _
|
|
}`,
|
|
) //if p found, delete all double br near p
|
|
.replace(
|
|
/<br>(?:(?=\s*<\/?p[> ])|(?<=<\/?p(?:>| [^>]+>)(?:<[^>]+>)*\s*<br>))\s*/g,
|
|
'',
|
|
);
|
|
}
|
|
if (html === appliedHTML) {
|
|
return;
|
|
}
|
|
|
|
reader.chapterElement.innerHTML = html;
|
|
appliedHTML = html;
|
|
reader.refresh();
|
|
schedulePageCalculation();
|
|
|
|
// Replacing the markup dropped the highlights, so restore the search.
|
|
const searchQuery = window.readerSearch?.query;
|
|
const searchIndex = window.readerSearch?.index;
|
|
if (searchQuery) {
|
|
window.readerSearch.search(searchQuery, searchIndex);
|
|
}
|
|
});
|
|
})();
|