Skip to content

Reader: paged mode reads right to left when the series does - #90

Merged
AngeloSha merged 7 commits into
AngeloSha:mainfrom
Squeaks72:pr/paged-rtl
Sep 25, 2026
Merged

AngeloSha merged 7 commits into
AngeloSha:mainfrom
Squeaks72:pr/paged-rtl

Conversation

@Squeaks72

@Squeaks72 Squeaks72 commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The reader already knows a series' reading direction: rtl in web/app/reader/page.tsx is set from metadata.readingDirection === 'RIGHT_TO_LEFT', or from the downloaded record when offline. It uses that only to swap the two halves of a double spread. The paged track itself always runs left to right, so a right-to-left manga still has its next page on the right.

This makes paged mode follow that direction:

  • New reader setting Reading direction (pagedDirection: series | ltr | rtl, default series), in the reader's settings sheet and in Profile → Settings → Reading, shown only in paged mode.
  • series (default). A series whose readingDirection is RIGHT_TO_LEFT gets a right-to-left track: the next page is on the left, so you swipe right, tap the left edge or press ←, and a spread puts its first page on the right. Every other series reads exactly as it does today. That includes every series on the built-in library, because seriesDto in lib/ownedCatalog.ts answers WEBTOON for all of them. So on the default backend nothing changes for anyone until a series actually reports right-to-left. On a Komga backend, series that Komga marks right-to-left start paging right to left.
  • ltr / rtl override it for every series, for readers who want one direction everywhere.
  • On a right-to-left track the bottom bar mirrors (dir="rtl"): the page slider fills and drags from the right, the same way as the pages, and previous/next chapter swap sides with their chevrons swapped to keep pointing outwards. The 12/40 counter stays LTR.

How it works: the track gets dir="rtl", where scrollLeft runs from 0 into negative values. trackSign (−1/+1) turns a slide index into a scrollLeft for the initial landing, the resume and the page-grid jump. onScroll reads Math.abs(scrollLeft). Changing the direction mid-chapter puts you back on the page you were looking at. Tap zones already use a physical scrollBy, so tapping the left edge reveals what is on the left, which is the next page on an RTL track. On an RTL track the spread pair swap is skipped, because dir already lays the pair out right to left.

A fix that comes with it: the Arabic UI. Under Arabic, <html dir="rtl">, and the paged track inherited it, while the scroll maths assumed a positive scrollLeft. Scrolling produced negative values, Math.round(scrollLeft / width) clamped to slide 0, and the counter stayed on page 1. A resume or a page-grid jump (positive scrollLeft) clamped to the first page too. The track now always states its dir (ltr unless the setting says otherwise), so the paged track in Arabic runs left to right and tracks pages correctly, the same as in every other language. The bottom bar still takes the page's direction unless the track is right to left.

Interplay with the arrow-key PR (#92). That PR binds ←/→/↓/↑/Space/PageUp/PageDown in paged mode to scrollBy(±width). ←/→ are physical and stay correct on an RTL track (← reveals the next page). The direction-free keys (Space, PageDown, ↓ = next; PageUp, ↑ = previous) need trackSign on an RTL track. Whichever PR lands second should map them as page(trackSign) / page(-trackSign) and add trackSign to that effect's deps. Neither PR depends on the other.

docs/USAGE.md (section 5) describes the setting. New strings (Reading direction, Left to right, Right to left, Series default) are translated in all eight locales.

Type

  • Fix
  • Feature
  • Docs
  • Refactor / chore

(Plus the Arabic paged-track fix described above.)

Testing

Re-run after rebasing onto v0.45.0 (91b41aa): bff 1857 tests, 1844 pass, 9 fail, 4 skipped — the same 9 embedded-Postgres entrypoint failures main has in this environment (main: 1857 / 1844 / 9 / 4), nothing new; web 432/432; both typechecks OK. The detail below is from the first run, on v0.44.0.

  • web/test/readerPrefsJunk.test.ts gains "paged mode follows the series unless overridden": the default is series, stored settings without the key migrate to it, an explicit ltr/rtl is kept, and an unknown stored value falls back to the default (migratePrefs now validates it the way it validates junkPages).

Run in node:24-alpine against a real postgres:16-alpine (TEST_DATABASE_URL set, so the SQL tests run), first on plain main (a8bc502) and then on this branch:

main (baseline) this branch
bff tsc --noEmit OK OK
bff tests 1834 tests: 1821 pass, 9 fail, 4 skipped same: 1834, 1821 pass, 9 fail, 4 skipped
web tsc --noEmit OK OK
web tests 421 / 421 422 / 422 (+1 new)

The 9 bff failures are the same on both: the embedded-Postgres entrypoint tests (not ok 291–299), which cannot run in this sandbox. No new failures.

npm run test:i18n was not run: it is the puppeteer walk (test/e2e/i18n.mjs) that needs a running app and Chromium. The new keys were checked to exist in all eight locale files, and each file still parses as JSON. The RTL track was not exercised in a real browser for this PR. The track code itself has been in daily use with right to left as the default; this PR changes only where the direction comes from.

Checklist

  • Focused scope (one topic)
  • Matches the existing TypeScript style
  • Docs updated if user-facing (docs/USAGE.md)
  • No new default sources hardcoded to specific sites (the engine system reaches sites by URL)

🤖 Generated with Claude Code

Squeaks72 and others added 3 commits September 24, 2026 10:27
A term submitted with a source chosen now asks only that source (&source= on
search-all) instead of fanning out to every one and answering with everything,
and the picker stays on screen so the filter is visible and clearable rather
than silently narrowing the results.

`source` only narrows the route's existing surfaceable() set, so an adult
source without adult=1, one above the account's age limit, or an unknown id
asks nobody and answers the empty shape. The search query's key now includes
the chosen source; discoverSearch.test.ts and discoverAdult.test.ts pin the
new key and the picker being mounted in both modes, and searchAll.int.test.ts
covers the narrowed fan-out. Documented in openapi.yaml, docs/api.md and
USAGE.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Upstream's reader already knows a series' readingDirection (the `rtl`
state, from the series or the downloaded record) but used it only to flip
the order of a double spread; the paged track always ran left to right.

New reader pref `pagedDirection` (series | ltr | rtl, default series).
With `series`, a RIGHT_TO_LEFT series lays paged mode's track out
right-to-left: the next page is to the left (swipe right, tap the left
edge, ←), and spreads put their first page on the right. Every other
series reads exactly as before -- including every series on the built-in
library, which reports WEBTOON. `ltr`/`rtl` override it for all series.
Toggle in the reader sheet and Profile → Reading.

The track now always states its `dir`. Under the Arabic UI it used to
inherit dir="rtl" from <html>, and the positive-scrollLeft maths left the
page counter on 1 and clamped a jump or resume to the first page.

On a right-to-left track the footer row takes dir="rtl": the page slider
fills and drags from the right, matching the page track, and
previous/next chapter swap sides with their chevrons swapped to keep
pointing outwards. The page counter stays LTR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t Continue

Chapter list
- The series page and the reader asked the books route for one page of
  1000, so One Piece's chapters 1001+ never appeared in the list or in the
  reader's prev/next/jump list. lib/seriesBooks.ts fetchAllBooks() walks
  every page (tested). The smart-offline planner had the same cap
  server-side.
- The list now renders 100 rows a page (lib/chapterPages.ts, tested) with
  a pager above and below (first/prev/range picker/next/last). It opens on
  the page holding the "Continue" chapter; a tap on the pager pins a page,
  and a new series, sort, or filter follows Continue again. A chapter chip
  in Sources & translations turns to the page holding that row before it
  scrolls to it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AngeloSha and others added 4 commits September 25, 2026 10:18
…per book

On top of @Squeaks72's paging, which fixes a real bug on main -- Continue opened chapter 1 for anyone past
chapter 1000:

- The picker named pages by row position. Ghost, run and "show all" rows are rows but not chapters, and
  newest first labelled the page holding 1193-1094 "1-100". Pages are now named by the first and last chapter
  numbers they show, in the list's own order, and the count beside it is chapters.
- The page followed Continue live, so expanding an older-chapters run, "Show all" or "Mark all read" flipped
  the list away from what the reader was doing. It is decided once per series, sort and filter, once the
  chapters and the sources' listing (whose ghost rows would shift it) have both arrived.
- A scan between the two requests could hand the same book back at the 1000 boundary: two rows, one React key.
  Kept once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ns keep their direction

On top of @Squeaks72's right-to-left paging, which also fixes a bug on main (under the Arabic interface the
paged track inherited RTL and the page counter stuck on page 1):

- Resume and `?page=` links opened a right-to-left series on page 1. A streamed series learns its direction in
  the same tick it becomes ready, so the new flip effect ran right after the resume scroll with `current` still
  0 in its closure. It now acts only on a real flip, tracked by the direction the track was last laid out for.
- In paged mode the bottom bar follows the track both ways. On a left-to-right read under the Arabic interface
  it inherited RTL and ran opposite to its own pages.
- Text inside the track (the missing-page caption, the Up Next and failure cards) took the track's direction:
  Arabic in an LTR paragraph, or English in RTL on a right-to-left read. It keeps the interface's, via useRtl().
- A double-tap zoom in paged mode centred scrollLeft, which there IS the page: it jumped to mid-chapter (page 1
  on a right-to-left track). Centring is for the webtoon column only.
- A right-to-left series forced to read left to right still swaps its spreads, on purpose: a double-page spread
  is one drawing and only reassembles with its first page on the right. Said so in the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ranch

The PRs merge in order, so each branch carries the ones before it and merges without conflicts.
The PRs merge in order, so each branch carries the ones before it and merges without conflicts.

# Conflicts:
#	web/public/locales/ar.json
#	web/public/locales/de.json
#	web/public/locales/es.json
#	web/public/locales/fr.json
#	web/public/locales/ja.json
#	web/public/locales/pt-BR.json
#	web/public/locales/ru.json
#	web/public/locales/zh.json
AngeloSha added a commit to Squeaks72/uchiyomi that referenced this pull request Sep 25, 2026
The PRs merge in order, so each branch carries the ones before it and merges without conflicts.
@AngeloSha
AngeloSha merged commit 31874e9 into AngeloSha:main Sep 25, 2026
12 checks passed
AngeloSha added a commit that referenced this pull request Sep 25, 2026
… extension engine

Right-to-left paging (#90), one key per page (#92), reader settings pinned to
a source (#95), type-to-search (#94), chapter names (#84), a chapter list that
pages past chapter 1000 (#89), Discover keeping its source through a search
(#87) and an 18+ filter an admin can widen (#86) -- seven merged with fixes on
top, one as it was. The extension engine (Suwayomi-Server, headless) gets the
desktop app's memory cap in every compose file (#72).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AngeloSha

Copy link
Copy Markdown
Owner

Thanks, this is a nice one, and it also fixes a bug on main: under the Arabic interface the paged track inherited right-to-left and the page counter got stuck on page 1.

Fixes on top:

  • Resume and ?page= links opened a right-to-left series on page 1. A streamed series learns its direction in the same tick it becomes ready, so the new flip effect ran right after the resume scroll, still holding page 0.
  • In paged mode the bottom bar follows the track in both directions now. On a left-to-right read under Arabic it was inheriting RTL and running the opposite way to its own pages.
  • Text inside the track (the missing-page caption, the Up Next and failure cards) was taking the track's direction. It keeps the interface's now.
  • A double-tap zoom in paged mode centred scrollLeft, which in paged mode is the page itself, so it jumped to the middle of the chapter.

I kept the spread swap when a right-to-left series is forced to read left to right, on purpose: a double-page spread is one drawing, and it only fits back together with its first page on the right. There's a comment saying so.

One thing I found while testing: Uchiyomi's own library reports every series as WEBTOON (ownedCatalog.ts:111), so at the time Series default only turned right to left on a Komga library. On the built-in library you pick it per series, per source (with #95) or as your default under Profile → Settings. Since v0.48.0 (#102) the library works it out by itself, from ComicInfo's Manga field, the source, then AniList, so it's right on the built-in library too.

Merged, and it shipped in v0.46.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants