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) <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:
Huang Xin
2026-05-29 21:28:42 +08:00
committed by GitHub
parent 89723b421e
commit 36e11de332
43 changed files with 1013 additions and 33 deletions
@@ -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);
};