From a5cfcb007ec5f5a392a0cb715bfa6508107cf317 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Wed, 2 Sep 2026 15:14:07 -0700 Subject: [PATCH] Scroll to the right page when reading Bottom to Top The vertical pager hands `v-virtual-scroll` a list of page numbers and reverses that list for bottom-to-top reading, so page 0 becomes the last item. `scrollToIndex` takes an index into that list, but `scrollToPage` passed the page number straight through, so every jump landed on the mirrored page: opening a comic at page 0 scrolled to the last page. Look the page up in `items` rather than repeating the reversal, so this keeps following however that list is built. Top to Bottom is unaffected, where a page number is already its own index; a test pins that too. Page tracking was never wrong. The intersection observer reads the real page number off the element, so only jumping to a page was affected. Co-Authored-By: Claude Opus 5 --- NEWS.md | 4 + .../reader/pager/pager-vertical.vue | 8 +- frontend/tests/unit/pager-vertical.test.js | 99 +++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 frontend/tests/unit/pager-vertical.test.js diff --git a/NEWS.md b/NEWS.md index 55cb85634..e6794092f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -37,6 +37,10 @@ border-radius: 128px; - The Admin Tagging Status table shows what is being looked up right now. - Sorting by a tag column outside the table view (cover cards, OPDS feeds) no longer errors. + - Reading Bottom to Top jumped to the wrong page. The page list runs + backwards in that direction, so opening a comic, changing pages from the + toolbar or following a link landed on the mirrored page — page one showed + the last page. Top to Bottom was never affected. ## v2.2.11 diff --git a/frontend/src/components/reader/pager/pager-vertical.vue b/frontend/src/components/reader/pager/pager-vertical.vue index 153a83630..c4cf6523d 100644 --- a/frontend/src/components/reader/pager/pager-vertical.vue +++ b/frontend/src/components/reader/pager/pager-vertical.vue @@ -150,7 +150,13 @@ export default { this.programmaticScroll = true; const vs = this.$refs.verticalScroll; if (vs) { - vs.scrollToIndex(page); + /* + * ``items`` runs backwards for bottom-to-top reading, so a page + * number is not its own index there: page 0 is the last item. + * Look the page up in ``items`` rather than repeating the + * reversal, so this keeps following however that list is built. + */ + vs.scrollToIndex(this.items.indexOf(page)); } else { console.debug("Can't find verticalScroll component."); } diff --git a/frontend/tests/unit/pager-vertical.test.js b/frontend/tests/unit/pager-vertical.test.js new file mode 100644 index 000000000..5579208b4 --- /dev/null +++ b/frontend/tests/unit/pager-vertical.test.js @@ -0,0 +1,99 @@ +/* + * Tests for ``pager-vertical.vue`` page-to-index mapping. + * + * The vertical pager feeds ``v-virtual-scroll`` a list of page numbers, and + * reverses that list for bottom-to-top reading. ``scrollToIndex`` takes an + * index into that list, so a page number is only its own index while + * reading top-to-bottom. Passing the page number straight through sent + * bottom-to-top readers to the mirrored page — opening at page 0 landed on + * the last page. + */ +import { createTestingPinia } from "@pinia/testing"; +import { mount } from "@vue/test-utils"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import PagerVertical from "@/components/reader/pager/pager-vertical.vue"; +import vuetify from "@/plugins/vuetify"; + +const PK = 7; +const MAX_PAGE = 9; + +let wrappers = []; + +function mountPager(readingDirection) { + const scrollToIndex = vi.fn(); + const pinia = createTestingPinia({ + // getBookSettings has to really run to return the seeded settings. + stubActions: false, + initialState: { + reader: { + page: 0, + // Seeded settings are returned as-is by getBookSettings. + bookSettings: { + [PK]: { + readingDirection, + isVertical: true, + isReadInReverse: readingDirection === "btt", + }, + }, + }, + }, + }); + const wrapper = mount(PagerVertical, { + props: { book: { pk: PK, maxPage: MAX_PAGE } }, + global: { + plugins: [pinia, vuetify], + stubs: { + ScaleForScroll: { template: "
" }, + // The pager reaches the scroller through the ref, so the spy has + // to live on the stub the ref resolves to. + VVirtualScroll: { template: "
", methods: { scrollToIndex } }, + }, + }, + }); + wrappers.push(wrapper); + return { wrapper, scrollToIndex }; +} + +afterEach(() => { + for (const wrapper of wrappers) { + wrapper.unmount(); + } + wrappers = []; +}); + +describe("PagerVertical — scrolling to a page", () => { + test("top to bottom scrolls to the page's own index", () => { + const { wrapper, scrollToIndex } = mountPager("ttb"); + + wrapper.vm.scrollToPage(3); + + expect(wrapper.vm.items[3]).toBe(3); + expect(scrollToIndex).toHaveBeenCalledWith(3); + }); + + test("bottom to top scrolls to the page's mirrored index", () => { + const { wrapper, scrollToIndex } = mountPager("btt"); + + wrapper.vm.scrollToPage(3); + + expect(wrapper.vm.items[6]).toBe(3); + expect(scrollToIndex).toHaveBeenCalledWith(6); + }); + + test("bottom to top opens page 0 at the end of the list", () => { + const { wrapper, scrollToIndex } = mountPager("btt"); + + wrapper.vm.scrollToPage(0); + + expect(scrollToIndex).toHaveBeenCalledWith(MAX_PAGE); + }); + + test("bottom to top reaches the last page at the top of the list", () => { + const { wrapper, scrollToIndex } = mountPager("btt"); + + wrapper.vm.scrollToPage(MAX_PAGE); + + expect(scrollToIndex).toHaveBeenCalledWith(0); + }); +});