* docs: design spec for gesture-based brightness control (#3021) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<BrightnessOverlay visible={overlayVisible} level={overlayLevel} />` 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 |
|
||||
@@ -1805,5 +1805,7 @@
|
||||
"No note yet": "لا توجد ملاحظات بعد",
|
||||
"No annotation yet": "لا توجد تعليقات بعد",
|
||||
"No bookmark yet": "لا توجد إشارات مرجعية بعد",
|
||||
"Importing...": "جاري الاستيراد..."
|
||||
"Importing...": "جاري الاستيراد...",
|
||||
"Swipe for Brightness": "اسحب لضبط السطوع",
|
||||
"Slide along the left edge": "اسحب على طول الحافة اليسرى"
|
||||
}
|
||||
|
||||
@@ -1677,5 +1677,7 @@
|
||||
"No note yet": "এখনও কোনো নোট নেই",
|
||||
"No annotation yet": "এখনও কোনো টীকা নেই",
|
||||
"No bookmark yet": "এখনও কোনো বুকমার্ক নেই",
|
||||
"Importing...": "আমদানি হচ্ছে..."
|
||||
"Importing...": "আমদানি হচ্ছে...",
|
||||
"Swipe for Brightness": "উজ্জ্বলতার জন্য সোয়াইপ করুন",
|
||||
"Slide along the left edge": "বাম প্রান্ত বরাবর স্লাইড করুন"
|
||||
}
|
||||
|
||||
@@ -1645,5 +1645,7 @@
|
||||
"No note yet": "ད་དུང་ཟིན་བྲིས་མེད།",
|
||||
"No annotation yet": "ད་དུང་མཆན་འགྲེལ་མེད།",
|
||||
"No bookmark yet": "ད་དུང་དཔེ་རྟགས་མེད།",
|
||||
"Importing...": "ནང་འདྲེན་བྱེད་བཞིན་པ..."
|
||||
"Importing...": "ནང་འདྲེན་བྱེད་བཞིན་པ...",
|
||||
"Swipe for Brightness": "འོད་གདངས་ཆེད་འཐེན།",
|
||||
"Slide along the left edge": "གཡོན་ངོས་ཟུར་ནས་འཐེན།"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1677,5 +1677,7 @@
|
||||
"No note yet": "Δεν υπάρχει ακόμη σημείωση",
|
||||
"No annotation yet": "Δεν υπάρχει ακόμη σχολιασμός",
|
||||
"No bookmark yet": "Δεν υπάρχει ακόμη σελιδοδείκτης",
|
||||
"Importing...": "Εισαγωγή..."
|
||||
"Importing...": "Εισαγωγή...",
|
||||
"Swipe for Brightness": "Σάρωση για φωτεινότητα",
|
||||
"Slide along the left edge": "Σύρετε κατά μήκος της αριστερής άκρης"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1677,5 +1677,7 @@
|
||||
"No note yet": "هنوز یادداشتی وجود ندارد",
|
||||
"No annotation yet": "هنوز یادداشتی وجود ندارد",
|
||||
"No bookmark yet": "هنوز نشانکی وجود ندارد",
|
||||
"Importing...": "در حال وارد کردن..."
|
||||
"Importing...": "در حال وارد کردن...",
|
||||
"Swipe for Brightness": "کشیدن برای روشنایی",
|
||||
"Slide along the left edge": "در امتداد لبه چپ بکشید"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1709,5 +1709,7 @@
|
||||
"No note yet": "אין הערות עדיין",
|
||||
"No annotation yet": "אין הערות עדיין",
|
||||
"No bookmark yet": "אין סימניות עדיין",
|
||||
"Importing...": "מייבא..."
|
||||
"Importing...": "מייבא...",
|
||||
"Swipe for Brightness": "החלקה לכוונון בהירות",
|
||||
"Slide along the left edge": "החליקו לאורך הקצה השמאלי"
|
||||
}
|
||||
|
||||
@@ -1677,5 +1677,7 @@
|
||||
"No note yet": "अभी तक कोई नोट नहीं",
|
||||
"No annotation yet": "अभी तक कोई टिप्पणी नहीं",
|
||||
"No bookmark yet": "अभी तक कोई बुकमार्क नहीं",
|
||||
"Importing...": "आयात हो रहा है..."
|
||||
"Importing...": "आयात हो रहा है...",
|
||||
"Swipe for Brightness": "चमक के लिए स्वाइप करें",
|
||||
"Slide along the left edge": "बाएँ किनारे पर स्लाइड करें"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1645,5 +1645,7 @@
|
||||
"No note yet": "メモはまだありません",
|
||||
"No annotation yet": "注釈はまだありません",
|
||||
"No bookmark yet": "ブックマークはまだありません",
|
||||
"Importing...": "インポートしています..."
|
||||
"Importing...": "インポートしています...",
|
||||
"Swipe for Brightness": "スワイプで明るさ調整",
|
||||
"Slide along the left edge": "左端をスライド"
|
||||
}
|
||||
|
||||
@@ -1645,5 +1645,7 @@
|
||||
"No note yet": "아직 노트가 없습니다",
|
||||
"No annotation yet": "아직 주석이 없습니다",
|
||||
"No bookmark yet": "아직 북마크가 없습니다",
|
||||
"Importing...": "가져오는 중..."
|
||||
"Importing...": "가져오는 중...",
|
||||
"Swipe for Brightness": "밝기 조절 스와이프",
|
||||
"Slide along the left edge": "왼쪽 가장자리를 따라 슬라이드"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1741,5 +1741,7 @@
|
||||
"No note yet": "Пока нет заметок",
|
||||
"No annotation yet": "Пока нет аннотаций",
|
||||
"No bookmark yet": "Пока нет закладок",
|
||||
"Importing...": "Импорт..."
|
||||
"Importing...": "Импорт...",
|
||||
"Swipe for Brightness": "Свайп для яркости",
|
||||
"Slide along the left edge": "Проведите вдоль левого края"
|
||||
}
|
||||
|
||||
@@ -1677,5 +1677,7 @@
|
||||
"No note yet": "තවම සටහනක් නැත",
|
||||
"No annotation yet": "තවම විවරණයක් නැත",
|
||||
"No bookmark yet": "තවම පොත් සලකුණක් නැත",
|
||||
"Importing...": "ආයාත වෙමින්..."
|
||||
"Importing...": "ආයාත වෙමින්...",
|
||||
"Swipe for Brightness": "දීප්තිය සඳහා ස්වයිප් කරන්න",
|
||||
"Slide along the left edge": "වම් කෙළවර දිගේ අදින්න"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1677,5 +1677,7 @@
|
||||
"No note yet": "இன்னும் குறிப்பு இல்லை",
|
||||
"No annotation yet": "இன்னும் சிறுகுறிப்பு இல்லை",
|
||||
"No bookmark yet": "இன்னும் புக்மார்க் இல்லை",
|
||||
"Importing...": "இறக்குமதி செய்கிறது..."
|
||||
"Importing...": "இறக்குமதி செய்கிறது...",
|
||||
"Swipe for Brightness": "ஒளிர்வுக்கு ஸ்வைப் செய்யவும்",
|
||||
"Slide along the left edge": "இடது விளிம்பில் இழுக்கவும்"
|
||||
}
|
||||
|
||||
@@ -1645,5 +1645,7 @@
|
||||
"No note yet": "ยังไม่มีบันทึก",
|
||||
"No annotation yet": "ยังไม่มีคำอธิบายประกอบ",
|
||||
"No bookmark yet": "ยังไม่มีบุ๊กมาร์ก",
|
||||
"Importing...": "กำลังนำเข้า..."
|
||||
"Importing...": "กำลังนำเข้า...",
|
||||
"Swipe for Brightness": "ปัดเพื่อปรับความสว่าง",
|
||||
"Slide along the left edge": "เลื่อนตามขอบด้านซ้าย"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1741,5 +1741,7 @@
|
||||
"No note yet": "Поки немає нотаток",
|
||||
"No annotation yet": "Поки немає анотацій",
|
||||
"No bookmark yet": "Поки немає закладок",
|
||||
"Importing...": "Імпорт..."
|
||||
"Importing...": "Імпорт...",
|
||||
"Swipe for Brightness": "Свайп для яскравості",
|
||||
"Slide along the left edge": "Проведіть уздовж лівого краю"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1645,5 +1645,7 @@
|
||||
"No note yet": "尚无笔记",
|
||||
"No annotation yet": "尚无标注",
|
||||
"No bookmark yet": "尚无书签",
|
||||
"Importing...": "正在导入..."
|
||||
"Importing...": "正在导入...",
|
||||
"Swipe for Brightness": "滑动调节亮度",
|
||||
"Slide along the left edge": "沿左侧边缘滑动"
|
||||
}
|
||||
|
||||
@@ -1645,5 +1645,7 @@
|
||||
"No note yet": "尚無筆記",
|
||||
"No annotation yet": "尚無註解",
|
||||
"No bookmark yet": "尚無書籤",
|
||||
"Importing...": "正在匯入..."
|
||||
"Importing...": "正在匯入...",
|
||||
"Swipe for Brightness": "滑動調整亮度",
|
||||
"Slide along the left edge": "沿左側邊緣滑動"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof useBrightnessGesture>;
|
||||
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(<Wrapper />);
|
||||
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(<Wrapper />) };
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<BrightnessOverlayProps> = ({ 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 (
|
||||
<div
|
||||
aria-hidden
|
||||
dir='ltr'
|
||||
className={clsx(
|
||||
'pointer-events-none absolute left-0 top-1/2 z-[15] -translate-y-1/2',
|
||||
'not-eink:transition-opacity not-eink:duration-200 motion-reduce:transition-none',
|
||||
visible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
style={{ marginInlineStart: 'calc(env(safe-area-inset-left) + 12px)' }}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'eink-bordered flex flex-col items-center gap-2 rounded-full px-2 py-3',
|
||||
'bg-base-100/90 not-eink:shadow-md',
|
||||
)}
|
||||
>
|
||||
<PiSun className='text-base-content h-4 w-4' />
|
||||
<div className='bg-base-content/20 relative h-40 w-1.5 overflow-hidden rounded-full'>
|
||||
<div
|
||||
className='bg-base-content absolute bottom-0 left-0 w-full rounded-full'
|
||||
style={{ height: `${fillPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-base-content text-xs tabular-nums'>{valuePercent}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrightnessOverlay;
|
||||
@@ -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}
|
||||
/>
|
||||
<BrightnessOverlay visible={overlayVisible} level={overlayLevel} />
|
||||
<ParagraphControl bookKey={bookKey} viewRef={viewRef} gridInsets={gridInsets} />
|
||||
{((!docLoaded.current && loading) || viewState?.loading) && (
|
||||
<div className='absolute left-0 top-0 z-10 flex h-full w-full items-center justify-center'>
|
||||
|
||||
@@ -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<LatestState>({ 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<number | null>(null);
|
||||
const pendingValueRef = useRef<number | null>(null);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | 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 };
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -48,6 +48,9 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ 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<SettingsPanelPanelProp> = ({ 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<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
|
||||
onChange={() => setAutoScreenBrightness(!autoScreenBrightness)}
|
||||
/>
|
||||
)}
|
||||
{appService?.hasScreenBrightness && (
|
||||
<SettingsSwitchRow
|
||||
label={_('Swipe for Brightness')}
|
||||
description={_('Slide along the left edge')}
|
||||
checked={swipeBrightnessGesture}
|
||||
onChange={() => setSwipeBrightnessGesture(!swipeBrightnessGesture)}
|
||||
data-setting-id='settings.control.swipeBrightnessGesture'
|
||||
/>
|
||||
)}
|
||||
<SettingsSwitchRow
|
||||
label={_('Keep Screen Awake')}
|
||||
checked={screenWakeLock}
|
||||
|
||||
@@ -109,6 +109,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||||
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 },
|
||||
|
||||
@@ -284,6 +284,7 @@ export interface SystemSettings {
|
||||
screenWakeLock: boolean;
|
||||
screenBrightness: number;
|
||||
autoScreenBrightness: boolean;
|
||||
swipeBrightnessGesture: boolean;
|
||||
hardwarePageTurner: HardwarePageTurnerSettings;
|
||||
alwaysShowStatusBar: boolean;
|
||||
alwaysInForeground: boolean;
|
||||
|
||||
Reference in New Issue
Block a user