feat(reader): add contrast option to PDF/CBZ view menu (#4800)
Add a Contrast stepper to the reader View menu for fixed-layout (PDF/CBZ) documents. It increases and decreases page contrast via a CSS filter on the rendered page images, applies to the whole book, and is stored per-book (local to the current document). The filter is built in applyFixedlayoutStyles by combining any dark-mode invert with the contrast amount into a single filter declaration. Persisted with skipGlobal so it never touches global view settings, and added to FoliateViewer's effect dependencies so the change re-applies across all rendered pages. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -505,6 +505,7 @@ describe('services/constants', () => {
|
||||
expect(typeof DEFAULT_BOOK_STYLE.wordSpacing).toBe('number');
|
||||
expect(typeof DEFAULT_BOOK_STYLE.letterSpacing).toBe('number');
|
||||
expect(typeof DEFAULT_BOOK_STYLE.textIndent).toBe('number');
|
||||
expect(DEFAULT_BOOK_STYLE.contrast).toBe(100);
|
||||
});
|
||||
|
||||
it('has boolean style flags', () => {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/utils/misc', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
getOSPlatform: vi.fn(() => 'macos' as const),
|
||||
};
|
||||
});
|
||||
|
||||
import { applyFixedlayoutStyles, ThemeCode } from '@/utils/style';
|
||||
import { ViewSettings } from '@/types/book';
|
||||
import {
|
||||
DEFAULT_BOOK_FONT,
|
||||
DEFAULT_BOOK_LAYOUT,
|
||||
DEFAULT_BOOK_LANGUAGE,
|
||||
DEFAULT_BOOK_STYLE,
|
||||
DEFAULT_VIEW_CONFIG,
|
||||
DEFAULT_TTS_CONFIG,
|
||||
DEFAULT_TRANSLATOR_CONFIG,
|
||||
DEFAULT_ANNOTATOR_CONFIG,
|
||||
DEFAULT_SCREEN_CONFIG,
|
||||
} from '@/services/constants';
|
||||
|
||||
function makeViewSettings(overrides: Partial<ViewSettings> = {}): ViewSettings {
|
||||
return {
|
||||
...DEFAULT_BOOK_FONT,
|
||||
...DEFAULT_BOOK_LAYOUT,
|
||||
...DEFAULT_BOOK_LANGUAGE,
|
||||
...DEFAULT_BOOK_STYLE,
|
||||
...DEFAULT_VIEW_CONFIG,
|
||||
...DEFAULT_TTS_CONFIG,
|
||||
...DEFAULT_TRANSLATOR_CONFIG,
|
||||
...DEFAULT_ANNOTATOR_CONFIG,
|
||||
...DEFAULT_SCREEN_CONFIG,
|
||||
...overrides,
|
||||
} as ViewSettings;
|
||||
}
|
||||
|
||||
function makeThemeCode(overrides: Partial<ThemeCode> = {}): ThemeCode {
|
||||
return {
|
||||
bg: '#ffffff',
|
||||
fg: '#000000',
|
||||
primary: '#3366cc',
|
||||
isDarkMode: false,
|
||||
palette: {
|
||||
'base-100': '#ffffff',
|
||||
'base-200': '#f0f0f0',
|
||||
'base-300': '#e0e0e0',
|
||||
'base-content': '#000000',
|
||||
neutral: '#808080',
|
||||
'neutral-content': '#ffffff',
|
||||
primary: '#3366cc',
|
||||
secondary: '#6699cc',
|
||||
accent: '#33cc99',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Run applyFixedlayoutStyles on a fresh document and return the injected CSS. */
|
||||
function fixedLayoutCss(vs: ViewSettings, theme: ThemeCode): string {
|
||||
const doc = document.implementation.createHTMLDocument('test');
|
||||
applyFixedlayoutStyles(doc, vs, theme);
|
||||
return doc.getElementById('fixed-layout-styles')?.textContent ?? '';
|
||||
}
|
||||
|
||||
describe('applyFixedlayoutStyles contrast filter', () => {
|
||||
it('does not apply a contrast filter at the default 100%', () => {
|
||||
const css = fixedLayoutCss(makeViewSettings({ contrast: 100 }), makeThemeCode());
|
||||
expect(css).not.toContain('contrast(');
|
||||
});
|
||||
|
||||
it('applies a contrast filter when contrast is increased above 100%', () => {
|
||||
const css = fixedLayoutCss(makeViewSettings({ contrast: 150 }), makeThemeCode());
|
||||
expect(css).toContain('filter: contrast(150%)');
|
||||
});
|
||||
|
||||
it('applies a contrast filter when contrast is decreased below 100%', () => {
|
||||
const css = fixedLayoutCss(makeViewSettings({ contrast: 75 }), makeThemeCode());
|
||||
expect(css).toContain('filter: contrast(75%)');
|
||||
});
|
||||
|
||||
it('applies contrast in light mode too (independent of invertImgColorInDark)', () => {
|
||||
const css = fixedLayoutCss(
|
||||
makeViewSettings({ contrast: 150, invertImgColorInDark: true }),
|
||||
makeThemeCode({ isDarkMode: false }),
|
||||
);
|
||||
expect(css).toContain('contrast(150%)');
|
||||
expect(css).not.toContain('invert(100%)');
|
||||
});
|
||||
|
||||
it('combines invert and contrast into a single filter declaration in dark mode', () => {
|
||||
const css = fixedLayoutCss(
|
||||
makeViewSettings({ contrast: 150, invertImgColorInDark: true }),
|
||||
makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' }),
|
||||
);
|
||||
expect(css).toContain('filter: invert(100%) contrast(150%)');
|
||||
});
|
||||
|
||||
it('treats an undefined contrast as 100% (no filter, backward compatible)', () => {
|
||||
const css = fixedLayoutCss(
|
||||
makeViewSettings({ contrast: undefined as unknown as number }),
|
||||
makeThemeCode(),
|
||||
);
|
||||
expect(css).not.toContain('contrast(');
|
||||
});
|
||||
});
|
||||
@@ -834,6 +834,7 @@ const FoliateViewer: React.FC<{
|
||||
viewSettings?.overrideColor,
|
||||
viewSettings?.invertImgColorInDark,
|
||||
viewSettings?.applyThemeToPDF,
|
||||
viewSettings?.contrast,
|
||||
viewSettings?.hideScrollbar,
|
||||
]);
|
||||
|
||||
|
||||
@@ -5,13 +5,21 @@ import { useRouter } from 'next/navigation';
|
||||
import { BiMoon, BiSun } from 'react-icons/bi';
|
||||
import { TbSunMoon } from 'react-icons/tb';
|
||||
import { MdZoomOut, MdZoomIn, MdCheck, MdInfoOutline } from 'react-icons/md';
|
||||
import { MdRemove, MdAdd, MdContrast } from 'react-icons/md';
|
||||
import { MdSync, MdSyncProblem } from 'react-icons/md';
|
||||
import { IoMdExpand } from 'react-icons/io';
|
||||
import { IoShareOutline } from 'react-icons/io5';
|
||||
import { TbArrowAutofitWidth } from 'react-icons/tb';
|
||||
import { TbColumns1, TbColumns2 } from 'react-icons/tb';
|
||||
|
||||
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
|
||||
import {
|
||||
MAX_ZOOM_LEVEL,
|
||||
MIN_ZOOM_LEVEL,
|
||||
ZOOM_STEP,
|
||||
MAX_CONTRAST,
|
||||
MIN_CONTRAST,
|
||||
CONTRAST_STEP,
|
||||
} from '@/services/constants';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
@@ -60,6 +68,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
viewSettings?.paragraphMode?.enabled ?? false,
|
||||
);
|
||||
const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!);
|
||||
const [contrast, setContrast] = useState(viewSettings!.contrast ?? 100);
|
||||
const [zoomMode, setZoomMode] = useState(viewSettings!.zoomMode!);
|
||||
const [spreadMode, setSpreadMode] = useState(viewSettings!.spreadMode!);
|
||||
const [keepCoverSpread, setKeepCoverSpread] = useState(viewSettings!.keepCoverSpread!);
|
||||
@@ -71,6 +80,11 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
const zoomIn = () => setZoomLevel((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM_LEVEL));
|
||||
const zoomOut = () => setZoomLevel((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM_LEVEL));
|
||||
const resetZoom = () => setZoomLevel(100);
|
||||
const increaseContrast = () =>
|
||||
setContrast((prev) => Math.min(prev + CONTRAST_STEP, MAX_CONTRAST));
|
||||
const decreaseContrast = () =>
|
||||
setContrast((prev) => Math.max(prev - CONTRAST_STEP, MIN_CONTRAST));
|
||||
const resetContrast = () => setContrast(100);
|
||||
const toggleScrolledMode = () => setScrolledMode(!isScrolledMode);
|
||||
const toggleWebtoonMode = () => setWebtoonMode(!webtoonMode);
|
||||
const toggleParagraphMode = () => {
|
||||
@@ -159,6 +173,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [zoomLevel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contrast === viewSettings.contrast) return;
|
||||
saveViewSettings(envConfig, bookKey, 'contrast', contrast, true, true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contrast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (invertImgColorInDark === viewSettings.invertImgColorInDark) return;
|
||||
saveViewSettings(envConfig, bookKey, 'invertImgColorInDark', invertImgColorInDark, true, true);
|
||||
@@ -257,6 +277,42 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
title={_('Contrast')}
|
||||
className={clsx('mt-2 flex items-center justify-between rounded-md')}
|
||||
>
|
||||
<button
|
||||
title={_('Decrease Contrast')}
|
||||
onClick={decreaseContrast}
|
||||
className={clsx(
|
||||
'hover:bg-base-300 text-base-content rounded-full p-2',
|
||||
contrast <= MIN_CONTRAST && 'btn-disabled text-gray-400',
|
||||
)}
|
||||
>
|
||||
<MdRemove />
|
||||
</button>
|
||||
<button
|
||||
title={_('Reset Contrast')}
|
||||
className={clsx(
|
||||
'hover:bg-base-300 text-base-content flex h-8 min-h-8 w-[50%] items-center justify-center gap-1 rounded-md p-1 text-center',
|
||||
)}
|
||||
onClick={resetContrast}
|
||||
>
|
||||
<MdContrast size={16} />
|
||||
{Math.round(contrast)}%
|
||||
</button>
|
||||
<button
|
||||
title={_('Increase Contrast')}
|
||||
onClick={increaseContrast}
|
||||
className={clsx(
|
||||
'hover:bg-base-300 text-base-content rounded-full p-2',
|
||||
contrast >= MAX_CONTRAST && 'btn-disabled text-gray-400',
|
||||
)}
|
||||
>
|
||||
<MdAdd />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<div
|
||||
title={_('Zoom Mode')}
|
||||
|
||||
@@ -297,6 +297,7 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
|
||||
keepCoverSpread: true,
|
||||
invertImgColorInDark: false,
|
||||
applyThemeToPDF: false,
|
||||
contrast: 100,
|
||||
};
|
||||
|
||||
export const DEFAULT_MOBILE_VIEW_SETTINGS: Partial<ViewSettings> = {
|
||||
@@ -838,6 +839,10 @@ export const MAX_ZOOM_LEVEL = 500;
|
||||
export const MIN_ZOOM_LEVEL = 50;
|
||||
export const ZOOM_STEP = 10;
|
||||
|
||||
export const MAX_CONTRAST = 300;
|
||||
export const MIN_CONTRAST = 50;
|
||||
export const CONTRAST_STEP = 10;
|
||||
|
||||
export const SHOW_UNREAD_STATUS_BADGE = false;
|
||||
|
||||
export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = {
|
||||
|
||||
@@ -240,6 +240,7 @@ export interface BookStyle {
|
||||
keepCoverSpread: boolean;
|
||||
invertImgColorInDark: boolean;
|
||||
applyThemeToPDF: boolean;
|
||||
contrast: number;
|
||||
}
|
||||
|
||||
export interface BookFont {
|
||||
|
||||
@@ -1235,6 +1235,11 @@ export const applyFixedlayoutStyles = (
|
||||
const isEink = viewSettings.isEink;
|
||||
const overrideColor = viewSettings.overrideColor!;
|
||||
const invertImgColorInDark = viewSettings.invertImgColorInDark!;
|
||||
const contrast = viewSettings.contrast ?? 100;
|
||||
const imgFilters: string[] = [];
|
||||
if (isDarkMode && invertImgColorInDark) imgFilters.push('invert(100%)');
|
||||
if (contrast !== 100) imgFilters.push(`contrast(${contrast}%)`);
|
||||
const imgFilter = imgFilters.length ? `filter: ${imgFilters.join(' ')};` : '';
|
||||
const darkMixBlendMode = bg === '#000000' ? 'luminosity' : 'overlay';
|
||||
const existingStyleId = 'fixed-layout-styles';
|
||||
let style = document.getElementById(existingStyleId) as HTMLStyleElement;
|
||||
@@ -1262,7 +1267,7 @@ export const applyFixedlayoutStyles = (
|
||||
background-color: var(--theme-bg-color);
|
||||
}
|
||||
img, canvas {
|
||||
${isDarkMode && invertImgColorInDark ? 'filter: invert(100%);' : ''}
|
||||
${imgFilter}
|
||||
${overrideColor ? `mix-blend-mode: ${isDarkMode ? darkMixBlendMode : 'multiply'};` : ''}
|
||||
}
|
||||
img.singlePage {
|
||||
|
||||
Reference in New Issue
Block a user