**Add continuous reading mode and enhance accessibility in TheComicReader**

- Introduced a continuous reading mode ("scroll"), allowing users to read without pagination.
- Implemented a toggle in the UI to switch between "paged" and "continuous" modes, with preferences saved in `localStorage`.
- Enhanced keyboard navigation by throttling repeated actions and ignoring irrelevant keypresses during form interactions.
- Integrated `IntersectionObserver` to handle lazy-loading of images and dynamically extending rendered content in continuous mode.
- Updated tests to validate both reading modes, ensuring proper state management and observer handling.
- Adjusted animations and transitions for smoother visual feedback in both modes, respecting users' motion-reduction preferences.
This commit is contained in:
ajurna committed 2026-10-16 09:30:46 +01:00
1 parent 494b64621e
commit 80566e657e
6 files changed
+647 -92

No files matched your search

+331 -78
View File
@@ -1,19 +1,51 @@
<template>
<div class="reveal" id="comic_box" ref="comic_box" >
<div id="slides_div" class="slides" ref="slides">
<section class="" v-for="page in pages" :key="page" :data-menu-title="page" hidden>
<img :data-src="'/api/read/' + selector + '/image/' + page + '/'" class="w-100" :alt="page">
<div class="reveal" :class="{ continuous: mode === 'scroll' }" id="comic_box" ref="comic_box">
<div id="slides_div" class="slides" ref="slides">
<section
v-for="page in visible_pages"
:key="page"
:data-page="page"
:data-menu-title="page"
:hidden="mode === 'paged'"
>
<img
:class="{ shown: mode === 'scroll' && inview.has(page) }"
:src="mode === 'scroll' ? image_url(page) : null"
:data-src="mode === 'paged' ? image_url(page) : null"
:loading="mode === 'scroll' ? 'lazy' : null"
class="w-100"
:alt="page"
>
</section>
</div>
<div class="sentinel" ref="sentinel" v-if="mode === 'scroll' && rendered < pages"></div>
</div>
<div class="row navButtons pb-2">
<comic-paginate
v-model="paginate_page"
:page_count="pages"
@setPage="this.setPage"
@prevComic="prevComic"
@nextComic="nextComic"
/>
<div class="col-auto">
<div class="btn-group btn-group-sm" role="group" aria-label="Reading mode">
<button
type="button"
class="btn btn-outline-secondary"
:class="{ active: mode === 'paged' }"
@click="setMode('paged')"
>One page</button>
<button
type="button"
class="btn btn-outline-secondary"
:class="{ active: mode === 'scroll' }"
@click="setMode('scroll')"
>Continuous</button>
</div>
</div>
<div class="col-auto">
<comic-paginate
v-model="paginate_page"
:page_count="pages"
@setPage="this.setPage"
@prevComic="prevComic"
@nextComic="nextComic"
/>
</div>
</div>
</template>
@@ -24,6 +56,14 @@ import api from "@/api";
import Hammer from 'hammerjs'
import ComicPaginate from "@/components/ComicPaginate";
// How many pages are rendered at a time in continuous mode. The window grows as the
// reader approaches the end of what has already been rendered.
const WINDOW_SIZE = 20;
// Held keys repeat, so a key press is handled immediately and then ignored for this
// long. The page write is already debounced by queue_save, so nothing is sent per key.
const KEY_THROTTLE_MS = 100;
export default {
name: "TheComicReader",
components: {ComicPaginate},
@@ -31,23 +71,67 @@ export default {
return {
current_page: 0,
paginate_page: 1,
inview: new Set(),
deck: null,
title: '',
prev_comic: {},
next_comic: {},
pages: 1,
rendered: 0,
visible: new Map(),
page_observer: null,
growth_observer: null,
save_timeout: null,
key_locked: false,
key_timeout: null,
}
},
props: {
selector: String
},
computed: {
mode () {
return this.$store.state.reader_mode
},
set_read_url () {
return '/api/read/' + this.selector + '/set_page/'
},
visible_pages () {
if (this.mode === 'paged') {
return Array.from({length: this.pages}, (_item, index) => index + 1)
}
return Array.from({length: Math.min(this.rendered, this.pages)}, (_item, index) => index + 1)
}
},
methods: {
prevPage(){
image_url (page) {
return '/api/read/' + this.selector + '/image/' + page + '/'
},
prevPage(behavior = 'smooth'){
if (this.mode === 'scroll') {
if (this.current_page <= 0) {
this.prevComic()
} else {
this.current_page -= 1
this.scroll_to_page(this.current_page + 1, behavior)
}
return
}
// reveal initializes asynchronously, so a key pressed before the promise resolves
// has no deck to move.
if (!this.deck) {
return
}
if (this.deck.isFirstSlide()){
this.prevComic()
} else {
this.current_page -= 1
this.deck.slide(this.current_page)
// Pages can be taller than the viewport, so show the top of the new page instead
// of keeping the scroll offset left over from the previous one.
this.scroll_to_page(this.current_page + 1, behavior)
}
},
prevComic(){
@@ -62,59 +146,140 @@ export default {
params: {selector: this.next_comic.selector}
})
},
nextPage(){
nextPage(behavior = 'smooth'){
if (this.mode === 'scroll') {
if (this.current_page + 1 >= this.pages) {
this.nextComic()
} else {
this.current_page += 1
this.scroll_to_page(this.current_page + 1, behavior)
}
return
}
if (!this.deck) {
return
}
if (this.deck.isLastSlide()){
this.nextComic()
} else {
this.current_page += 1
this.deck.slide(this.current_page)
this.scroll_to_page(this.current_page + 1, behavior)
}
},
setPage(pageNum){
if (this.mode === 'scroll') {
const page = Math.min(pageNum, this.pages)
this.rendered = Math.max(this.rendered, page)
this.current_page = page - 1
this.$nextTick(() => {
this.observe_pages()
this.scroll_to_page(page)
})
return
}
this.current_page = pageNum-1
this.deck.slide(this.current_page)
},
keyPressDebounce(e){
clearTimeout(this.key_timeout)
this.key_timeout = setTimeout(() => {this.keyPress(e)}, 50)
scroll_to_page (page, behavior = 'smooth') {
const section = this.$refs.slides.querySelector('section[data-page="' + page + '"]')
if (section) {
section.scrollIntoView({behavior: behavior, block: 'start'})
}
},
keyPress(e) {
if (e.key === 'ArrowRight') {
this.nextPage()
} else if (e.key === 'ArrowLeft') {
this.prevPage()
} else if (e.key === 'ArrowUp') {
window.scrollTo({
top: window.scrollY-window.innerHeight*.7,
left: 0,
behavior: 'smooth'
});
} else if (e.key === 'ArrowDown') {
window.scrollTo({
top: window.scrollY+window.innerHeight*.7,
left: 0,
behavior: 'smooth'
});
keyPress (e) {
// Alt+Left is the browser's back button and Ctrl/Cmd+Arrow moves a caret in a
// form control, so only a bare arrow key pages.
if (e.ctrlKey || e.metaKey || e.altKey) {
return
}
}
},
watch: {
'current_page' (new_page) {
this.paginate_page = new_page + 1
}
},
mounted () {
const set_read_url = '/api/read/' + this.selector + '/set_page/'
let comic_data_url = '/api/read/' + this.selector + '/'
window.addEventListener('keyup', this.keyPressDebounce)
api.get(comic_data_url)
.then(response => {
this.title = response.data.title
this.current_page = response.data.last_read_page
this.prev_comic = response.data.prev_comic
this.next_comic = response.data.next_comic
this.pages = response.data.pages
// The reader listens on window, so a key pressed while a control has focus would
// otherwise page as well as activate that control.
if (e.target && e.target.closest && e.target.closest('input, select, textarea, button')) {
return
}
if (this.key_locked) {
return
}
this.key_locked = true
this.key_timeout = setTimeout(() => { this.key_locked = false }, KEY_THROTTLE_MS)
// Left and Right change pages. Up and Down are left to the browser's default
// scrolling: the page is taller than the viewport, and intercepting them here would
// double up with the native scroll. In continuous mode the observer records whichever
// page is topmost, so scrolling inside a page does not change the saved page.
if (e.key === 'ArrowRight') {
this.nextPage('auto')
} else if (e.key === 'ArrowLeft') {
this.prevPage('auto')
}
},
// Continuous mode: reveal is not used for visibility here, so the page in view is
// worked out from what is actually intersecting the viewport.
start_scroll () {
this.visible.clear()
this.inview.clear()
this.page_observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
this.visible.set(entry.target, entry.boundingClientRect.top)
this.inview.add(Number(entry.target.dataset.page))
} else {
this.visible.delete(entry.target)
this.inview.delete(Number(entry.target.dataset.page))
}
}
if (this.visible.size === 0) {
return
}
const top = [...this.visible.entries()].sort((a, b) => a[1] - b[1])[0][0]
const page = Number(top.dataset.page)
if (page && page !== this.current_page + 1) {
this.current_page = page - 1
this.queue_save(page - 1)
}
}, { threshold: 0 })
this.growth_observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
this.extend_window()
}
}, { rootMargin: '1200px 0px' })
this.observe_pages()
if (this.$refs.sentinel) {
this.growth_observer.observe(this.$refs.sentinel)
}
this.scroll_to_page(this.current_page + 1)
},
observe_pages () {
this.$refs.slides.querySelectorAll('section[data-page]').forEach((section) => {
this.page_observer.observe(section)
})
},
extend_window () {
if (this.rendered >= this.pages) {
return
}
this.rendered = Math.min(this.pages, this.rendered + WINDOW_SIZE)
this.$nextTick(() => this.observe_pages())
},
// One write per burst of scrolling rather than one per intersection.
queue_save (page) {
clearTimeout(this.save_timeout)
this.save_timeout = setTimeout(() => {
api.put(this.set_read_url, {page: page})
}, 500)
},
start_paged () {
this.deck = Reveal(this.$refs.comic_box)
this.deck.initialize({
controls: false,
@@ -130,41 +295,99 @@ export default {
plugins: [ ]
}).then(() => {
this.deck.slide(this.current_page)
api.put(set_read_url, {page: this.current_page})
this.slideChanged = (event) => {
this.$refs.comic_box.scrollIntoView({behavior: 'smooth'})
api.put(set_read_url, {page: event.indexh})
api.put(this.set_read_url, {page: event.indexh})
}
this.deck.on('slidechanged', this.slideChanged);
})
this.hammertime = new Hammer(this.$refs.comic_box, {})
this.hammertime.on('swipeleft', (_e, self=this) => {
self.nextPage()
})
this.hammertime.on('swiperight', (_e, self=this) => {
self.prevPage()
})
this.hammertime.on('tap', (_e, self=this) => {
self.nextPage()
})
})
.catch((error) => {console.log(error)})
},
beforeUnmount() {
window.removeEventListener('keyup', this.keyPressDebounce)
clearTimeout(this.key_timeout)
if (this.deck) {
if (this.slideChanged) {
this.deck.off('slidechanged', this.slideChanged)
},
async setMode (mode) {
if (mode === this.mode) {
return
}
try {
this.deck.destroy()
} catch (e) {
console.log(e)
this.$store.commit('setReaderMode', mode)
this.teardown()
await this.$nextTick()
if (mode === 'scroll') {
this.rendered = Math.min(this.pages, Math.max(WINDOW_SIZE, this.current_page + 1))
await this.$nextTick()
this.start_scroll()
} else {
this.start_paged()
}
},
teardown () {
clearTimeout(this.save_timeout)
this.inview.clear()
if (this.page_observer) {
this.page_observer.disconnect()
this.page_observer = null
}
if (this.growth_observer) {
this.growth_observer.disconnect()
this.growth_observer = null
}
this.visible.clear()
if (this.deck) {
if (this.slideChanged) {
this.deck.off('slidechanged', this.slideChanged)
}
try {
this.deck.destroy()
} catch (e) {
console.log(e)
}
this.deck = null
}
}
},
watch: {
'current_page' (new_page) {
this.paginate_page = new_page + 1
}
},
mounted () {
let comic_data_url = '/api/read/' + this.selector + '/'
window.addEventListener('keydown', this.keyPress)
api.get(comic_data_url)
.then(response => {
this.title = response.data.title
this.current_page = response.data.last_read_page
this.prev_comic = response.data.prev_comic
this.next_comic = response.data.next_comic
this.pages = response.data.pages
if (this.mode === 'scroll') {
this.rendered = Math.min(this.pages, Math.max(WINDOW_SIZE, this.current_page + 1))
return this.$nextTick().then(() => this.start_scroll())
}
return this.$nextTick().then(() => this.start_paged())
})
.then(() => {
api.put(this.set_read_url, {page: this.current_page})
})
.catch((error) => {console.log(error)})
this.hammertime = new Hammer(this.$refs.comic_box, {})
this.hammertime.on('swipeleft', (_e, self=this) => {
self.nextPage()
})
this.hammertime.on('swiperight', (_e, self=this) => {
self.prevPage()
})
this.hammertime.on('tap', (_e, self=this) => {
self.nextPage()
})
},
beforeUnmount() {
window.removeEventListener('keydown', this.keyPress)
clearTimeout(this.key_timeout)
this.teardown()
try {
this.hammertime.off('swipeleft')
this.hammertime.off('swiperight')
@@ -191,4 +414,34 @@ export default {
section {
padding-bottom: 60px;
}
/* reveal.css is deliberately not loaded, so visibility is the `hidden` attribute and
reveal's own transition classes do nothing. A transition cannot run across
display:none -> block, so paged mode uses an animation on the slide that becomes
present; continuous mode uses a transition driven by the IntersectionObserver. */
.reveal section.present img {
animation: page-fade 0.3s ease;
}
.continuous img {
opacity: 0;
transition: opacity 0.3s ease;
}
.continuous img.shown {
opacity: 1;
}
@keyframes page-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.reveal section.present img,
.continuous img {
animation: none;
transition: none;
}
}
</style>
+14
View File
@@ -14,6 +14,12 @@ function get_user_from_storage(){
}
}
// Reading mode is a preference rather than part of the session, so it is kept on its own.
function get_reader_mode_from_storage(){
const mode = localStorage.getItem('reader_mode')
return mode === 'scroll' ? 'scroll' : 'paged'
}
function session_expired_query(next){
return {
name: 'login',
@@ -28,6 +34,7 @@ export default createStore({
state: {
filters: {},
user: get_user_from_storage(),
reader_mode: get_reader_mode_from_storage(),
classifications: [
{label: 'G', value: '0'},
{label: 'PG', value: '1'},
@@ -57,6 +64,13 @@ export default createStore({
localStorage.setItem('u', JSON.stringify(userData));
state.user = userData
},
setReaderMode(state, mode){
if (mode !== 'paged' && mode !== 'scroll') {
return
}
localStorage.setItem('reader_mode', mode);
state.reader_mode = mode
},
},
actions: {
obtainToken(context, {username, password}){