mirror of
https://github.com/ajurna/cbwebreader.git
synced 2026-11-02 10:36:55 +00:00
**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:
1 parent
494b64621e
commit
80566e657e
6 files changed
+647
-92
No files matched your search
@@ -52,6 +52,7 @@ export default [
|
||||
console: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
IntersectionObserver: 'readonly',
|
||||
process: 'readonly',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,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}){
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user