From 80566e657e7dfac3f013e6b58eea86917523fac5 Mon Sep 17 00:00:00 2001 From: ajurna Date: Fri, 16 Oct 2026 09:30:46 +0100 Subject: [PATCH] **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. --- cbreader/crontab | 4 +- frontend/eslint.config.mjs | 1 + frontend/src/components/TheComicReader.vue | 409 +++++++++++++++++---- frontend/src/store/index.js | 14 + frontend/test/comicreader.test.js | 287 ++++++++++++++- frontend/test/store.test.js | 24 ++ 6 files changed, 647 insertions(+), 92 deletions(-) diff --git a/cbreader/crontab b/cbreader/crontab index b41db45..e823065 100644 --- a/cbreader/crontab +++ b/cbreader/crontab @@ -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 diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 3858d5b..8d3a7b5 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -52,6 +52,7 @@ export default [ console: 'readonly', setTimeout: 'readonly', clearTimeout: 'readonly', + IntersectionObserver: 'readonly', process: 'readonly', }, }, diff --git a/frontend/src/components/TheComicReader.vue b/frontend/src/components/TheComicReader.vue index bc86ef6..16cbb4b 100644 --- a/frontend/src/components/TheComicReader.vue +++ b/frontend/src/components/TheComicReader.vue @@ -1,19 +1,51 @@ @@ -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; + } +} diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index 206be89..77dd7f4 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -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}){ diff --git a/frontend/test/comicreader.test.js b/frontend/test/comicreader.test.js index 325f5b5..f6286c0 100644 --- a/frontend/test/comicreader.test.js +++ b/frontend/test/comicreader.test.js @@ -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) + }) }) diff --git a/frontend/test/store.test.js b/frontend/test/store.test.js index 496ff22..c91b962 100644 --- a/frontend/test/store.test.js +++ b/frontend/test/store.test.js @@ -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 })