cbe7d70e2d
[skip ci]
582 lines
15 KiB
JavaScript
582 lines
15 KiB
JavaScript
const { div, p, img, button, span } = van.tags;
|
|
|
|
/**
|
|
* Registers a callback for the scrolled-through ratio, coalesced to one call
|
|
* per frame. Every scroll-driven indicator shares this: separate `scroll`
|
|
* listeners all re-run the same work on the same frames, and that shows up
|
|
* directly as scrolling smoothness.
|
|
*/
|
|
const onScrollRatio = (() => {
|
|
const callbacks = [];
|
|
let queued = false;
|
|
|
|
window.addEventListener(
|
|
'scroll',
|
|
() => {
|
|
if (queued) {
|
|
return;
|
|
}
|
|
queued = true;
|
|
requestAnimationFrame(() => {
|
|
queued = false;
|
|
const ratio = Math.min(
|
|
1,
|
|
(window.scrollY + reader.layoutHeight) / reader.chapterHeight,
|
|
);
|
|
for (const callback of callbacks) {
|
|
callback(ratio);
|
|
}
|
|
});
|
|
},
|
|
{ passive: true },
|
|
);
|
|
|
|
return callback => callbacks.push(callback);
|
|
})();
|
|
|
|
const ChapterEnding = () => {
|
|
return () =>
|
|
reader.generalSettings.val.pageReader
|
|
? div()
|
|
: div(div({ class: 'info-text' }, reader.strings.finished), () =>
|
|
// Reading `adjacentVersion` subscribes this binding to the adjacent
|
|
// chapters being pushed in after the chapter itself was rendered.
|
|
reader.adjacentVersion.val >= 0 && reader.nextChapter
|
|
? button(
|
|
{
|
|
class: 'next-button',
|
|
onclick: e => {
|
|
e.stopPropagation();
|
|
reader.post({ type: 'next' });
|
|
},
|
|
},
|
|
reader.strings.nextChapter,
|
|
)
|
|
: div({ class: 'info-text' }, reader.strings.noNextChapter),
|
|
);
|
|
};
|
|
|
|
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 + reader.layoutHeight) / reader.chapterHeight;
|
|
}
|
|
if (ratio > 1) {
|
|
ratio = 1;
|
|
}
|
|
if (reader.generalSettings.val.pageReader) {
|
|
pageReader.movePage(
|
|
parseInt(pageReader.totalPages.val * Math.min(0.99, ratio)),
|
|
);
|
|
return;
|
|
}
|
|
percentage.val = parseInt(ratio * 100);
|
|
if (lock) {
|
|
window.scrollTo({
|
|
top: reader.chapterHeight * ratio - reader.layoutHeight,
|
|
behavior: 'instant',
|
|
});
|
|
}
|
|
};
|
|
onScrollRatio(
|
|
ratio => !lock && !reader.generalSettings.val.pageReader && update(ratio),
|
|
);
|
|
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,
|
|
),
|
|
);
|
|
};
|
|
|
|
const ToolWrapper = () => {
|
|
const horizontal = van.derive(
|
|
() => !reader.generalSettings.val.verticalSeekbar,
|
|
);
|
|
return div(
|
|
{
|
|
id: 'ToolWrapper',
|
|
class: () =>
|
|
`${reader.hidden.val ? 'hidden' : ''} ${
|
|
horizontal.val ? 'horizontal' : ''
|
|
}`,
|
|
},
|
|
Scrollbar(),
|
|
);
|
|
};
|
|
|
|
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 = '';
|
|
}
|
|
},
|
|
},
|
|
img({
|
|
id: 'Image-Modal-img',
|
|
src: src,
|
|
alt: () => (src.val ? `Cant not render image from ${src.val}` : ''),
|
|
}),
|
|
);
|
|
};
|
|
|
|
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',
|
|
);
|
|
};
|
|
const hideImage = () => {
|
|
imgSrc.val = '';
|
|
reader.viewport.setAttribute(
|
|
'content',
|
|
'width=device-width, initial-scale=1.0, maximum-scale=1.0',
|
|
);
|
|
};
|
|
|
|
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,
|
|
}),
|
|
);
|
|
onScrollRatio(ratio => {
|
|
percentage.val = parseInt(ratio * 100);
|
|
});
|
|
setInterval(() => {
|
|
if (!reader.generalSettings.val.showBatteryAndTime) {
|
|
return;
|
|
}
|
|
time.val = new Date().toLocaleTimeString(undefined, {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
});
|
|
}, 10000);
|
|
const wrapper = 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 footerObserver = new ResizeObserver(() => {
|
|
document.documentElement.style.setProperty(
|
|
'--pageReader-footerHeight',
|
|
`${wrapper.offsetHeight}px`,
|
|
);
|
|
});
|
|
footerObserver.observe(wrapper);
|
|
requestAnimationFrame(() => {
|
|
document.documentElement.style.setProperty(
|
|
'--pageReader-footerHeight',
|
|
`${wrapper.offsetHeight}px`,
|
|
);
|
|
});
|
|
return wrapper;
|
|
};
|
|
|
|
const TTSController = () => {
|
|
let controllerElement = null;
|
|
let hoverElement = null;
|
|
let dragOffsetX = 0;
|
|
let dragOffsetY = 0;
|
|
let dragStartX = 0;
|
|
let dragStartY = 0;
|
|
let moved = false;
|
|
const collapsed = van.state(true);
|
|
let collapseButtonElement = null;
|
|
let lastBubbleTouchEnd = 0;
|
|
|
|
const stopEvent = e => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
const setControllerPosition = touch => {
|
|
const maxLeft = Math.max(
|
|
8,
|
|
window.innerWidth - controllerElement.offsetWidth - 8,
|
|
);
|
|
const maxTop = Math.max(
|
|
8,
|
|
window.innerHeight - controllerElement.offsetHeight - 8,
|
|
);
|
|
const left = Math.min(maxLeft, Math.max(8, touch.clientX - dragOffsetX));
|
|
const top = Math.min(maxTop, Math.max(8, touch.clientY - dragOffsetY));
|
|
|
|
controllerElement.style.left = `${left}px`;
|
|
controllerElement.style.top = `${top}px`;
|
|
controllerElement.style.right = 'auto';
|
|
controllerElement.style.bottom = 'auto';
|
|
};
|
|
|
|
const clampControllerToViewport = () => {
|
|
const bounds = controllerElement.getBoundingClientRect();
|
|
const maxLeft = Math.max(8, window.innerWidth - bounds.width - 8);
|
|
const maxTop = Math.max(8, window.innerHeight - bounds.height - 8);
|
|
|
|
controllerElement.style.left = `${Math.min(
|
|
maxLeft,
|
|
Math.max(8, bounds.left),
|
|
)}px`;
|
|
controllerElement.style.top = `${Math.min(
|
|
maxTop,
|
|
Math.max(8, bounds.top),
|
|
)}px`;
|
|
controllerElement.style.right = 'auto';
|
|
controllerElement.style.bottom = 'auto';
|
|
};
|
|
|
|
const setCollapsed = value => {
|
|
collapsed.val = value;
|
|
controllerElement ??= document.getElementById('TTS-Controller');
|
|
collapseButtonElement ??= controllerElement.querySelector(
|
|
'.tts-collapse-toggle',
|
|
);
|
|
collapseButtonElement.setAttribute(
|
|
'aria-label',
|
|
collapsed.val
|
|
? 'Expand text-to-speech controls'
|
|
: 'Minimize text-to-speech controls',
|
|
);
|
|
collapseButtonElement.innerHTML = collapsed.val
|
|
? textToSpeechIcon
|
|
: minimizeIcon;
|
|
requestAnimationFrame(clampControllerToViewport);
|
|
};
|
|
|
|
const startDrag = e => {
|
|
stopEvent(e);
|
|
controllerElement ??= document.getElementById('TTS-Controller');
|
|
const touch = e.changedTouches[0];
|
|
const bounds = controllerElement.getBoundingClientRect();
|
|
dragOffsetX = touch.clientX - bounds.left;
|
|
dragOffsetY = touch.clientY - bounds.top;
|
|
moved = false;
|
|
controllerElement.classList.add('active');
|
|
controllerElement.style.transition = 'none';
|
|
};
|
|
|
|
const moveDrag = e => {
|
|
stopEvent(e);
|
|
const touch = e.changedTouches[0];
|
|
|
|
moved = true;
|
|
setControllerPosition(touch);
|
|
|
|
const newHoverElement = document
|
|
.elementsFromPoint(touch.clientX, touch.clientY)
|
|
.find(
|
|
element =>
|
|
!element.closest('#TTS-Controller') &&
|
|
!element.id.includes('scrollbar') &&
|
|
tts.readable(element),
|
|
);
|
|
hoverElement?.classList.remove('highlight');
|
|
hoverElement = newHoverElement ?? null;
|
|
hoverElement?.classList.add('highlight');
|
|
};
|
|
|
|
const endDrag = e => {
|
|
stopEvent(e);
|
|
controllerElement.classList.remove('active');
|
|
controllerElement.style.transition = '';
|
|
|
|
if (moved && hoverElement && reader.generalSettings.val.TTSEnable) {
|
|
tts.start(hoverElement);
|
|
}
|
|
hoverElement?.classList.remove('highlight');
|
|
hoverElement = null;
|
|
moved = false;
|
|
};
|
|
|
|
const startBubbleDrag = e => {
|
|
if (!collapsed.val) {
|
|
return;
|
|
}
|
|
stopEvent(e);
|
|
controllerElement ??= document.getElementById('TTS-Controller');
|
|
const touch = e.changedTouches[0];
|
|
const bounds = controllerElement.getBoundingClientRect();
|
|
dragOffsetX = touch.clientX - bounds.left;
|
|
dragOffsetY = touch.clientY - bounds.top;
|
|
dragStartX = touch.clientX;
|
|
dragStartY = touch.clientY;
|
|
moved = false;
|
|
controllerElement.classList.add('active');
|
|
controllerElement.style.transition = 'none';
|
|
};
|
|
|
|
const moveBubbleDrag = e => {
|
|
if (!collapsed.val) {
|
|
return;
|
|
}
|
|
stopEvent(e);
|
|
const touch = e.changedTouches[0];
|
|
if (
|
|
!moved &&
|
|
Math.hypot(touch.clientX - dragStartX, touch.clientY - dragStartY) < 6
|
|
) {
|
|
return;
|
|
}
|
|
moved = true;
|
|
setControllerPosition(touch);
|
|
};
|
|
|
|
const finishBubbleDrag = () => {
|
|
controllerElement.classList.remove('active');
|
|
controllerElement.style.transition = '';
|
|
moved = false;
|
|
};
|
|
|
|
const endBubbleDrag = e => {
|
|
if (!collapsed.val) {
|
|
return;
|
|
}
|
|
stopEvent(e);
|
|
const shouldExpand = !moved;
|
|
lastBubbleTouchEnd = Date.now();
|
|
finishBubbleDrag();
|
|
if (shouldExpand) {
|
|
setCollapsed(false);
|
|
}
|
|
};
|
|
|
|
const cancelBubbleDrag = e => {
|
|
if (!collapsed.val) {
|
|
return;
|
|
}
|
|
stopEvent(e);
|
|
lastBubbleTouchEnd = Date.now();
|
|
finishBubbleDrag();
|
|
};
|
|
|
|
const toggleCollapsed = e => {
|
|
e.stopPropagation();
|
|
if (Date.now() - lastBubbleTouchEnd < 500) {
|
|
return;
|
|
}
|
|
setCollapsed(!collapsed.val);
|
|
};
|
|
|
|
const runCommand = command => e => {
|
|
e.stopPropagation();
|
|
if (reader.generalSettings.val.TTSEnable) {
|
|
command();
|
|
}
|
|
};
|
|
|
|
collapseButtonElement = button({
|
|
type: 'button',
|
|
class: 'tts-collapse-toggle',
|
|
'aria-label': 'Expand text-to-speech controls',
|
|
innerHTML: textToSpeechIcon,
|
|
ontouchstart: startBubbleDrag,
|
|
ontouchmove: moveBubbleDrag,
|
|
ontouchend: endBubbleDrag,
|
|
ontouchcancel: cancelBubbleDrag,
|
|
onclick: toggleCollapsed,
|
|
});
|
|
|
|
return div(
|
|
{
|
|
id: 'TTS-Controller',
|
|
class: () =>
|
|
[
|
|
reader.generalSettings.val.TTSEnable ? '' : 'hidden',
|
|
collapsed.val ? 'collapsed' : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' '),
|
|
style: () =>
|
|
reader.generalSettings.val.TTSEnable
|
|
? 'pointer-events: auto;'
|
|
: 'pointer-events: none; display: none !important; opacity: 0; transition: none;',
|
|
onclick: e => e.stopPropagation(),
|
|
},
|
|
button({
|
|
type: 'button',
|
|
class: 'tts-drag-handle',
|
|
'aria-label': 'Move text-to-speech controls',
|
|
innerHTML: dragHandleIcon,
|
|
ontouchstart: startDrag,
|
|
ontouchmove: moveDrag,
|
|
ontouchend: endDrag,
|
|
ontouchcancel: endDrag,
|
|
onclick: stopEvent,
|
|
}),
|
|
collapseButtonElement,
|
|
button({
|
|
type: 'button',
|
|
class: 'tts-control-button',
|
|
'aria-label': 'Previous paragraph',
|
|
innerHTML: previousParagraphIcon,
|
|
onclick: runCommand(() => tts.previous()),
|
|
}),
|
|
button({
|
|
id: 'TTS-PlayPause',
|
|
type: 'button',
|
|
class: 'tts-control-button tts-play-pause',
|
|
'aria-label': 'Play text-to-speech',
|
|
innerHTML: resumeIcon,
|
|
onclick: runCommand(() => {
|
|
if (tts.reading) {
|
|
tts.pause();
|
|
} else if (tts.started) {
|
|
tts.resume();
|
|
} else {
|
|
tts.start();
|
|
}
|
|
}),
|
|
}),
|
|
button({
|
|
type: 'button',
|
|
class: 'tts-control-button',
|
|
'aria-label': 'Next paragraph',
|
|
innerHTML: nextParagraphIcon,
|
|
onclick: runCommand(() => tts.next()),
|
|
}),
|
|
span({ id: 'TTS-Progress', 'aria-hidden': 'true' }),
|
|
);
|
|
};
|
|
|
|
const ReaderUI = () => {
|
|
return div(
|
|
ToolWrapper(),
|
|
TTSController(),
|
|
ModalWrapper(),
|
|
Footer(),
|
|
ChapterEnding(),
|
|
);
|
|
};
|
|
|
|
van.add(document.getElementById('reader-ui'), ReaderUI());
|