From 36e11de3322aad72e9687b86d87d35e488d0839a Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 29 May 2026 21:28:42 +0800 Subject: [PATCH] feat(reader): swipe-to-adjust brightness gesture on mobile (#3021) (#4356) * docs: design spec for gesture-based brightness control (#3021) Co-Authored-By: Claude Opus 4.8 (1M context) * docs: revise brightness-gesture spec per /autoplan review (#3021) CEO+Design+Eng dual-voice review. Key fixes: capture-phase listener (bubble-phase could not suppress foliate paginator), opt-out toggle, 18px threshold, selection guard, brightness seed race, rAF teardown, e-ink stepped overlay, contrast capsule, perceptual curve reuse, listener-level test harness. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(reader): swipe-to-adjust brightness gesture on mobile (#3021) Left-edge vertical swipe adjusts screen brightness on iOS/Android, with a Sun-icon progress overlay. Capture-phase non-passive listener suppresses the foliate paginator / page-flip / UI-toggle handlers; selection guard, strip reservation in scrolled mode, eager brightness seed, rAF throttle + teardown. Opt-out toggle in Settings > Behavior > Device (default on). Perceptual curve shared with the menu slider. Pure-helper + listener-level tests. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(reader): detect brightness-swipe edge by screenX; i18n + shorter label (#3021) On-device fix: paginated mode lays the iframe doc out as wide side-by-side columns, so clientX/documentElement.clientWidth are document coordinates and a left-edge touch on a later page never fell inside the strip (armed stayed false). Detect with screenX against the parent window width, matching usePagination. Also: translate the two new setting strings across all locales and shorten the toggle description to 'Slide along the left edge'. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../2026-05-29-gesture-brightness-design.md | 307 ++++++++++++++++++ .../public/locales/ar/translation.json | 4 +- .../public/locales/bn/translation.json | 4 +- .../public/locales/bo/translation.json | 4 +- .../public/locales/de/translation.json | 4 +- .../public/locales/el/translation.json | 4 +- .../public/locales/es/translation.json | 4 +- .../public/locales/fa/translation.json | 4 +- .../public/locales/fr/translation.json | 4 +- .../public/locales/he/translation.json | 4 +- .../public/locales/hi/translation.json | 4 +- .../public/locales/hu/translation.json | 4 +- .../public/locales/id/translation.json | 4 +- .../public/locales/it/translation.json | 4 +- .../public/locales/ja/translation.json | 4 +- .../public/locales/ko/translation.json | 4 +- .../public/locales/ms/translation.json | 4 +- .../public/locales/nl/translation.json | 4 +- .../public/locales/pl/translation.json | 4 +- .../public/locales/pt-BR/translation.json | 4 +- .../public/locales/pt/translation.json | 4 +- .../public/locales/ro/translation.json | 4 +- .../public/locales/ru/translation.json | 4 +- .../public/locales/si/translation.json | 4 +- .../public/locales/sl/translation.json | 4 +- .../public/locales/sv/translation.json | 4 +- .../public/locales/ta/translation.json | 4 +- .../public/locales/th/translation.json | 4 +- .../public/locales/tr/translation.json | 4 +- .../public/locales/uk/translation.json | 4 +- .../public/locales/uz/translation.json | 4 +- .../public/locales/vi/translation.json | 4 +- .../public/locales/zh-CN/translation.json | 4 +- .../public/locales/zh-TW/translation.json | 4 +- .../__tests__/app/brightnessGesture.test.ts | 92 ++++++ .../hooks/useBrightnessGesture.test.tsx | 173 ++++++++++ .../reader/components/BrightnessOverlay.tsx | 61 ++++ .../app/reader/components/FoliateViewer.tsx | 6 + .../app/reader/hooks/useBrightnessGesture.ts | 197 +++++++++++ .../src/app/reader/utils/brightnessGesture.ts | 58 ++++ .../src/components/settings/ControlPanel.tsx | 18 + apps/readest-app/src/services/constants.ts | 1 + apps/readest-app/src/types/settings.ts | 1 + 43 files changed, 1013 insertions(+), 33 deletions(-) create mode 100644 apps/readest-app/docs/superpowers/specs/2026-05-29-gesture-brightness-design.md create mode 100644 apps/readest-app/src/__tests__/app/brightnessGesture.test.ts create mode 100644 apps/readest-app/src/__tests__/hooks/useBrightnessGesture.test.tsx create mode 100644 apps/readest-app/src/app/reader/components/BrightnessOverlay.tsx create mode 100644 apps/readest-app/src/app/reader/hooks/useBrightnessGesture.ts create mode 100644 apps/readest-app/src/app/reader/utils/brightnessGesture.ts diff --git a/apps/readest-app/docs/superpowers/specs/2026-05-29-gesture-brightness-design.md b/apps/readest-app/docs/superpowers/specs/2026-05-29-gesture-brightness-design.md new file mode 100644 index 00000000..2c129921 --- /dev/null +++ b/apps/readest-app/docs/superpowers/specs/2026-05-29-gesture-brightness-design.md @@ -0,0 +1,307 @@ +# Gesture-Based Brightness Control (iOS / Android) + +GitHub issue: https://github.com/readest/readest/issues/3021 + +## Summary + +Add a left-edge vertical swipe gesture that adjusts screen brightness while +reading, without opening the menu. While adjusting, a vertical progress bar with +a Sun icon appears at the left edge to indicate the current brightness level. + +The feature is gated to platforms with native brightness control (iOS and +Android — `appService.hasScreenBrightness`). It is on by default but can be +disabled via a setting, works in both paginated and scrolled modes, and persists +the chosen brightness across sessions. + +## Locked decisions + +- **Enablement**: on by default for iOS/Android, with an opt-out toggle in + **Settings → Behavior → Device**. The toggle doubles as the discoverability + surface and the escape hatch for accidental activation (CEO review, both + voices). New setting: `swipeBrightnessGesture: boolean` (default `true`) in + `SystemSettings`. +- **Persistence**: on release, save `screenBrightness` (0–100) and set + `autoScreenBrightness = false`, exactly like the existing menu slider, so the + value survives restart and stays in sync with the slider. Undo path: the menu + slider's "System Screen Brightness" toggle re-enables auto-brightness (CEO + review: an accidental swipe silently disables auto-brightness; the undo must be + documented and reachable). +- **Scope**: core only. One opt-out toggle (above). No sensitivity setting, no + corner choice, no volume gesture, no haptics, no lock. +- **Gesture area**: left **10%** of the view width, in **both** paginated and + scrolled modes. +- **Direction**: swipe up = brighter, swipe down = dimmer. A full view-height + drag spans the full 0→100% range. + +## Behavior + +1. A touch begins inside the left 10% of the view width. +2. It activates as a brightness gesture once movement becomes dominantly + vertical (`|Δy| > |Δx|`) and passes a ~18px threshold. The threshold is + deliberately above incidental thumb-jitter (CEO review: 10px was too eager + for an always-on edge strip). +3. While active, device brightness updates live (throttled via + `requestAnimationFrame`), and the overlay shows the current level. +4. On release, the value is persisted (`screenBrightness` + `autoScreenBrightness + = false`) and the overlay fades out shortly after. + +Brightness mapping: `next = clamp(startBrightness − Δy / viewHeight, 0, 1)`, +where `startBrightness` is the device brightness captured at activation. + +## Conflict suppression (key design point) + +The existing iframe touch listeners (`FoliateViewer.tsx` ~line 326) are passive +and forward events via `postMessage` to `useTouchEvent` / the interceptor chain, +which drives page-flip swipes and the upward-swipe-to-toggle-UI behavior. In +scrolled mode the iframe also scrolls natively on a vertical drag. + +Attach a **dedicated capture-phase, non-passive** touch listener on the iframe +`doc`: `addEventListener('touch{start,move,end,cancel}', fn, { capture: true, +passive: false })`. The callback is a parent-realm closure, so it can call the +device store and React state directly — no `postMessage` or interceptor needed. + +**Why capture phase (corrected after eng review — this was a bug in the first +draft).** There are *three* independent touch-listener registrants on the same +`doc`: (1) FoliateViewer's own `postMessage` forwarders (`FoliateViewer.tsx:326`, +passive), (2) the Annotator's non-passive selection listeners (`Annotator.tsx:332`), +and (3) **foliate-js's own paginator** (`packages/foliate-js/paginator.js:1034`, +non-passive, bubble-phase), registered during `view.open()` — i.e. *before* any +app-level listener. The paginator's `touchmove` can `preventDefault()`, set +`#touchScrolled`, and `scrollBy()`. A bubble-phase listener registered later +therefore **cannot** `stopImmediatePropagation` the paginator — it already ran. +Both eng voices (Claude + Codex) independently verified this against `paginator.js`. +A **capture-phase** listener fires before every bubble-phase listener regardless +of registration order, so its `stopImmediatePropagation` suppresses paginator, +Annotator, and FoliateViewer handlers alike. + +When the gesture is active, each move/end/cancel calls `preventDefault()` + +`stopImmediatePropagation()`. The latter is the *sole* mechanism that suppresses +the conflicting page-flip / upward-swipe-to-toggle-UI (`useIframeEvents.ts:282` — +also 10px, vertical, left-edge-inclusive); there is no threshold gap to rely on. +The ~18px activation threshold only reduces *accidental* starts. + +**Selection guard.** Before arming, the listener checks `doc.getSelection()`; if a +non-collapsed selection exists, it does not arm (mirrors `paginator.js:1622`). This +keeps a vertical text-selection drag that starts in the left strip from being +hijacked into a brightness change. + +**Scrolled-mode timing.** In scrolled mode the paginator does not `preventDefault` +(it early-returns at `paginator.js:1613`); native container scroll is what moves +content. `preventDefault` only takes effect once called, so the pre-activation +travel (≤18px) would scroll before brightness takes over. Decision: see the +"Scrolled-strip reservation" item in the review report — the implementation +`preventDefault`s from the first move of any touch that *armed* in the strip +(reserve the strip), so there is no scroll-then-freeze jump. + +Before arming, and for taps / horizontal swipes / touches outside the strip, the +listener does nothing, so normal page-turn taps and swipes are unaffected. + +Required test: a short upward flick inside the left 10% must adjust brightness and +never toggle the toolbar (asserted by spying `stopImmediatePropagation` with a +fake paginator listener registered *first*, proving capture-phase suppression). + +## Components + +### `src/app/reader/utils/brightnessGesture.ts` (pure, unit-tested) + +- `isInLeftEdge(x: number, viewWidth: number, edgeRatio = 0.1): boolean`. + **Use `screenX` + the parent `window.innerWidth`, NOT `clientX` / + `documentElement.clientWidth`.** In paginated mode foliate-js lays content out + as side-by-side columns, so the iframe document is many screens wide and + `clientX` is a document coordinate (a left-edge touch on a later page reports a + large `clientX`). `screenX` is the physical screen position; the listener runs + in the parent realm so `window.innerWidth` is the real app viewport. (Matches + how `useIframeEvents` / `usePagination` already do zone detection.) +- `shouldActivate(deltaX: number, deltaY: number, threshold: number): boolean` + — true when `|Δy| >= threshold && |Δy| > |Δx|`. +- `computeBrightness(startPos: number, deltaY: number, viewHeight: number): number` + — works in **perceptual position** space (0–1) to match the menu slider: + `pos = clamp(startPos − deltaY / viewHeight, 0, 1)`, then brightness value = + `positionToValue(pos)`. Reuse the slider's `pow(0.5)` curve from `ColorPanel.tsx` + (extract `valueToPosition` / `positionToValue` into this module so there is one + source of truth — design review, both voices: a linear gesture would land on a + different number than the slider for the same finger travel). Always clamp the + seed to `[0,1]` and never feed the `-1` sentinel into the curve. + +Constants: `BRIGHTNESS_GESTURE_EDGE_RATIO = 0.1`, +`BRIGHTNESS_GESTURE_ACTIVATION_PX = 18`. + +### `src/app/reader/hooks/useBrightnessGesture.ts` + +Inert unless `appService.hasScreenBrightness` AND `settings.swipeBrightnessGesture`. + +**Latest-closure ref (`latestRef`).** The listener is attached once per doc (the +`isEventListenersAdded` guard) from a `docLoadHandler` that itself is captured +with a `[view]` dependency — so it sees stale render values. Therefore the +listener must read everything runtime-variable from a single `latestRef` updated +each render (mirrors `handlePageFlipRef` / `useTouchInterceptor`): the live +`swipeBrightnessGesture` toggle, `viewSettings.scrolled` / `.vertical`, and the +seed brightness. It must NOT read values captured in the hook's render closure. + +**Seed priming (async-race fix).** On mount (when `hasScreenBrightness`), prime a +`seedBrightnessRef`: if `settings.screenBrightness ≥ 0` use it, else +`await getScreenBrightness()`, clamped to `[0,1]`; fall back to `0.5` if the read +fails or returns `< 0`. Multi-pane coherence: seed each gesture-start from the +**shared** `settings.screenBrightness` (via `latestRef`), not a private per-book +cache, so two grid panes don't drift. A late-resolving seed must not overwrite a +value the user has already adjusted this gesture. + +Owns refs: `touchStart`, `armed`, `active`, `startPos`, `rafId`, `hideTimer`, +`seedBrightnessRef`, `latestRef`. Exposes `registerBrightnessListeners(doc)` and +`{ overlayVisible, overlayLevel }`. + +Listener logic (capture phase): + +- **touchstart**: if `!latestRef.swipeBrightnessGesture` → ignore. If + `doc.getSelection()` is non-collapsed → ignore (selection guard). Else record + start `clientX/clientY`; `armed = isInLeftEdge(...)`. +- **touchmove**: if `armed`: + - scrolled mode → `preventDefault()` from this first move (reserve the strip; no + scroll-then-freeze jump). + - once `active || shouldActivate(...)`: set `active`, `preventDefault()`, + `stopImmediatePropagation()`, compute brightness via the perceptual curve, + coalesce `setScreenBrightness` through a single `requestAnimationFrame` + (store `rafId`, cancel the prior frame), set `{ overlayVisible, overlayLevel }`. +- **touchend / touchcancel**: if `active`: `preventDefault()`, + `stopImmediatePropagation()`, **cancel any pending `rafId`**, apply the final + level deterministically, persist (`saveSysSettings('screenBrightness', + round(value*100))` + `saveSysSettings('autoScreenBrightness', false)`), schedule + the overlay hide (`hideTimer`, ~600ms). Always reset `touchStart/armed/active`. +- **teardown**: on hook unmount, `cancelAnimationFrame(rafId)` and clear + `hideTimer`. + +### `src/app/reader/components/BrightnessOverlay.tsx` + +A self-contained **capsule** (its own surface — `bg-base-100/90`, `border-base-content/20`) +holding a `PiSun` icon (`react-icons/pi`), a vertical track (`bg-base-content/20`) +filled from the bottom to `overlayLevel` (`bg-base-content`), and a small numeric +`%` label (`Math.round(value*100)`). The capsule surface is required so the +overlay stays legible over any book background (white / sepia / black / image +themes) — a bare bar would vanish on a same-tone page (design review, both voices). + +- **Position**: physical `left` + `env(safe-area-inset-left)`, vertically centered + within the inset-aware content box (not the raw viewport, so it never lands under + a half-open FooterBar). `z-[15]` (above the z-10 header/footer/Ribbon tier), + `pointer-events: none`. RTL: keep physical left (user-locked); set `dir="ltr"` + on the capsule so the `%` reads correctly. +- **Timing (color themes)**: fill height has **no CSS transition** (tracks the + finger 1:1); the capsule fades in fast (~100ms), holds ~500ms after release, + fades out ~200ms, then unmounts. Appears immediately on activation, never dims + the Sun icon with level (full opacity at 0%). 0% keeps the track + border + `0%` + visible; 100% fills flush. +- **e-ink (`[data-eink]`)**: `eink-bordered`, no shadow/gradient, 1px border, and + **no continuous animation** — quantize the visual fill to ~10% steps and drop + the fade (show/hide instantly) so the panel repaints a handful of times, not + 60×/s. Device brightness still updates live; only the overlay repaint is stepped. +- **Reduced motion** (`prefers-reduced-motion: reduce`): drop the opacity fades, + show/hide instantly (the live fill is functional, not decorative). +- `aria-hidden` (transient; the labeled menu slider is the canonical control). + +Positioned `absolute` within the per-book view container (sibling of the iframe in +FoliateViewer), so in a multi-pane grid each book's overlay stays in its own pane. + +### `src/app/reader/components/FoliateViewer.tsx` + +- `const { registerBrightnessListeners, overlayVisible, overlayLevel } = + useBrightnessGesture(bookKey)`. +- Call `registerBrightnessListeners(detail.doc)` inside the existing + `isEventListenersAdded` block. (Capture-phase registration makes ordering moot, + but keep it in this block so it shares the doc lifecycle.) +- Render `` as a + sibling of the iframe container. + +### `src/types/settings.ts` + `src/services/constants.ts` + +Add `swipeBrightnessGesture: boolean` to `SystemSettings` (default `true` in +`DEFAULT_SYSTEM_SETTINGS`). Surface the toggle in the settings UI under +**Behavior → Device** (next to the existing "System Screen Brightness" control in +`ControlPanel.tsx`), gated on `appService.hasScreenBrightness`. i18n: add the +label + description strings. + +## Testing + +The pure helpers are the easy part; the bug-prone logic is in the listener. Both +layers get tests (eng review: the first draft tested only the trivial helpers). + +- **Pure helpers** (`brightnessGesture.test.ts`, failing-first): + edge detection at the exact 10% boundary; `shouldActivate` at the 18px boundary + and the `|Δy| == |Δx|` tie; perceptual curve round-trip + (`positionToValue(valueToPosition(v)) ≈ v`); up = brighter sign; `[0,1]` clamp + including an unseeded / `-1` start. +- **Listener-level integration** (`useBrightnessGesture.test.ts`): build a fake + `Document`, call `registerBrightnessListeners`, dispatch synthetic + touchstart/move/end/cancel sequences, and assert: + - capture-phase suppression — register a fake paginator listener *first*; a + left-strip upward flick must call `stopImmediatePropagation` so the fake never + fires (this is the test that fails with bubble-phase, proving the fix); + - horizontal swipe in the strip → not activated (page-flip preserved); + - selection-in-progress in the strip → not hijacked; + - scrolled mode → `preventDefault` called on the armed pre-activation move; + - gating → inert when `!hasScreenBrightness` or `!swipeBrightnessGesture`; + - persistence on end → `saveSysSettings('screenBrightness', …)` + + `autoScreenBrightness=false` (mock `saveSysSettings`/`setScreenBrightness`); + - `rafId` / `hideTimer` cancelled on touchend and unmount. +- **Manual on device**: live feel, overlay appear/fade + e-ink stepped repaint, + scroll reservation in scrolled mode, taps / page-turn swipes still work, and the + Settings → Behavior → Device toggle disables the gesture. + +## Out of scope (deferred) + +Sensitivity setting, corner/edge choice, right-edge volume gesture, haptic +feedback, gesture lock. These are listed in the issue but explicitly deferred. + +## What already exists (reused, not rebuilt) + +- `deviceStore.getScreenBrightness/setScreenBrightness` (0–1) → live brightness. +- `saveSysSettings` + the `screenBrightness` / `autoScreenBrightness` settings → + persistence, identical to the menu slider. +- `ColorPanel.tsx` perceptual `pow(0.5)` curve → extracted and shared. +- `useTouchInterceptor` / `handlePageFlipRef` → latest-closure ref precedent. +- `Annotator.tsx` non-passive doc listener → precedent (we go one further: capture). +- `appService.hasScreenBrightness` → platform gate. + +--- + +## GSTACK REVIEW REPORT (/autoplan — CEO + Design + Eng, dual voices) + +Mode: SELECTIVE EXPANSION. Codex + Claude subagent per phase. Premise confirmed +by user. Plan revised in place per the findings below. + +### Consensus + +- **CEO**: premise CONFIRMED (parity with Moon+/KOReader). Both voices challenged + "no toggle", "left edge", "10px". User decided: add opt-out toggle + (Settings→Behavior→Device, default on); keep left edge; raise to 18px. +- **Design**: e-ink stepped repaint (no continuous animation), self-contained + contrast capsule, perceptual-curve + `%` label, fill-vs-fade timing split, + `z-[15]` + `pointer-events:none`, reduced-motion, RTL physical-left + `dir=ltr`. +- **Eng**: 🔴 the original bubble-phase + `stopImmediatePropagation` suppression + was **refuted by both voices** (foliate-js paginator registers first, bubble) → + **capture-phase** non-passive listener + `touchcancel`. Plus: selection guard, + eager+clamped brightness seed, rAF/timer teardown, shared-settings seed for + multi-pane, iframe-doc coordinate space, listener-level test harness. + +### Cross-phase theme + +**Gesture-conflict correctness** surfaced in all three phases (CEO flagged the +rationale as unverified; both Eng voices proved it false). Highest-confidence +signal — the capture-phase fix is the single most important change. + +### Decision Audit Trail + +| # | Phase | Decision | Class | Principle | Rationale | +|---|-------|----------|-------|-----------|-----------| +| 1 | CEO | Ship the gesture | Premise gate | — | User confirmed; parity feature | +| 2 | CEO | Add opt-out toggle (default on) | User challenge | — | User chose: Settings→Behavior→Device | +| 3 | CEO | Keep left edge | User challenge | — | User chose; preserves overlay placement | +| 4 | CEO | Threshold 10→18px | Taste | P5 explicit | Both voices: 10px too eager | +| 5 | CEO | Document auto-brightness undo path | Mechanical | P1 | Silent side-effect needs reachable undo | +| 6 | Eng | Capture-phase listener + touchcancel | Mechanical | P5 | Both voices proved bubble-phase insufficient | +| 7 | Eng | Selection guard before arming | Mechanical | P1 | Prevent selection hijack in strip | +| 8 | Eng | Eager + clamped brightness seed | Mechanical | P1 | Fix async race on default -1/auto | +| 9 | Eng | rAF / hide-timer teardown | Mechanical | P1 | Prevent stale write / leak | +| 10 | Eng | Listener-level integration tests | Mechanical | P1 | Cover the actual bug-prone branches | +| 11 | Design | Perceptual curve reuse + `%` | Mechanical | P4 DRY | One source of truth vs slider | +| 12 | Design | e-ink stepped, no continuous anim | Mechanical | P1 | Project e-ink rules | +| 13 | Design | Contrast capsule surface | Mechanical | P1 | Legible over any book theme | +| T1 | Eng | Scrolled-strip reservation (preventDefault from arm) | **Taste — open** | P5 | See final gate | diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 89303c96..ccdc669f 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1805,5 +1805,7 @@ "No note yet": "لا توجد ملاحظات بعد", "No annotation yet": "لا توجد تعليقات بعد", "No bookmark yet": "لا توجد إشارات مرجعية بعد", - "Importing...": "جاري الاستيراد..." + "Importing...": "جاري الاستيراد...", + "Swipe for Brightness": "اسحب لضبط السطوع", + "Slide along the left edge": "اسحب على طول الحافة اليسرى" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 0ad30644..11cdc3aa 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1677,5 +1677,7 @@ "No note yet": "এখনও কোনো নোট নেই", "No annotation yet": "এখনও কোনো টীকা নেই", "No bookmark yet": "এখনও কোনো বুকমার্ক নেই", - "Importing...": "আমদানি হচ্ছে..." + "Importing...": "আমদানি হচ্ছে...", + "Swipe for Brightness": "উজ্জ্বলতার জন্য সোয়াইপ করুন", + "Slide along the left edge": "বাম প্রান্ত বরাবর স্লাইড করুন" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 61c13113..b4313a94 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -1645,5 +1645,7 @@ "No note yet": "ད་དུང་ཟིན་བྲིས་མེད།", "No annotation yet": "ད་དུང་མཆན་འགྲེལ་མེད།", "No bookmark yet": "ད་དུང་དཔེ་རྟགས་མེད།", - "Importing...": "ནང་འདྲེན་བྱེད་བཞིན་པ..." + "Importing...": "ནང་འདྲེན་བྱེད་བཞིན་པ...", + "Swipe for Brightness": "འོད་གདངས་ཆེད་འཐེན།", + "Slide along the left edge": "གཡོན་ངོས་ཟུར་ནས་འཐེན།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 1616863a..4bdbae8f 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1677,5 +1677,7 @@ "No note yet": "Noch keine Notiz", "No annotation yet": "Noch keine Anmerkung", "No bookmark yet": "Noch kein Lesezeichen", - "Importing...": "Wird importiert..." + "Importing...": "Wird importiert...", + "Swipe for Brightness": "Wischen für Helligkeit", + "Slide along the left edge": "Am linken Rand entlang wischen" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 3ef6da26..7ed9197c 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1677,5 +1677,7 @@ "No note yet": "Δεν υπάρχει ακόμη σημείωση", "No annotation yet": "Δεν υπάρχει ακόμη σχολιασμός", "No bookmark yet": "Δεν υπάρχει ακόμη σελιδοδείκτης", - "Importing...": "Εισαγωγή..." + "Importing...": "Εισαγωγή...", + "Swipe for Brightness": "Σάρωση για φωτεινότητα", + "Slide along the left edge": "Σύρετε κατά μήκος της αριστερής άκρης" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 881547c8..e3a61a51 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1709,5 +1709,7 @@ "No note yet": "Aún no hay notas", "No annotation yet": "Aún no hay anotaciones", "No bookmark yet": "Aún no hay marcadores", - "Importing...": "Importando..." + "Importing...": "Importando...", + "Swipe for Brightness": "Deslizar para el brillo", + "Slide along the left edge": "Desliza por el borde izquierdo" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 1aad21b8..0faba0d9 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1677,5 +1677,7 @@ "No note yet": "هنوز یادداشتی وجود ندارد", "No annotation yet": "هنوز یادداشتی وجود ندارد", "No bookmark yet": "هنوز نشانکی وجود ندارد", - "Importing...": "در حال وارد کردن..." + "Importing...": "در حال وارد کردن...", + "Swipe for Brightness": "کشیدن برای روشنایی", + "Slide along the left edge": "در امتداد لبه چپ بکشید" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 1eb75695..15c1008e 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1709,5 +1709,7 @@ "No note yet": "Aucune note pour le moment", "No annotation yet": "Aucune annotation pour le moment", "No bookmark yet": "Aucun signet pour le moment", - "Importing...": "Importation..." + "Importing...": "Importation...", + "Swipe for Brightness": "Balayer pour la luminosité", + "Slide along the left edge": "Glissez le long du bord gauche" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index 134c3c50..b4e2d1cd 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -1709,5 +1709,7 @@ "No note yet": "אין הערות עדיין", "No annotation yet": "אין הערות עדיין", "No bookmark yet": "אין סימניות עדיין", - "Importing...": "מייבא..." + "Importing...": "מייבא...", + "Swipe for Brightness": "החלקה לכוונון בהירות", + "Slide along the left edge": "החליקו לאורך הקצה השמאלי" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 5089ccd6..dc74d0d1 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1677,5 +1677,7 @@ "No note yet": "अभी तक कोई नोट नहीं", "No annotation yet": "अभी तक कोई टिप्पणी नहीं", "No bookmark yet": "अभी तक कोई बुकमार्क नहीं", - "Importing...": "आयात हो रहा है..." + "Importing...": "आयात हो रहा है...", + "Swipe for Brightness": "चमक के लिए स्वाइप करें", + "Slide along the left edge": "बाएँ किनारे पर स्लाइड करें" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 32189a9d..58558500 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -1677,5 +1677,7 @@ "No note yet": "Még nincs jegyzet", "No annotation yet": "Még nincs jegyzet", "No bookmark yet": "Még nincs könyvjelző", - "Importing...": "Importálás..." + "Importing...": "Importálás...", + "Swipe for Brightness": "Húzás a fényerőhöz", + "Slide along the left edge": "Húzza a bal szél mentén" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 64a22de5..accfa751 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -1645,5 +1645,7 @@ "No note yet": "Belum ada catatan", "No annotation yet": "Belum ada anotasi", "No bookmark yet": "Belum ada penanda", - "Importing...": "Mengimpor..." + "Importing...": "Mengimpor...", + "Swipe for Brightness": "Geser untuk kecerahan", + "Slide along the left edge": "Geser di sepanjang tepi kiri" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 21b605bd..6c7f14ae 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1709,5 +1709,7 @@ "No note yet": "Nessuna nota ancora", "No annotation yet": "Nessuna annotazione ancora", "No bookmark yet": "Nessun segnalibro ancora", - "Importing...": "Importazione..." + "Importing...": "Importazione...", + "Swipe for Brightness": "Scorri per la luminosità", + "Slide along the left edge": "Scorri lungo il bordo sinistro" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 68e2a9d0..e32dcf5d 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -1645,5 +1645,7 @@ "No note yet": "メモはまだありません", "No annotation yet": "注釈はまだありません", "No bookmark yet": "ブックマークはまだありません", - "Importing...": "インポートしています..." + "Importing...": "インポートしています...", + "Swipe for Brightness": "スワイプで明るさ調整", + "Slide along the left edge": "左端をスライド" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 8396ff08..c3d9503c 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -1645,5 +1645,7 @@ "No note yet": "아직 노트가 없습니다", "No annotation yet": "아직 주석이 없습니다", "No bookmark yet": "아직 북마크가 없습니다", - "Importing...": "가져오는 중..." + "Importing...": "가져오는 중...", + "Swipe for Brightness": "밝기 조절 스와이프", + "Slide along the left edge": "왼쪽 가장자리를 따라 슬라이드" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index accdf12d..36dcb7cf 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -1645,5 +1645,7 @@ "No note yet": "Belum ada nota", "No annotation yet": "Belum ada anotasi", "No bookmark yet": "Belum ada tandabuku", - "Importing...": "Mengimport..." + "Importing...": "Mengimport...", + "Swipe for Brightness": "Leret untuk kecerahan", + "Slide along the left edge": "Leret di sepanjang tepi kiri" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 034369c1..7d5f588a 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1677,5 +1677,7 @@ "No note yet": "Nog geen notitie", "No annotation yet": "Nog geen annotatie", "No bookmark yet": "Nog geen bladwijzer", - "Importing...": "Bezig met importeren..." + "Importing...": "Bezig met importeren...", + "Swipe for Brightness": "Veeg voor helderheid", + "Slide along the left edge": "Veeg langs de linkerrand" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index c29906d1..0e585be0 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1741,5 +1741,7 @@ "No note yet": "Brak notatek", "No annotation yet": "Brak adnotacji", "No bookmark yet": "Brak zakładek", - "Importing...": "Importowanie..." + "Importing...": "Importowanie...", + "Swipe for Brightness": "Przesuń, aby zmienić jasność", + "Slide along the left edge": "Przesuń wzdłuż lewej krawędzi" } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index a25fd000..db7825f0 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -1709,5 +1709,7 @@ "No note yet": "Nenhuma nota ainda", "No annotation yet": "Nenhuma anotação ainda", "No bookmark yet": "Nenhum marcador ainda", - "Importing...": "Importando..." + "Importing...": "Importando...", + "Swipe for Brightness": "Deslizar para o brilho", + "Slide along the left edge": "Deslize pela borda esquerda" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index f08af4de..f4a92ffb 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1709,5 +1709,7 @@ "No note yet": "Ainda não há notas", "No annotation yet": "Ainda não há anotações", "No bookmark yet": "Ainda não há marcadores", - "Importing...": "Importando..." + "Importing...": "Importando...", + "Swipe for Brightness": "Deslizar para o brilho", + "Slide along the left edge": "Deslize pela borda esquerda" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index 431fc023..d1d4879d 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -1709,5 +1709,7 @@ "No note yet": "Nicio notă încă", "No annotation yet": "Nicio adnotare încă", "No bookmark yet": "Niciun marcaj încă", - "Importing...": "Se importă..." + "Importing...": "Se importă...", + "Swipe for Brightness": "Glisează pentru luminozitate", + "Slide along the left edge": "Glisează de-a lungul marginii stângi" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 88d2a896..8c46ee86 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1741,5 +1741,7 @@ "No note yet": "Пока нет заметок", "No annotation yet": "Пока нет аннотаций", "No bookmark yet": "Пока нет закладок", - "Importing...": "Импорт..." + "Importing...": "Импорт...", + "Swipe for Brightness": "Свайп для яркости", + "Slide along the left edge": "Проведите вдоль левого края" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index e9e66473..2a6da192 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1677,5 +1677,7 @@ "No note yet": "තවම සටහනක් නැත", "No annotation yet": "තවම විවරණයක් නැත", "No bookmark yet": "තවම පොත් සලකුණක් නැත", - "Importing...": "ආයාත වෙමින්..." + "Importing...": "ආයාත වෙමින්...", + "Swipe for Brightness": "දීප්තිය සඳහා ස්වයිප් කරන්න", + "Slide along the left edge": "වම් කෙළවර දිගේ අදින්න" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index a3509d9d..8522764c 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -1741,5 +1741,7 @@ "No note yet": "Še ni opombe", "No annotation yet": "Še ni opombe", "No bookmark yet": "Še ni zaznamka", - "Importing...": "Uvažanje ..." + "Importing...": "Uvažanje ...", + "Swipe for Brightness": "Poteg za svetlost", + "Slide along the left edge": "Povlecite ob levem robu" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 0e32a7f4..07c4d79d 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1677,5 +1677,7 @@ "No note yet": "Ingen anteckning än", "No annotation yet": "Ingen annotering än", "No bookmark yet": "Inget bokmärke än", - "Importing...": "Importerar..." + "Importing...": "Importerar...", + "Swipe for Brightness": "Svep för ljusstyrka", + "Slide along the left edge": "Dra längs vänsterkanten" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 8639a7d1..4c3cf9ea 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1677,5 +1677,7 @@ "No note yet": "இன்னும் குறிப்பு இல்லை", "No annotation yet": "இன்னும் சிறுகுறிப்பு இல்லை", "No bookmark yet": "இன்னும் புக்மார்க் இல்லை", - "Importing...": "இறக்குமதி செய்கிறது..." + "Importing...": "இறக்குமதி செய்கிறது...", + "Swipe for Brightness": "ஒளிர்வுக்கு ஸ்வைப் செய்யவும்", + "Slide along the left edge": "இடது விளிம்பில் இழுக்கவும்" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index df7e93a6..03aca4a6 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -1645,5 +1645,7 @@ "No note yet": "ยังไม่มีบันทึก", "No annotation yet": "ยังไม่มีคำอธิบายประกอบ", "No bookmark yet": "ยังไม่มีบุ๊กมาร์ก", - "Importing...": "กำลังนำเข้า..." + "Importing...": "กำลังนำเข้า...", + "Swipe for Brightness": "ปัดเพื่อปรับความสว่าง", + "Slide along the left edge": "เลื่อนตามขอบด้านซ้าย" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index c98d19d5..b551a89f 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1677,5 +1677,7 @@ "No note yet": "Henüz not yok", "No annotation yet": "Henüz açıklama yok", "No bookmark yet": "Henüz yer imi yok", - "Importing...": "İçe aktarılıyor..." + "Importing...": "İçe aktarılıyor...", + "Swipe for Brightness": "Parlaklık için kaydır", + "Slide along the left edge": "Sol kenar boyunca kaydır" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index ea9f8b52..07e8038a 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1741,5 +1741,7 @@ "No note yet": "Поки немає нотаток", "No annotation yet": "Поки немає анотацій", "No bookmark yet": "Поки немає закладок", - "Importing...": "Імпорт..." + "Importing...": "Імпорт...", + "Swipe for Brightness": "Свайп для яскравості", + "Slide along the left edge": "Проведіть уздовж лівого краю" } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index 35a69584..29b03f08 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -1677,5 +1677,7 @@ "No note yet": "Hali eslatma yoʻq", "No annotation yet": "Hali izoh yoʻq", "No bookmark yet": "Hali xatchoʻp yoʻq", - "Importing...": "Import qilinmoqda..." + "Importing...": "Import qilinmoqda...", + "Swipe for Brightness": "Yorqinlik uchun suring", + "Slide along the left edge": "Chap chekka bo'ylab suring" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 92eb0338..b5840eb2 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -1645,5 +1645,7 @@ "No note yet": "Chưa có ghi chú", "No annotation yet": "Chưa có chú thích", "No bookmark yet": "Chưa có dấu trang", - "Importing...": "Đang nhập..." + "Importing...": "Đang nhập...", + "Swipe for Brightness": "Vuốt để chỉnh độ sáng", + "Slide along the left edge": "Vuốt dọc theo cạnh trái" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 7e63d093..8048d62c 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -1645,5 +1645,7 @@ "No note yet": "尚无笔记", "No annotation yet": "尚无标注", "No bookmark yet": "尚无书签", - "Importing...": "正在导入..." + "Importing...": "正在导入...", + "Swipe for Brightness": "滑动调节亮度", + "Slide along the left edge": "沿左侧边缘滑动" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index d002453b..0287fe90 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -1645,5 +1645,7 @@ "No note yet": "尚無筆記", "No annotation yet": "尚無註解", "No bookmark yet": "尚無書籤", - "Importing...": "正在匯入..." + "Importing...": "正在匯入...", + "Swipe for Brightness": "滑動調整亮度", + "Slide along the left edge": "沿左側邊緣滑動" } diff --git a/apps/readest-app/src/__tests__/app/brightnessGesture.test.ts b/apps/readest-app/src/__tests__/app/brightnessGesture.test.ts new file mode 100644 index 00000000..8e36b3ad --- /dev/null +++ b/apps/readest-app/src/__tests__/app/brightnessGesture.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import { + BRIGHTNESS_GESTURE_ACTIVATION_PX, + BRIGHTNESS_GESTURE_EDGE_RATIO, + isInLeftEdge, + shouldActivate, + valueToPosition, + positionToValue, + computeBrightness, +} from '@/app/reader/utils/brightnessGesture'; + +describe('brightnessGesture pure helpers', () => { + describe('isInLeftEdge', () => { + it('is true on and below the 10% boundary, false above', () => { + const w = 1000; + expect(isInLeftEdge(0, w)).toBe(true); + expect(isInLeftEdge(w * BRIGHTNESS_GESTURE_EDGE_RATIO, w)).toBe(true); // exactly 100 + expect(isInLeftEdge(101, w)).toBe(false); + }); + + it('is false for a non-positive view width', () => { + expect(isInLeftEdge(0, 0)).toBe(false); + expect(isInLeftEdge(5, -10)).toBe(false); + }); + }); + + describe('shouldActivate', () => { + it('activates only when vertical-dominant past the threshold', () => { + const t = BRIGHTNESS_GESTURE_ACTIVATION_PX; + expect(shouldActivate(0, t)).toBe(true); // exactly 18, dominant + expect(shouldActivate(0, t - 1)).toBe(false); // below threshold + expect(shouldActivate(0, -t)).toBe(true); // upward, magnitude counts + expect(shouldActivate(10, 20)).toBe(true); // vertical-dominant + }); + + it('does not activate on horizontal-dominant or tie moves', () => { + expect(shouldActivate(30, 20)).toBe(false); // horizontal-dominant + expect(shouldActivate(20, 20)).toBe(false); // tie is not "> " + }); + }); + + describe('perceptual curve (matches the menu slider pow(0.5))', () => { + it('round-trips position -> value -> position', () => { + for (const p of [0, 0.1, 0.25, 0.5, 0.75, 1]) { + expect(positionToValue(valueToPosition(positionToValue(p)))).toBeCloseTo( + positionToValue(p), + 10, + ); + } + }); + + it('value 0.25 sits at position 0.5 (slider convention)', () => { + expect(valueToPosition(0.25)).toBeCloseTo(0.5, 10); + expect(positionToValue(0.5)).toBeCloseTo(0.25, 10); + }); + + it('clamps out-of-range inputs', () => { + expect(valueToPosition(-1)).toBe(0); + expect(valueToPosition(2)).toBe(1); + expect(positionToValue(-1)).toBe(0); + expect(positionToValue(2)).toBe(1); + }); + }); + + describe('computeBrightness', () => { + it('swiping up (negative deltaY) increases brightness', () => { + const start = 0.25; + const up = computeBrightness(start, -100, 1000); + const down = computeBrightness(start, 100, 1000); + expect(up).toBeGreaterThan(start); + expect(down).toBeLessThan(start); + }); + + it('a full view-height upward drag reaches max', () => { + expect(computeBrightness(0, -1000, 1000)).toBeCloseTo(1, 10); + }); + + it('a full view-height downward drag reaches min', () => { + expect(computeBrightness(1, 1000, 1000)).toBeCloseTo(0, 10); + }); + + it('clamps to [0,1] and tolerates an unseeded / -1 start', () => { + expect(computeBrightness(-1, -10, 1000)).toBeGreaterThanOrEqual(0); + expect(computeBrightness(2, -10, 1000)).toBeLessThanOrEqual(1); + }); + + it('returns the clamped start when viewHeight is non-positive', () => { + expect(computeBrightness(0.4, -50, 0)).toBeCloseTo(0.4, 10); + expect(computeBrightness(-1, -50, 0)).toBe(0); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/hooks/useBrightnessGesture.test.tsx b/apps/readest-app/src/__tests__/hooks/useBrightnessGesture.test.tsx new file mode 100644 index 00000000..e1f2ed87 --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useBrightnessGesture.test.tsx @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, act } from '@testing-library/react'; + +const h = vi.hoisted(() => ({ + hasScreenBrightness: true, + swipeSetting: true, + scrolled: false, + screenBrightness: -1, + setScreenBrightness: vi.fn(), + getScreenBrightness: vi.fn(), + saveSysSettings: vi.fn(), +})); + +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ + appService: { hasScreenBrightness: h.hasScreenBrightness }, + envConfig: {}, + }), +})); +vi.mock('@/store/settingsStore', () => ({ + useSettingsStore: () => ({ + settings: { swipeBrightnessGesture: h.swipeSetting, screenBrightness: h.screenBrightness }, + }), +})); +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => ({ getViewSettings: () => ({ scrolled: h.scrolled }) }), +})); +vi.mock('@/store/deviceStore', () => ({ + useDeviceControlStore: () => ({ + getScreenBrightness: h.getScreenBrightness, + setScreenBrightness: h.setScreenBrightness, + }), +})); +vi.mock('@/helpers/settings', () => ({ saveSysSettings: h.saveSysSettings })); + +import { useBrightnessGesture } from '@/app/reader/hooks/useBrightnessGesture'; + +type Api = ReturnType; +type TouchLike = { clientX: number; clientY: number; screenX: number; screenY: number }; +type FakeTouchEvent = Event & { touches: TouchLike[]; changedTouches: TouchLike[] }; +type SelectableDoc = { getSelection: () => { isCollapsed: boolean } | null }; + +const setSelection = (d: Document, isCollapsed: boolean) => { + (d as unknown as SelectableDoc).getSelection = () => ({ isCollapsed }); +}; + +const makeDoc = () => { + const d = document.implementation.createHTMLDocument('t'); + Object.defineProperty(d.documentElement, 'clientWidth', { value: 1000, configurable: true }); + Object.defineProperty(d.documentElement, 'clientHeight', { value: 1000, configurable: true }); + setSelection(d, true); + return d; +}; + +const fireTouch = (target: EventTarget, type: string, x: number, y: number) => { + const ev = new Event(type, { bubbles: true, cancelable: true }) as FakeTouchEvent; + const touch = { clientX: x, clientY: y, screenX: x, screenY: y }; + ev.touches = [touch]; + ev.changedTouches = [touch]; + const preventDefault = vi.spyOn(ev, 'preventDefault'); + const stopImmediatePropagation = vi.spyOn(ev, 'stopImmediatePropagation'); + act(() => { + target.dispatchEvent(ev); + }); + return { preventDefault, stopImmediatePropagation }; +}; + +const setup = () => { + let api: Api = null as unknown as Api; + function Wrapper() { + api = useBrightnessGesture('book-1'); + return null; + } + const utils = render(); + const doc = makeDoc(); + act(() => api.registerBrightnessListeners(doc as unknown as Document)); + // a descendant target so capture-phase doc listeners fire before bubble ones + const target = doc.createElement('div'); + doc.body.appendChild(target); + // stand-in for foliate-js's own bubble-phase paginator listener + const paginator = vi.fn(); + doc.addEventListener('touchmove', paginator); + return { getApi: () => api, doc, target, paginator, rerender: () => utils.rerender() }; +}; + +describe('useBrightnessGesture (listener-level)', () => { + beforeEach(() => { + h.hasScreenBrightness = true; + h.swipeSetting = true; + h.scrolled = false; + h.screenBrightness = -1; + h.setScreenBrightness.mockReset(); + h.saveSysSettings.mockReset(); + h.getScreenBrightness.mockReset().mockResolvedValue(0.5); + }); + afterEach(() => cleanup()); + + it('activates on a left-edge upward flick and suppresses the paginator (capture phase)', () => { + const { target, paginator } = setup(); + fireTouch(target, 'touchstart', 10, 500); // x=10 → inside left 10% + const { preventDefault, stopImmediatePropagation } = fireTouch(target, 'touchmove', 10, 470); // dy=-30 + expect(preventDefault).toHaveBeenCalled(); + expect(stopImmediatePropagation).toHaveBeenCalled(); + expect(paginator).not.toHaveBeenCalled(); // suppressed → never reaches bubble phase + }); + + it('does not activate for a horizontal-dominant swipe; paginator still runs', () => { + const { target, paginator } = setup(); + fireTouch(target, 'touchstart', 10, 500); + const { stopImmediatePropagation } = fireTouch(target, 'touchmove', 60, 510); // dx=50, dy=10 + expect(stopImmediatePropagation).not.toHaveBeenCalled(); + expect(paginator).toHaveBeenCalled(); + }); + + it('does not arm outside the left strip', () => { + const { target, paginator } = setup(); + fireTouch(target, 'touchstart', 500, 500); // center + const { stopImmediatePropagation } = fireTouch(target, 'touchmove', 500, 460); + expect(stopImmediatePropagation).not.toHaveBeenCalled(); + expect(paginator).toHaveBeenCalled(); + }); + + it('does not hijack an in-progress text selection', () => { + const { doc, target, paginator } = setup(); + setSelection(doc, false); + fireTouch(target, 'touchstart', 10, 500); + const { stopImmediatePropagation } = fireTouch(target, 'touchmove', 10, 470); + expect(stopImmediatePropagation).not.toHaveBeenCalled(); + expect(paginator).toHaveBeenCalled(); + }); + + it('reserves the strip in scrolled mode: preventDefault before activation, no stopImmediatePropagation', () => { + h.scrolled = true; + const { target } = setup(); + fireTouch(target, 'touchstart', 10, 500); + const { preventDefault, stopImmediatePropagation } = fireTouch(target, 'touchmove', 10, 495); // dy=-5, below 18px + expect(preventDefault).toHaveBeenCalled(); // scroll reserved + expect(stopImmediatePropagation).not.toHaveBeenCalled(); // not yet active + }); + + it('persists brightness and disables auto-brightness on release', () => { + const { target } = setup(); + fireTouch(target, 'touchstart', 10, 800); + fireTouch(target, 'touchmove', 10, 300); // big upward drag → brighter + fireTouch(target, 'touchend', 10, 300); + expect(h.setScreenBrightness).toHaveBeenCalled(); + const last = h.setScreenBrightness.mock.calls.at(-1)![0]; + expect(last).toBeGreaterThan(0.5); + expect(h.saveSysSettings).toHaveBeenCalledWith({}, 'screenBrightness', expect.any(Number)); + expect(h.saveSysSettings).toHaveBeenCalledWith({}, 'autoScreenBrightness', false); + }); + + it('is inert when the setting is disabled', () => { + h.swipeSetting = false; + const { target, paginator, rerender } = setup(); + rerender(); + fireTouch(target, 'touchstart', 10, 500); + const { stopImmediatePropagation } = fireTouch(target, 'touchmove', 10, 460); + expect(stopImmediatePropagation).not.toHaveBeenCalled(); + expect(paginator).toHaveBeenCalled(); + expect(h.setScreenBrightness).not.toHaveBeenCalled(); + }); + + it('is inert when the platform lacks screen brightness control', () => { + h.hasScreenBrightness = false; + const { target, paginator, rerender } = setup(); + rerender(); + fireTouch(target, 'touchstart', 10, 500); + const { stopImmediatePropagation } = fireTouch(target, 'touchmove', 10, 460); + expect(stopImmediatePropagation).not.toHaveBeenCalled(); + expect(paginator).toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/BrightnessOverlay.tsx b/apps/readest-app/src/app/reader/components/BrightnessOverlay.tsx new file mode 100644 index 00000000..91b3d4a7 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/BrightnessOverlay.tsx @@ -0,0 +1,61 @@ +import clsx from 'clsx'; +import React from 'react'; +import { PiSun } from 'react-icons/pi'; +import { valueToPosition } from '@/app/reader/utils/brightnessGesture'; + +interface BrightnessOverlayProps { + visible: boolean; + /** Brightness value, 0-1. */ + level: number; +} + +const isEink = () => + typeof document !== 'undefined' && document.documentElement.getAttribute('data-eink') === 'true'; + +/** + * Transient brightness indicator shown at the left edge while the swipe gesture + * adjusts brightness. Its own capsule surface keeps it legible over any book + * background; on e-ink it snaps (no continuous animation) and quantizes the fill. + */ +const BrightnessOverlay: React.FC = ({ visible, level }) => { + const eink = isEink(); + const clamped = Math.max(0, Math.min(1, level)); + // Fill height tracks the slider's perceptual position; the label shows the value. + let fillPercent = valueToPosition(clamped) * 100; + let valuePercent = Math.round(clamped * 100); + if (eink) { + fillPercent = Math.round(fillPercent / 10) * 10; + valuePercent = Math.round(valuePercent / 10) * 10; + } + + return ( +
+
+ +
+
+
+ {valuePercent} +
+
+ ); +}; + +export default BrightnessOverlay; diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 7e4e4285..d0c97075 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -14,6 +14,8 @@ import { useSettingsStore } from '@/store/settingsStore'; import { useCustomFontStore } from '@/store/customFontStore'; import { useParallelViewStore } from '@/store/parallelViewStore'; import { useMouseEvent, useTouchEvent, useLongPressEvent } from '../hooks/useIframeEvents'; +import { useBrightnessGesture } from '../hooks/useBrightnessGesture'; +import BrightnessOverlay from './BrightnessOverlay'; import { usePagination, viewPagination } from '../hooks/usePagination'; import { useFoliateEvents } from '../hooks/useFoliateEvents'; import { useProgressSync } from '../hooks/useProgressSync'; @@ -101,6 +103,8 @@ const FoliateViewer: React.FC<{ const { getBookData } = useBookDataStore(); const { applyBackgroundTexture } = useBackgroundTexture(); const { applyEinkMode } = useEinkMode(); + const { registerBrightnessListeners, overlayVisible, overlayLevel } = + useBrightnessGesture(bookKey); const bookData = getBookData(bookKey); const viewState = getViewState(bookKey); const viewSettings = getViewSettings(bookKey); @@ -315,6 +319,7 @@ const FoliateViewer: React.FC<{ detail.doc.addEventListener('touchmove', handleTouchMove.bind(null, bookKey)); detail.doc.addEventListener('touchend', handleTouchEnd.bind(null, bookKey)); addLongPressListeners(bookKey, detail.doc); + registerBrightnessListeners(detail.doc); } } }; @@ -796,6 +801,7 @@ const FoliateViewer: React.FC<{ {...mouseHandlers} {...touchHandlers} /> + {((!docLoaded.current && loading) || viewState?.loading) && (
diff --git a/apps/readest-app/src/app/reader/hooks/useBrightnessGesture.ts b/apps/readest-app/src/app/reader/hooks/useBrightnessGesture.ts new file mode 100644 index 00000000..4d6d57a7 --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useBrightnessGesture.ts @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEnv } from '@/context/EnvContext'; +import { useReaderStore } from '@/store/readerStore'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useDeviceControlStore } from '@/store/deviceStore'; +import { saveSysSettings } from '@/helpers/settings'; +import { + computeBrightness, + isInLeftEdge, + shouldActivate, +} from '@/app/reader/utils/brightnessGesture'; + +const OVERLAY_HIDE_DELAY_MS = 600; +const DEFAULT_BRIGHTNESS = 0.5; + +interface LatestState { + enabled: boolean; + scrolled: boolean; +} + +/** + * Left-edge swipe-to-adjust-brightness gesture (iOS / Android only). + * + * Attaches capture-phase, non-passive touch listeners to the foliate iframe + * document. Capture phase is required: foliate-js's own paginator registers its + * touch listeners during `view.open()` (before any app listener) in the bubble + * phase, so only a capture-phase `stopImmediatePropagation` can suppress them. + * + * The listener is attached once per document, so everything runtime-variable is + * read through `latestRef` (updated each render), mirroring `useTouchInterceptor`. + */ +export const useBrightnessGesture = (bookKey: string) => { + const { appService, envConfig } = useEnv(); + const { settings } = useSettingsStore(); + const { getViewSettings } = useReaderStore(); + const { getScreenBrightness, setScreenBrightness } = useDeviceControlStore(); + + const hasScreenBrightness = !!appService?.hasScreenBrightness; + const viewSettings = getViewSettings(bookKey); + + const [overlayVisible, setOverlayVisible] = useState(false); + const [overlayLevel, setOverlayLevel] = useState(0); + + // Everything the once-attached listener must read at the latest value. + const latestRef = useRef({ enabled: false, scrolled: false }); + latestRef.current = { + enabled: hasScreenBrightness && settings.swipeBrightnessGesture, + scrolled: !!viewSettings?.scrolled, + }; + + // Per-gesture state. + const armedRef = useRef(false); + const activeRef = useRef(false); + const startXRef = useRef(0); + const startYRef = useRef(0); + const viewHeightRef = useRef(0); + const startValueRef = useRef(DEFAULT_BRIGHTNESS); + const levelRef = useRef(DEFAULT_BRIGHTNESS); + const rafIdRef = useRef(null); + const pendingValueRef = useRef(null); + const hideTimerRef = useRef | null>(null); + + // Seed brightness (0-1): the value a gesture starts from. Primed eagerly so the + // first swipe never races the async device read. + const seedRef = useRef(DEFAULT_BRIGHTNESS); + const seededRef = useRef(false); + + useEffect(() => { + if (!hasScreenBrightness) return; + if (settings.screenBrightness >= 0) { + seedRef.current = Math.max(0, Math.min(1, settings.screenBrightness / 100)); + seededRef.current = true; + return; + } + let cancelled = false; + getScreenBrightness().then((b) => { + if (cancelled) return; + seedRef.current = b >= 0 && b <= 1 ? b : DEFAULT_BRIGHTNESS; + seededRef.current = true; + }); + return () => { + cancelled = true; + }; + }, [hasScreenBrightness, settings.screenBrightness, getScreenBrightness]); + + const flushBrightness = useCallback(() => { + rafIdRef.current = null; + if (pendingValueRef.current !== null) { + setScreenBrightness(pendingValueRef.current); + pendingValueRef.current = null; + } + }, [setScreenBrightness]); + + const scheduleBrightness = useCallback( + (value: number) => { + pendingValueRef.current = value; + if (rafIdRef.current === null) { + rafIdRef.current = requestAnimationFrame(flushBrightness); + } + }, + [flushBrightness], + ); + + const cancelRaf = useCallback(() => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + pendingValueRef.current = null; + }, []); + + const resetGesture = useCallback(() => { + armedRef.current = false; + activeRef.current = false; + }, []); + + const registerBrightnessListeners = useCallback( + (doc: Document) => { + const opts = { capture: true, passive: false } as const; + + const onTouchStart = (e: TouchEvent) => { + resetGesture(); + if (!latestRef.current.enabled) return; + const selection = doc.getSelection?.(); + if (selection && !selection.isCollapsed) return; // don't hijack selection + const t = e.touches[0]; + if (!t) return; + // Use screenX/screenY, not clientX/clientY: in paginated mode foliate-js + // lays content out as side-by-side columns, so the iframe document is + // many screens wide and clientX is a document coordinate. screenX is the + // physical screen position, and this listener runs in the parent realm so + // `window` is the app viewport. + const viewWidth = window.innerWidth; + viewHeightRef.current = window.innerHeight; + startXRef.current = t.screenX; + startYRef.current = t.screenY; + armedRef.current = isInLeftEdge(t.screenX, viewWidth); + startValueRef.current = seedRef.current; + }; + + const onTouchMove = (e: TouchEvent) => { + if (!armedRef.current) return; + const t = e.touches[0]; + if (!t) return; + const dx = t.screenX - startXRef.current; + const dy = t.screenY - startYRef.current; + // Reserve the strip in scrolled mode: stop native scroll from the first + // move, so there is no scroll-then-freeze jump once brightness activates. + if (latestRef.current.scrolled) e.preventDefault(); + if (!activeRef.current && shouldActivate(dx, dy)) { + activeRef.current = true; + } + if (!activeRef.current) return; + e.preventDefault(); + e.stopImmediatePropagation(); + const value = computeBrightness(startValueRef.current, dy, viewHeightRef.current); + levelRef.current = value; + scheduleBrightness(value); + setOverlayVisible(true); + setOverlayLevel(value); + }; + + const onTouchEnd = (e: TouchEvent) => { + if (!activeRef.current) { + resetGesture(); + return; + } + e.preventDefault(); + e.stopImmediatePropagation(); + cancelRaf(); + const value = levelRef.current; + setScreenBrightness(value); + seedRef.current = value; + saveSysSettings(envConfig, 'screenBrightness', Math.round(value * 100)); + saveSysSettings(envConfig, 'autoScreenBrightness', false); + if (hideTimerRef.current) clearTimeout(hideTimerRef.current); + hideTimerRef.current = setTimeout(() => setOverlayVisible(false), OVERLAY_HIDE_DELAY_MS); + resetGesture(); + }; + + doc.addEventListener('touchstart', onTouchStart, opts); + doc.addEventListener('touchmove', onTouchMove, opts); + doc.addEventListener('touchend', onTouchEnd, opts); + doc.addEventListener('touchcancel', onTouchEnd, opts); + }, + [resetGesture, scheduleBrightness, cancelRaf, setScreenBrightness, envConfig], + ); + + useEffect(() => { + return () => { + cancelRaf(); + if (hideTimerRef.current) clearTimeout(hideTimerRef.current); + }; + }, [cancelRaf]); + + return { registerBrightnessListeners, overlayVisible, overlayLevel }; +}; diff --git a/apps/readest-app/src/app/reader/utils/brightnessGesture.ts b/apps/readest-app/src/app/reader/utils/brightnessGesture.ts new file mode 100644 index 00000000..13aa1a54 --- /dev/null +++ b/apps/readest-app/src/app/reader/utils/brightnessGesture.ts @@ -0,0 +1,58 @@ +/** + * Pure helpers for the left-edge swipe-to-adjust-brightness gesture. + * + * The gesture reserves the left `BRIGHTNESS_GESTURE_EDGE_RATIO` of the rendered + * iframe-document width. Once a touch that starts there becomes vertical-dominant + * past `BRIGHTNESS_GESTURE_ACTIVATION_PX`, finger travel maps to brightness. + * + * Brightness math happens in the same perceptual "position" space the menu + * slider uses (`ColorPanel.tsx`: position = value^0.5, value = position^2), so + * the gesture, the overlay fill, and the slider all agree. + */ + +export const BRIGHTNESS_GESTURE_EDGE_RATIO = 0.1; +export const BRIGHTNESS_GESTURE_ACTIVATION_PX = 18; + +const clamp01 = (v: number): number => Math.max(0, Math.min(1, v)); + +/** True when `clientX` falls within the left edge strip of the view. */ +export const isInLeftEdge = ( + clientX: number, + viewWidth: number, + edgeRatio = BRIGHTNESS_GESTURE_EDGE_RATIO, +): boolean => viewWidth > 0 && clientX <= viewWidth * edgeRatio; + +/** True when movement is vertical-dominant and past the activation threshold. */ +export const shouldActivate = ( + deltaX: number, + deltaY: number, + threshold = BRIGHTNESS_GESTURE_ACTIVATION_PX, +): boolean => Math.abs(deltaY) >= threshold && Math.abs(deltaY) > Math.abs(deltaX); + +/** Perceptual position (0-1) for a brightness value (0-1) — matches the slider. */ +export const valueToPosition = (value: number): number => Math.sqrt(clamp01(value)); + +/** Brightness value (0-1) for a perceptual position (0-1) — matches the slider. */ +export const positionToValue = (position: number): number => { + const p = clamp01(position); + return clamp01(p * p); +}; + +/** + * New brightness value after dragging `deltaY` px from `startValue`. + * + * Up (negative `deltaY`) brightens. A full view-height drag spans the whole + * range. Travel is linear in perceptual position, then converted back to a + * brightness value so the feel matches the slider. The start is clamped, so an + * unseeded/`-1` value is safe. + */ +export const computeBrightness = ( + startValue: number, + deltaY: number, + viewHeight: number, +): number => { + if (viewHeight <= 0) return clamp01(startValue); + const startPos = valueToPosition(clamp01(startValue)); + const nextPos = clamp01(startPos - deltaY / viewHeight); + return positionToValue(nextPos); +}; diff --git a/apps/readest-app/src/components/settings/ControlPanel.tsx b/apps/readest-app/src/components/settings/ControlPanel.tsx index 37e01343..94cbb871 100644 --- a/apps/readest-app/src/components/settings/ControlPanel.tsx +++ b/apps/readest-app/src/components/settings/ControlPanel.tsx @@ -48,6 +48,9 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes const [isEink, setIsEink] = useState(viewSettings.isEink); const [isColorEink, setIsColorEink] = useState(viewSettings.isColorEink); const [autoScreenBrightness, setAutoScreenBrightness] = useState(settings.autoScreenBrightness); + const [swipeBrightnessGesture, setSwipeBrightnessGesture] = useState( + settings.swipeBrightnessGesture, + ); const [screenWakeLock, setScreenWakeLock] = useState(settings.screenWakeLock); const [allowScript, setAllowScript] = useState(viewSettings.allowScript); @@ -191,6 +194,12 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoScreenBrightness]); + useEffect(() => { + if (swipeBrightnessGesture === settings.swipeBrightnessGesture) return; + saveSysSettings(envConfig, 'swipeBrightnessGesture', swipeBrightnessGesture); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [swipeBrightnessGesture]); + useEffect(() => { if (screenWakeLock === settings.screenWakeLock) return; saveSysSettings(envConfig, 'screenWakeLock', screenWakeLock); @@ -382,6 +391,15 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes onChange={() => setAutoScreenBrightness(!autoScreenBrightness)} /> )} + {appService?.hasScreenBrightness && ( + setSwipeBrightnessGesture(!swipeBrightnessGesture)} + data-setting-id='settings.control.swipeBrightnessGesture' + /> + )} = { screenWakeLock: false, screenBrightness: -1, // -1~100, -1 for system default autoScreenBrightness: true, + swipeBrightnessGesture: true, hardwarePageTurner: { enabled: false, bindings: { pagePrev: null, pageNext: null, sectionPrev: null, sectionNext: null }, diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index 6ee3e635..489362a9 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -284,6 +284,7 @@ export interface SystemSettings { screenWakeLock: boolean; screenBrightness: number; autoScreenBrightness: boolean; + swipeBrightnessGesture: boolean; hardwarePageTurner: HardwarePageTurnerSettings; alwaysShowStatusBar: boolean; alwaysInForeground: boolean;