**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
+627 -72

No files matched your search

+3 -1
View File
@@ -1 +1,3 @@
*/15 * * * * root /opt/venv/bin/python /src/manage.py scan_comics --settings=cbreader.settings.base
# Cron captures job output for e-mail, which is discarded without an MTA, so write to
# the cron daemon's own stderr instead - that is what `docker logs` shows.
*/15 * * * * root /opt/venv/bin/python /src/manage.py scan_comics --settings=cbreader.settings.base >> /proc/1/fd/2 2>&1
+1
View File
@@ -52,6 +52,7 @@ export default [
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
IntersectionObserver: 'readonly',
process: 'readonly',
},
},
+311 -58
View File
@@ -1,12 +1,43 @@
<template>
<div class="reveal" id="comic_box" ref="comic_box" >
<div class="reveal" :class="{ continuous: mode === 'scroll' }" 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">
<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">
<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"
@@ -15,6 +46,7 @@
@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)
},
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'
});
}
scroll_to_page (page, behavior = 'smooth') {
const section = this.$refs.slides.querySelector('section[data-page="' + page + '"]')
if (section) {
section.scrollIntoView({behavior: behavior, block: 'start'})
}
},
watch: {
'current_page' (new_page) {
this.paginate_page = new_page + 1
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
}
},
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,13 +295,83 @@ 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);
})
},
async setMode (mode) {
if (mode === this.mode) {
return
}
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) => {
@@ -148,23 +383,11 @@ export default {
this.hammertime.on('tap', (_e, self=this) => {
self.nextPage()
})
})
.catch((error) => {console.log(error)})
},
beforeUnmount() {
window.removeEventListener('keyup', this.keyPressDebounce)
window.removeEventListener('keydown', this.keyPress)
clearTimeout(this.key_timeout)
if (this.deck) {
if (this.slideChanged) {
this.deck.off('slidechanged', this.slideChanged)
}
try {
this.deck.destroy()
} catch (e) {
console.log(e)
}
}
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}){
+274 -13
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { reactive } from 'vue'
const api = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() }))
const router = vi.hoisted(() => ({ push: vi.fn() }))
@@ -14,6 +15,31 @@ const deck = vi.hoisted(() => ({
}))
const hammertime = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn() }))
const reveal = vi.hoisted(() => vi.fn(() => deck))
const store = vi.hoisted(() => ({
state: { reader_mode: 'paged' },
commit: vi.fn(),
}))
const observers = []
// jsdom has no IntersectionObserver, so the tests drive the callbacks by hand.
class FakeIntersectionObserver {
constructor(callback, options) {
this.callback = callback
this.options = options
this.targets = []
this.disconnected = false
observers.push(this)
}
observe(target) {
if (!this.targets.includes(target)) this.targets.push(target)
}
unobserve(target) { this.targets = this.targets.filter((item) => item !== target) }
disconnect() { this.disconnected = true; this.targets = [] }
fire(entries) { this.callback(entries, this) }
}
vi.mock('@/api', () => ({ default: api }))
vi.mock('reveal.js', () => ({ default: reveal }))
@@ -35,7 +61,7 @@ function mountReader() {
const wrapper = mount(TheComicReader, {
props: { selector: 'a1' },
global: {
mocks: { $router: router },
mocks: { $router: router, $store: store },
stubs: { 'comic-paginate': true },
},
})
@@ -51,6 +77,15 @@ beforeEach(() => {
api.get.mockResolvedValue({ data: { ...COMIC } })
api.put.mockResolvedValue({ data: {} })
router.push.mockReset()
store.state = reactive({ reader_mode: 'paged' })
store.commit.mockReset()
store.commit.mockImplementation((name, value) => {
if (name === 'setReaderMode' && (value === 'paged' || value === 'scroll')) {
store.state.reader_mode = value
}
})
observers.length = 0
globalThis.IntersectionObserver = FakeIntersectionObserver
reveal.mockClear()
for (const fn of Object.values(deck)) {
if (typeof fn === 'function') fn.mockClear?.()
@@ -87,16 +122,19 @@ describe('TheComicReader', () => {
expect(wrapper.vm.paginate_page).toBe(4)
})
it('debounces arrow keys so held keys do not spam the deck', async () => {
it('acts on the first arrow key and ignores held keys until the throttle expires', async () => {
const wrapper = mountReader()
await flushPromises()
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' }))
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' }))
vi.advanceTimersByTime(50)
expect(deck.slide).toHaveBeenCalledTimes(2)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
expect(wrapper.vm.current_page).toBe(3)
expect(deck.slide).toHaveBeenCalledTimes(2)
vi.advanceTimersByTime(100)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
expect(wrapper.vm.current_page).toBe(4)
expect(deck.slide).toHaveBeenCalledTimes(3)
})
it('moves to the next book when the last slide is reached', async () => {
@@ -138,11 +176,11 @@ describe('TheComicReader', () => {
const wrapper = mountReader()
await flushPromises()
expect(addSpy).toHaveBeenCalledWith('keyup', wrapper.vm.keyPressDebounce)
expect(addSpy).toHaveBeenCalledWith('keydown', wrapper.vm.keyPress)
wrapper.unmount()
expect(removeSpy).toHaveBeenCalledWith('keyup', wrapper.vm.keyPressDebounce)
expect(removeSpy).toHaveBeenCalledWith('keydown', wrapper.vm.keyPress)
expect(deck.off).toHaveBeenCalledWith('slidechanged', wrapper.vm.slideChanged)
expect(deck.destroy).toHaveBeenCalled()
expect(hammertime.off).toHaveBeenCalledWith('swipeleft')
@@ -153,15 +191,238 @@ describe('TheComicReader', () => {
removeSpy.mockRestore()
})
it('does not act on a key press that was debounced just before unmounting', async () => {
it('leaves arrow keys with a modifier to the browser', async () => {
const wrapper = mountReader()
await flushPromises()
deck.slide.mockClear()
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' }))
wrapper.unmount()
vi.advanceTimersByTime(100)
wrapper.vm.keyPress({ key: 'ArrowLeft', altKey: true })
expect(deck.slide).not.toHaveBeenCalled()
wrapper.vm.keyPress({ key: 'ArrowRight' })
expect(wrapper.vm.current_page).toBe(3)
})
it('ignores arrow keys pressed while a form control has focus', async () => {
const wrapper = mountReader()
await flushPromises()
deck.slide.mockClear()
wrapper.vm.keyPress({ key: 'ArrowRight', target: document.createElement('input') })
expect(deck.slide).not.toHaveBeenCalled()
wrapper.vm.keyPress({ key: 'ArrowRight', target: document.createElement('button') })
expect(deck.slide).not.toHaveBeenCalled()
})
it('leaves Up and Down to the browser so they scroll within the page', async () => {
const wrapper = mountReader()
await flushPromises()
deck.slide.mockClear()
api.put.mockClear()
wrapper.vm.keyPress({ key: 'ArrowDown' })
wrapper.vm.keyPress({ key: 'ArrowUp' })
// Nothing is intercepted, so the native scroll is not doubled up and the recorded
// page stays where the reader actually is.
expect(wrapper.vm.current_page).toBe(2)
expect(deck.slide).not.toHaveBeenCalled()
expect(api.put).not.toHaveBeenCalled()
})
it('shows the top of the page when changing pages in paged mode', async () => {
const wrapper = mountReader()
await flushPromises()
const scrollIntoView = HTMLElement.prototype.scrollIntoView
wrapper.vm.keyPress({ key: 'ArrowRight' })
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' })
})
it('does not move the deck when a key arrives before reveal has initialized', async () => {
const wrapper = mountReader()
deck.slide.mockClear()
wrapper.vm.keyPress({ key: 'ArrowRight' })
expect(deck.slide).not.toHaveBeenCalled()
await flushPromises()
expect(deck.initialize).toHaveBeenCalled()
vi.advanceTimersByTime(100)
wrapper.vm.keyPress({ key: 'ArrowRight' })
expect(wrapper.vm.current_page).toBe(3)
expect(deck.slide).toHaveBeenCalledWith(3)
})
})
describe('TheComicReader in continuous mode', () => {
function mountContinuous(pageCount) {
store.state.reader_mode = 'scroll'
api.get.mockResolvedValue({ data: { ...COMIC, pages: pageCount } })
return mountReader()
}
function growthObserver() {
return observers.find((observer) => observer.options.rootMargin)
}
function pageObserver() {
return observers.find((observer) => !observer.options.rootMargin)
}
it('renders only the first window of pages and does not start a deck', async () => {
const wrapper = mountContinuous(45)
await flushPromises()
expect(wrapper.vm.rendered).toBe(20)
expect(wrapper.findAll('section[data-page]')).toHaveLength(20)
expect(reveal).not.toHaveBeenCalled()
})
it('renders far enough to include the page it resumed on', async () => {
store.state.reader_mode = 'scroll'
api.get.mockResolvedValue({ data: { ...COMIC, last_read_page: 30, pages: 45 } })
const wrapper = mountReader()
await flushPromises()
expect(wrapper.vm.rendered).toBe(31)
expect(wrapper.findAll('section[data-page]')).toHaveLength(31)
})
it('grows the window when the reader reaches the end of what is rendered', async () => {
const wrapper = mountContinuous(45)
await flushPromises()
growthObserver().fire([{ isIntersecting: true, target: wrapper.find('.sentinel').element, boundingClientRect: { top: 0 } }])
await flushPromises()
expect(wrapper.vm.rendered).toBe(40)
expect(wrapper.findAll('section[data-page]')).toHaveLength(40)
expect(pageObserver().targets).toHaveLength(40)
})
it('does not grow past the last page', async () => {
const wrapper = mountContinuous(25)
await flushPromises()
growthObserver().fire([{ isIntersecting: true, target: wrapper.find('.sentinel').element, boundingClientRect: { top: 0 } }])
await flushPromises()
expect(wrapper.vm.rendered).toBe(25)
expect(wrapper.find('.sentinel').exists()).toBe(false)
})
it('records the page at the top of the viewport', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
api.put.mockClear()
const sections = wrapper.findAll('section[data-page]')
pageObserver().fire([
{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: 10 } },
{ isIntersecting: true, target: sections[2].element, boundingClientRect: { top: 400 } },
])
expect(wrapper.vm.current_page).toBe(4)
vi.advanceTimersByTime(500)
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
})
it('keeps the recorded page while scrolling inside a page taller than the viewport', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
api.put.mockClear()
const sections = wrapper.findAll('section[data-page]')
const observer = pageObserver()
observer.fire([{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: 0 } }])
vi.advanceTimersByTime(500)
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
// Scrolling down inside that page leaves it topmost, so nothing is written.
api.put.mockClear()
observer.fire([{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: -300 } }])
vi.advanceTimersByTime(500)
expect(wrapper.vm.current_page).toBe(4)
expect(api.put).not.toHaveBeenCalled()
// The recorded page only changes when the next page becomes the topmost one.
observer.fire([
{ isIntersecting: false, target: sections[4].element },
{ isIntersecting: true, target: sections[5].element, boundingClientRect: { top: 0 } },
])
vi.advanceTimersByTime(500)
expect(wrapper.vm.current_page).toBe(5)
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 5 })
})
it('writes once for a burst of scrolling rather than once per intersection', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
api.put.mockClear()
const sections = wrapper.findAll('section[data-page]')
const observer = pageObserver()
observer.fire([{ isIntersecting: true, target: sections[5].element, boundingClientRect: { top: 400 } }])
observer.fire([{ isIntersecting: true, target: sections[6].element, boundingClientRect: { top: 0 } }])
vi.advanceTimersByTime(500)
expect(api.put).toHaveBeenCalledTimes(1)
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 6 })
})
it('marks the page in view as shown so it fades in', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
const sections = wrapper.findAll('section[data-page]')
const observer = pageObserver()
observer.fire([{ isIntersecting: true, target: sections[2].element, boundingClientRect: { top: 0 } }])
await flushPromises()
expect(sections[2].find('img').classes()).toContain('shown')
expect(sections[0].find('img').classes()).not.toContain('shown')
observer.fire([{ isIntersecting: false, target: sections[2].element, boundingClientRect: { top: 0 } }])
await flushPromises()
expect(sections[2].find('img').classes()).not.toContain('shown')
})
it('scrolls to a page chosen from the pager, rendering it first if it is outside the window', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
const target = wrapper.findAll('section[data-page]')[19].element
const spy = vi.spyOn(target, 'scrollIntoView')
wrapper.vm.setPage(20)
await flushPromises()
expect(wrapper.vm.rendered).toBe(20)
expect(spy).toHaveBeenCalled()
})
it('disconnects both observers when it is unmounted', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
wrapper.unmount()
expect(observers.every((observer) => observer.disconnected)).toBe(true)
})
it('commits the mode change and rebuilds the deck when switching back to one page at a time', async () => {
const wrapper = mountContinuous(30)
await flushPromises()
await wrapper.vm.setMode('paged')
await flushPromises()
expect(store.commit).toHaveBeenCalledWith('setReaderMode', 'paged')
expect(observers.every((observer) => observer.disconnected)).toBe(true)
expect(reveal).toHaveBeenCalled()
expect(wrapper.findAll('section[data-page]')).toHaveLength(30)
})
})
+24
View File
@@ -40,6 +40,30 @@ describe('session state', () => {
})
})
describe('reader mode', () => {
it('starts in paged mode when nothing is stored', async () => {
vi.resetModules()
const fresh = (await import('@/store')).default
expect(fresh.state.reader_mode).toBe('paged')
})
it('keeps the chosen mode in localStorage', () => {
store.commit('setReaderMode', 'scroll')
expect(store.state.reader_mode).toBe('scroll')
expect(localStorage.getItem('reader_mode')).toBe('scroll')
})
it('ignores a mode it does not recognise', () => {
store.commit('setReaderMode', 'scroll')
store.commit('setReaderMode', 'banana')
expect(store.state.reader_mode).toBe('scroll')
expect(localStorage.getItem('reader_mode')).toBe('scroll')
})
})
describe('restoreSession', () => {
it('refreshes the stored profile from the cookie session', async () => {
store.commit('updateUser', { id: 1, is_superuser: false })