Files
readest/apps/readest-app/src/components/settings/ControlPanel.tsx
T
Huang Xin 600d69fa50 fix(reader): gate route View Transitions on API support (READEST-9) (#4989)
* fix(reader): gate route View Transitions on the API, turns on groups (READEST-9)

Reverts #4949, which opened books through the plain router to dodge the
"Transition was aborted because of timeout in DOM update" TimeoutError
(Sentry READEST-9). Rather than carve the transition out of one flow, gate it
at the router: useAppRouter routes through the View Transition router only
where the engine has the View Transitions API, and every into-reader path
(including the reverted ones) goes back through useAppRouter.

The base View Transitions API and nested view-transition groups reach very
different browsers, so they become two separate appService capability flags,
each backed by a probe in utils/viewTransition:

* supportsViewTransitionsAPI (document.startViewTransition): the baseline a
  route crossfade needs, landing on Chrome 111+, Safari 18+, recent WebView.
  Gates the router.
* supportsViewTransitionGroup (view-transition-group: nearest, Chrome/WebView
  140+): the far narrower target the paginator's layered turns require. Gates
  the turn-style options and the captured-turn fallback.

Both flags fold in the Linux WebKitGTK carve-out because it crashes on the
snapshot, matching the supportsCanvasContext2DFilter precedent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tts): enlarge the Now Playing bar and scale its controls responsively

Grow the collapsed bar to h-14 with a 10x10 cover and symmetric px-2 padding,
drive the play/pause and close icons through useResponsiveSize instead of fixed
pixel sizes, and cut the bottom safe-area contribution to a third so the bar
sits closer to the screen 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>
2026-07-07 09:20:06 +02:00

555 lines
21 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useResetViewSettings } from '@/hooks/useResetSettings';
import { useEinkMode } from '@/hooks/useEinkMode';
import { getStyles } from '@/utils/style';
import { getMaxInlineSize } from '@/utils/config';
import { saveSysSettings, saveViewSettings } from '@/helpers/settings';
import { PageTurnStyle } from '@/types/book';
import { SettingsPanelPanelProp } from './SettingsDialog';
import { annotationToolQuickActions } from '@/app/reader/components/annotator/AnnotationTools';
import { applyPageTurnAttributes } from '@/app/reader/hooks/useCapturedTurn';
import { isTauriAppPlatform } from '@/services/environment';
import {
BoxedList,
NavigationRow,
SettingsRow,
SettingsSelect,
SettingsSwitchRow,
} from './primitives';
import NumberInput from './NumberInput';
import PageTurnerSettings from './PageTurnerSettings';
import AnnotationToolbarCustomizer from './AnnotationToolbarCustomizer';
import { DEFAULT_ANNOTATION_TOOLBAR_ITEMS } from '@/utils/annotationToolbar';
import { canShareText } from '@/utils/share';
import { optInTelemetry, optOutTelemetry } from '@/utils/telemetry';
const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { getView, getViewSettings, recreateViewer } = useReaderStore();
const { getBookData } = useBookDataStore();
const { settings } = useSettingsStore();
const { applyEinkMode } = useEinkMode();
const bookData = getBookData(bookKey);
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
const [isScrolledMode, setScrolledMode] = useState(viewSettings.scrolled);
const [noContinuousScroll, setNoContinuousScroll] = useState(viewSettings.noContinuousScroll);
const [scrollingOverlap, setScrollingOverlap] = useState(viewSettings.scrollingOverlap);
const [hideScrollbar, setHideScrollbar] = useState(viewSettings.hideScrollbar || false);
const [showPaginationButtons, setShowPaginationButtons] = useState(
viewSettings.showPaginationButtons,
);
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick);
const [isDisableSwipe, setIsDisableSwipe] = useState(viewSettings.disableSwipe);
const [fullscreenClickArea, setFullscreenClickArea] = useState(viewSettings.fullscreenClickArea);
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea);
const [isDisableDoubleClick, setIsDisableDoubleClick] = useState(viewSettings.disableDoubleClick);
const [enableAnnotationQuickActions, setEnableAnnotationQuickActions] = useState(
viewSettings.enableAnnotationQuickActions,
);
const [annotationQuickAction, setAnnotationQuickAction] = useState(
viewSettings.annotationQuickAction,
);
const [copyToNotebook, setCopyToNotebook] = useState(viewSettings.copyToNotebook);
const [showToolbarCustomizer, setShowToolbarCustomizer] = useState(false);
const [animated, setAnimated] = useState(viewSettings.animated);
const [pageTurnStyle, setPageTurnStyle] = useState(viewSettings.pageTurnStyle || 'push');
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);
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
const [isNightlyChannel, setIsNightlyChannel] = useState(settings.updateChannel === 'nightly');
const [isTelemetryEnabled, setIsTelemetryEnabled] = useState(settings.telemetryEnabled);
const resetToDefaults = useResetViewSettings();
const pageTurnerResetRef = useRef<() => void>(() => {});
const canShare = canShareText(appService);
// The layered styles need an engine with full View Transitions support or
// the Tauri captured-turn fallback; engines like iOS 18 WebKit crash on
// the VT turns, so on the web they only get Push (readest#555).
const turnStyleOptions = [
{ value: 'push', label: _('Push') },
...(appService?.supportsViewTransitionGroup || isTauriAppPlatform()
? [
{ value: 'slide', label: _('Slide') },
{ value: 'curl', label: _('Page Curl') },
]
: []),
];
const handleReset = () => {
resetToDefaults({
scrolled: setScrolledMode,
noContinuousScroll: setNoContinuousScroll,
scrollingOverlap: setScrollingOverlap,
hideScrollbar: setHideScrollbar,
showPaginationButtons: setShowPaginationButtons,
disableClick: setIsDisableClick,
disableSwipe: setIsDisableSwipe,
swapClickArea: setSwapClickArea,
animated: setAnimated,
isEink: setIsEink,
allowScript: setAllowScript,
fullscreenClickArea: setFullscreenClickArea,
disableDoubleClick: setIsDisableDoubleClick,
enableAnnotationQuickActions: setEnableAnnotationQuickActions,
copyToNotebook: setCopyToNotebook,
});
saveViewSettings(
envConfig,
bookKey,
'annotationToolbarItems',
DEFAULT_ANNOTATION_TOOLBAR_ITEMS,
false,
true,
);
pageTurnerResetRef.current();
};
useEffect(() => {
onRegisterReset(handleReset);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (isScrolledMode === viewSettings.scrolled) return;
saveViewSettings(envConfig, bookKey, 'scrolled', isScrolledMode);
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
getView(bookKey)?.renderer.setAttribute(
'max-inline-size',
`${getMaxInlineSize(viewSettings)}px`,
);
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isScrolledMode]);
useEffect(() => {
if (noContinuousScroll === viewSettings.noContinuousScroll) return;
saveViewSettings(envConfig, bookKey, 'noContinuousScroll', noContinuousScroll);
if (noContinuousScroll) {
getView(bookKey)?.renderer.setAttribute('no-continuous-scroll', '');
} else {
getView(bookKey)?.renderer.removeAttribute('no-continuous-scroll');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [noContinuousScroll]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'hideScrollbar', hideScrollbar, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hideScrollbar]);
useEffect(() => {
if (scrollingOverlap === viewSettings.scrollingOverlap) return;
saveViewSettings(envConfig, bookKey, 'scrollingOverlap', scrollingOverlap, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollingOverlap]);
useEffect(() => {
saveViewSettings(
envConfig,
bookKey,
'showPaginationButtons',
showPaginationButtons,
false,
false,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showPaginationButtons]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'disableClick', isDisableClick, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDisableClick]);
// The renderer reads `turn-style`/`no-swipe` at touchmove/touchend time, so
// settings changes have to push the attributes through immediately rather
// than waiting for the next recreateViewer pass.
const applyTurnAttributes = () => {
const view = getView(bookKey);
const freshSettings = getViewSettings(bookKey);
if (view && freshSettings) {
applyPageTurnAttributes(view, freshSettings, !!bookData?.isFixedLayout);
}
};
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'disableSwipe', isDisableSwipe, false, false);
applyTurnAttributes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDisableSwipe]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'disableDoubleClick', isDisableDoubleClick, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDisableDoubleClick]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'fullscreenClickArea', fullscreenClickArea, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fullscreenClickArea]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'swapClickArea', swapClickArea, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [swapClickArea]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'animated', animated, false, false);
if (animated) {
getView(bookKey)?.renderer.setAttribute('animated', '');
} else {
getView(bookKey)?.renderer.removeAttribute('animated');
}
// Mesh-curl eligibility depends on `animated`.
applyTurnAttributes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animated]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'pageTurnStyle', pageTurnStyle, false, false);
applyTurnAttributes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageTurnStyle]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'isEink', isEink);
if (isEink) {
getView(bookKey)?.renderer.setAttribute('eink', '');
} else {
getView(bookKey)?.renderer.removeAttribute('eink');
}
applyEinkMode(isEink);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEink]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'isColorEink', isColorEink);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isColorEink]);
useEffect(() => {
if (autoScreenBrightness === settings.autoScreenBrightness) return;
saveSysSettings(envConfig, 'autoScreenBrightness', autoScreenBrightness);
// 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);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screenWakeLock]);
useEffect(() => {
if (viewSettings.allowScript === allowScript) return;
saveViewSettings(envConfig, bookKey, 'allowScript', allowScript, true, false).then(() => {
recreateViewer(envConfig, bookKey);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowScript]);
useEffect(() => {
saveViewSettings(
envConfig,
bookKey,
'enableAnnotationQuickActions',
enableAnnotationQuickActions,
false,
false,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enableAnnotationQuickActions]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'copyToNotebook', copyToNotebook, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [copyToNotebook]);
const toggleAutoCheckUpdates = () => {
const newValue = !isAutoCheckUpdates;
saveSysSettings(envConfig, 'autoCheckUpdates', newValue);
setIsAutoCheckUpdates(newValue);
};
const toggleNightlyChannel = () => {
const newValue = !isNightlyChannel;
saveSysSettings(envConfig, 'updateChannel', newValue ? 'nightly' : 'stable');
setIsNightlyChannel(newValue);
};
const toggleTelemetry = () => {
const newValue = !isTelemetryEnabled;
saveSysSettings(envConfig, 'telemetryEnabled', newValue);
setIsTelemetryEnabled(newValue);
if (newValue) {
optInTelemetry();
} else {
optOutTelemetry();
}
};
const getQuickActionOptions = () => {
return [
{
value: '',
label: _('None'),
},
...annotationToolQuickActions
.filter((button) => button.type !== 'share' || canShare)
.map((button) => ({
value: button.type,
label: _(button.label),
})),
];
};
const handleSelectAnnotationQuickAction = (event: React.ChangeEvent<HTMLSelectElement>) => {
const action = event.target.value as typeof annotationQuickAction;
setAnnotationQuickAction(action);
saveViewSettings(envConfig, bookKey, 'annotationQuickAction', action, false, true);
};
if (showToolbarCustomizer) {
return (
<AnnotationToolbarCustomizer
bookKey={bookKey}
onBack={() => setShowToolbarCustomizer(false)}
/>
);
}
return (
<div className='my-4 w-full space-y-6'>
<BoxedList title={_('Scroll')} data-setting-id='settings.control.scrolledMode'>
<SettingsSwitchRow
label={_('Scrolled Mode')}
checked={isScrolledMode}
disabled={bookData?.isFixedLayout}
onChange={() => setScrolledMode(!isScrolledMode)}
/>
<SettingsSwitchRow
label={_('Single Section Scroll')}
checked={noContinuousScroll}
disabled={!viewSettings.scrolled}
onChange={() => setNoContinuousScroll(!noContinuousScroll)}
data-setting-id='settings.control.scroll.noContinuousScroll'
/>
<NumberInput
label={_('Overlap Pixels')}
value={scrollingOverlap}
onChange={setScrollingOverlap}
disabled={!viewSettings.scrolled}
min={0}
max={200}
step={10}
data-setting-id='settings.control.overlapPixels'
/>
<SettingsSwitchRow
label={_('Hide Scrollbar')}
checked={hideScrollbar}
disabled={!viewSettings.scrolled}
onChange={() => setHideScrollbar(!hideScrollbar)}
data-setting-id='settings.control.scroll.hideScrollbar'
/>
</BoxedList>
<BoxedList title={_('Pagination')} data-setting-id='settings.control.clickToPaginate'>
<SettingsSwitchRow
label={appService?.isMobileApp ? _('Tap to Paginate') : _('Click to Paginate')}
checked={!isDisableClick}
onChange={() => setIsDisableClick(!isDisableClick)}
/>
<SettingsSwitchRow
label={_('Swipe to Paginate')}
checked={!isDisableSwipe}
onChange={() => setIsDisableSwipe(!isDisableSwipe)}
data-setting-id='settings.control.swipeToPaginate'
/>
<SettingsSwitchRow
label={appService?.isMobileApp ? _('Tap Both Sides') : _('Click Both Sides')}
checked={fullscreenClickArea}
disabled={isDisableClick}
onChange={() => setFullscreenClickArea(!fullscreenClickArea)}
data-setting-id='settings.control.clickBothSides'
/>
<SettingsSwitchRow
label={appService?.isMobileApp ? _('Swap Tap Sides') : _('Swap Click Sides')}
checked={swapClickArea}
disabled={isDisableClick || fullscreenClickArea}
onChange={() => setSwapClickArea(!swapClickArea)}
data-setting-id='settings.control.swapClickSides'
/>
<SettingsSwitchRow
label={appService?.isMobileApp ? _('Disable Double Tap') : _('Disable Double Click')}
checked={isDisableDoubleClick}
onChange={() => setIsDisableDoubleClick(!isDisableDoubleClick)}
data-setting-id='settings.control.disableDoubleClick'
/>
<SettingsSwitchRow
label={_('Show Page Navigation Buttons')}
checked={showPaginationButtons}
onChange={() => setShowPaginationButtons(!showPaginationButtons)}
data-setting-id='settings.control.showPaginationButtons'
/>
</BoxedList>
<PageTurnerSettings
bookKey={bookKey}
onRegisterReset={(fn) => {
pageTurnerResetRef.current = fn;
}}
/>
<BoxedList
title={_('Annotation Tools')}
data-setting-id='settings.control.enableQuickActions'
>
<SettingsSwitchRow
label={_('Enable Quick Actions')}
checked={enableAnnotationQuickActions}
onChange={() => setEnableAnnotationQuickActions(!enableAnnotationQuickActions)}
/>
<SettingsRow label={_('Quick Action')} data-setting-id='settings.control.quickAction'>
<SettingsSelect
value={annotationQuickAction || ''}
onChange={handleSelectAnnotationQuickAction}
ariaLabel={_('Quick Action')}
options={getQuickActionOptions()}
disabled={!enableAnnotationQuickActions}
/>
</SettingsRow>
<SettingsSwitchRow
label={_('Copy to Notebook')}
checked={copyToNotebook}
onChange={() => setCopyToNotebook(!copyToNotebook)}
data-setting-id='settings.control.copyToNotebook'
/>
<NavigationRow
title={_('Customize Toolbar')}
onClick={() => setShowToolbarCustomizer(true)}
data-setting-id='settings.control.customizeToolbar'
/>
</BoxedList>
<BoxedList title={_('Animation')} data-setting-id='settings.control.pagingAnimation'>
<SettingsSwitchRow
label={_('Paging Animation')}
checked={animated}
onChange={() => setAnimated(!animated)}
/>
<SettingsRow label={_('Animation Style')} data-setting-id='settings.control.pageTurnStyle'>
<SettingsSelect
// A synced slide/curl setting from another device still reads as
// push here when this engine cannot animate it.
value={
turnStyleOptions.some((opt) => opt.value === pageTurnStyle) ? pageTurnStyle : 'push'
}
onChange={(e) => setPageTurnStyle(e.target.value as PageTurnStyle)}
ariaLabel={_('Animation Style')}
options={turnStyleOptions}
disabled={!animated}
/>
</SettingsRow>
</BoxedList>
<BoxedList title={_('Device')} data-setting-id='settings.control.device'>
{(appService?.isAndroidApp || appService?.appPlatform === 'web') && (
<SettingsSwitchRow
label={_('E-Ink Mode')}
checked={isEink}
onChange={() => setIsEink(!isEink)}
data-setting-id='settings.control.einkMode'
/>
)}
{(appService?.isAndroidApp || appService?.appPlatform === 'web') && (
<SettingsSwitchRow
label={_('Color E-Ink Mode')}
checked={isColorEink}
disabled={!isEink}
onChange={() => setIsColorEink(!isColorEink)}
data-setting-id='settings.control.colorEinkMode'
/>
)}
{appService?.isMobileApp && (
<SettingsSwitchRow
label={_('System Screen Brightness')}
checked={autoScreenBrightness}
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}
onChange={() => setScreenWakeLock(!screenWakeLock)}
data-setting-id='settings.control.screenWakeLock'
/>
</BoxedList>
{appService?.hasUpdater && (
<BoxedList title={_('Update')} data-setting-id='settings.control.checkUpdates'>
<SettingsSwitchRow
label={_('Check Updates on Start')}
checked={isAutoCheckUpdates}
onChange={toggleAutoCheckUpdates}
/>
<SettingsSwitchRow
label={_('Nightly Builds')}
description={isNightlyChannel ? _('Early daily builds') : ''}
checked={isNightlyChannel}
onChange={toggleNightlyChannel}
data-setting-id='settings.control.nightlyChannel'
/>
</BoxedList>
)}
<BoxedList title={_('Security')} data-setting-id='settings.control.allowJavascript'>
<SettingsSwitchRow
label={_('Allow JavaScript')}
description={_('Enable only if you trust the file.')}
checked={allowScript}
disabled={bookData?.book?.format !== 'EPUB'}
onChange={() => setAllowScript(!allowScript)}
/>
</BoxedList>
<BoxedList title={_('Privacy')} data-setting-id='settings.control.telemetry'>
<SettingsSwitchRow
label={_('Help improve Readest')}
description={isTelemetryEnabled ? _('Sharing anonymized statistics') : ''}
checked={isTelemetryEnabled}
onChange={toggleTelemetry}
/>
</BoxedList>
</div>
);
};
export default ControlPanel;