Files
Rajarshee Chatterjee cbe7d70e2d fix: Restore Collapsed TTS Button Interaction
[skip ci]
2026-07-26 15:08:02 +05:30

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());