forked from akai/readest
@@ -10,13 +10,13 @@ import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getGridTemplate, getInsetEdges } from '@/utils/grid';
|
||||
import { getViewInsets } from '@/utils/insets';
|
||||
import SettingsDialog from '@/components/settings/SettingsDialog';
|
||||
import FoliateViewer from './FoliateViewer';
|
||||
import SectionInfo from './SectionInfo';
|
||||
import HeaderBar from './HeaderBar';
|
||||
import FooterBar from './FooterBar';
|
||||
import ProgressInfoView from './ProgressInfo';
|
||||
import Ribbon from './Ribbon';
|
||||
import SettingsDialog from './settings/SettingsDialog';
|
||||
import Annotator from './annotator/Annotator';
|
||||
import FootnotePopup from './FootnotePopup';
|
||||
import HintInfo from './HintInfo';
|
||||
@@ -34,7 +34,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
const { getProgress, getViewState, getViewSettings } = useReaderStore();
|
||||
const { setGridInsets, hoveredBookKey } = useReaderStore();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
const { isFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
|
||||
const { safeAreaInsets: screenInsets } = useThemeStore();
|
||||
const aspectRatio = window.innerWidth / window.innerHeight;
|
||||
@@ -120,7 +120,6 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
isTopLeft={index === 0}
|
||||
isHoveredAnim={bookKeys.length > 2}
|
||||
onCloseBook={onCloseBook}
|
||||
onSetSettingsDialogOpen={setFontLayoutSettingsDialogOpen}
|
||||
gridInsets={gridInsets}
|
||||
/>
|
||||
<FoliateViewer
|
||||
@@ -203,7 +202,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
isHoveredAnim={false}
|
||||
gridInsets={gridInsets}
|
||||
/>
|
||||
{isFontLayoutSettingsDialogOpen && <SettingsDialog bookKey={bookKey} config={config} />}
|
||||
{isFontLayoutSettingsDialogOpen && <SettingsDialog bookKey={bookKey} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -18,9 +18,9 @@ import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { saveViewSettings } from '@/helpers/viewSettings';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { viewPagination } from '../hooks/usePagination';
|
||||
import { saveViewSettings } from '../utils/viewSettingsHelper';
|
||||
import { PageInfo } from '@/types/book';
|
||||
import { Insets } from '@/types/misc';
|
||||
import Button from '@/components/Button';
|
||||
|
||||
@@ -26,7 +26,6 @@ interface HeaderBarProps {
|
||||
isHoveredAnim: boolean;
|
||||
gridInsets: Insets;
|
||||
onCloseBook: (bookKey: string) => void;
|
||||
onSetSettingsDialogOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
@@ -36,7 +35,6 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
isHoveredAnim,
|
||||
gridInsets,
|
||||
onCloseBook,
|
||||
onSetSettingsDialogOpen,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
@@ -185,7 +183,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
toggleButton={<PiDotsThreeVerticalBold size={iconSize16} />}
|
||||
onToggle={handleToggleDropdown}
|
||||
>
|
||||
<ViewMenu bookKey={bookKey} onSetSettingsDialogOpen={onSetSettingsDialogOpen} />
|
||||
<ViewMenu bookKey={bookKey} />
|
||||
</Dropdown>
|
||||
|
||||
<WindowButtons
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { saveViewSettings } from '../utils/viewSettingsHelper';
|
||||
import { saveViewSettings } from '@/helpers/viewSettings';
|
||||
import { isTranslationAvailable } from '@/services/translators/utils';
|
||||
import Button from '@/components/Button';
|
||||
|
||||
|
||||
@@ -16,32 +16,29 @@ import { useAuth } from '@/context/AuthContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { navigateToLogin } from '@/utils/nav';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { saveViewSettings } from '@/helpers/viewSettings';
|
||||
import { tauriHandleToggleFullScreen } from '@/utils/window';
|
||||
import { saveViewSettings } from '../utils/viewSettingsHelper';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import Menu from '@/components/Menu';
|
||||
|
||||
interface ViewMenuProps {
|
||||
bookKey: string;
|
||||
setIsDropdownOpen?: (open: boolean) => void;
|
||||
onSetSettingsDialogOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
bookKey,
|
||||
setIsDropdownOpen,
|
||||
onSetSettingsDialogOpen,
|
||||
}) => {
|
||||
const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getConfig, getBookData } = useBookDataStore();
|
||||
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
const { getView, getViewSettings, getViewState, setViewSettings } = useReaderStore();
|
||||
const config = getConfig(bookKey)!;
|
||||
const bookData = getBookData(bookKey)!;
|
||||
@@ -65,7 +62,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
|
||||
const openFontLayoutMenu = () => {
|
||||
setIsDropdownOpen?.(false);
|
||||
onSetSettingsDialogOpen(true);
|
||||
setFontLayoutSettingsDialogOpen(true);
|
||||
};
|
||||
|
||||
const cycleThemeMode = () => {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { SketchPicker, ColorResult } from 'react-color';
|
||||
|
||||
type ColorInputProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const ColorInput: React.FC<ColorInputProps> = ({ label, value, onChange }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
const handlePickerChange = (colorResult: ColorResult) => {
|
||||
onChange(colorResult.hex);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='mb-3'>
|
||||
<label className='mb-1 block text-sm font-medium'>{label}</label>
|
||||
<div className='flex items-center'>
|
||||
<button
|
||||
className='border-base-200/75 relative mr-2 flex h-7 w-8 cursor-pointer items-center justify-center overflow-hidden rounded border'
|
||||
style={{ backgroundColor: value }}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
/>
|
||||
|
||||
<input
|
||||
type='text'
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className='bg-base-100 text-base-content border-base-200/75 min-w-4 max-w-36 flex-1 rounded border p-1 font-mono text-sm'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div ref={pickerRef} className='relative z-50 mt-2'>
|
||||
<div className='absolute'>
|
||||
<SketchPicker
|
||||
width='100%'
|
||||
color={value}
|
||||
onChange={handlePickerChange}
|
||||
disableAlpha={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorInput;
|
||||
@@ -1,287 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { MdOutlineLightMode, MdOutlineDarkMode } from 'react-icons/md';
|
||||
import { MdRadioButtonUnchecked, MdRadioButtonChecked } from 'react-icons/md';
|
||||
import { CgColorPicker } from 'react-icons/cg';
|
||||
import { TbSunMoon } from 'react-icons/tb';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import {
|
||||
applyCustomTheme,
|
||||
CustomTheme,
|
||||
generateDarkPalette,
|
||||
generateLightPalette,
|
||||
Theme,
|
||||
themes,
|
||||
} from '@/styles/themes';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useResetViewSettings } from '../../hooks/useResetSettings';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import { CODE_LANGUAGES, CodeLanguage, manageSyntaxHighlighting } from '@/utils/highlightjs';
|
||||
import { SettingsPanelPanelProp } from './SettingsDialog';
|
||||
import Select from '@/components/Select';
|
||||
import ThemeEditor from './ThemeEditor';
|
||||
|
||||
const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
|
||||
const _ = useTranslation();
|
||||
const { themeMode, themeColor, isDarkMode, setThemeMode, setThemeColor, saveCustomTheme } =
|
||||
useThemeStore();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const [invertImgColorInDark, setInvertImgColorInDark] = useState(
|
||||
viewSettings.invertImgColorInDark,
|
||||
);
|
||||
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const iconSize24 = useResponsiveSize(24);
|
||||
const [editTheme, setEditTheme] = useState<CustomTheme | null>(null);
|
||||
const [customThemes, setCustomThemes] = useState<Theme[]>([]);
|
||||
const [showCustomThemeEditor, setShowCustomThemeEditor] = useState(false);
|
||||
const [overrideColor, setOverrideColor] = useState(viewSettings.overrideColor!);
|
||||
const [codeHighlighting, setcodeHighlighting] = useState(viewSettings.codeHighlighting!);
|
||||
const [codeLanguage, setCodeLanguage] = useState(viewSettings.codeLanguage!);
|
||||
|
||||
const resetToDefaults = useResetViewSettings();
|
||||
|
||||
const handleReset = () => {
|
||||
resetToDefaults({
|
||||
overrideColor: setOverrideColor,
|
||||
invertImgColorInDark: setInvertImgColorInDark,
|
||||
codeHighlighting: setcodeHighlighting,
|
||||
codeLanguage: setCodeLanguage,
|
||||
});
|
||||
setThemeColor('default');
|
||||
setThemeMode('auto');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onRegisterReset(handleReset);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (invertImgColorInDark === viewSettings.invertImgColorInDark) return;
|
||||
saveViewSettings(envConfig, bookKey, 'invertImgColorInDark', invertImgColorInDark);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [invertImgColorInDark]);
|
||||
|
||||
useEffect(() => {
|
||||
if (overrideColor === viewSettings.overrideColor) return;
|
||||
saveViewSettings(envConfig, bookKey, 'overrideColor', overrideColor);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [overrideColor]);
|
||||
|
||||
useEffect(() => {
|
||||
let update = false; // check if we need to update syntax highlighting
|
||||
if (codeHighlighting !== viewSettings.codeHighlighting) {
|
||||
saveViewSettings(envConfig, bookKey, 'codeHighlighting', codeHighlighting);
|
||||
update = true;
|
||||
}
|
||||
if (codeLanguage !== viewSettings.codeLanguage) {
|
||||
saveViewSettings(envConfig, bookKey, 'codeLanguage', codeLanguage);
|
||||
update = true;
|
||||
}
|
||||
if (!update) return;
|
||||
const view = getView(bookKey);
|
||||
if (!view) return;
|
||||
const docs = view.renderer.getContents();
|
||||
docs.forEach(({ doc }) => manageSyntaxHighlighting(doc, viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [codeHighlighting, codeLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
const customThemes = settings.globalReadSettings.customThemes ?? [];
|
||||
setCustomThemes(
|
||||
customThemes.map((customTheme) => ({
|
||||
name: customTheme.name,
|
||||
label: customTheme.label,
|
||||
colors: {
|
||||
light: generateLightPalette(customTheme.colors.light),
|
||||
dark: generateDarkPalette(customTheme.colors.dark),
|
||||
},
|
||||
isCustomizale: true,
|
||||
})),
|
||||
);
|
||||
}, [settings]);
|
||||
|
||||
const handleSaveCustomTheme = (customTheme: CustomTheme) => {
|
||||
applyCustomTheme(customTheme);
|
||||
saveCustomTheme(envConfig, settings, customTheme);
|
||||
|
||||
setSettings({ ...settings });
|
||||
setThemeColor(customTheme.name);
|
||||
setShowCustomThemeEditor(false);
|
||||
};
|
||||
|
||||
const handleDeleteCustomTheme = (customTheme: CustomTheme) => {
|
||||
saveCustomTheme(envConfig, settings, customTheme, true);
|
||||
|
||||
setSettings({ ...settings });
|
||||
setThemeColor('default');
|
||||
setShowCustomThemeEditor(false);
|
||||
};
|
||||
|
||||
const handleEditTheme = (name: string) => {
|
||||
const customTheme = settings.globalReadSettings.customThemes.find((t) => t.name === name);
|
||||
if (customTheme) {
|
||||
setEditTheme(customTheme);
|
||||
setShowCustomThemeEditor(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
{showCustomThemeEditor ? (
|
||||
<ThemeEditor
|
||||
customTheme={editTheme}
|
||||
onSave={handleSaveCustomTheme}
|
||||
onDelete={handleDeleteCustomTheme}
|
||||
onCancel={() => setShowCustomThemeEditor(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Theme Mode')}</h2>
|
||||
<div className='flex gap-4'>
|
||||
<button
|
||||
title={_('Auto Mode')}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setThemeMode('auto')}
|
||||
>
|
||||
<TbSunMoon />
|
||||
</button>
|
||||
<button
|
||||
title={_('Light Mode')}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'light' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setThemeMode('light')}
|
||||
>
|
||||
<MdOutlineLightMode />
|
||||
</button>
|
||||
<button
|
||||
title={_('Dark Mode')}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'dark' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setThemeMode('dark')}
|
||||
>
|
||||
<MdOutlineDarkMode />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Invert Image In Dark Mode')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={invertImgColorInDark}
|
||||
disabled={!isDarkMode}
|
||||
onChange={() => setInvertImgColorInDark(!invertImgColorInDark)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Override Book Color')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={overrideColor}
|
||||
onChange={() => setOverrideColor(!overrideColor)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
{themes.concat(customThemes).map(({ name, label, colors, isCustomizale }) => (
|
||||
<button
|
||||
key={name}
|
||||
tabIndex={0}
|
||||
onClick={() => setThemeColor(name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
setThemeColor(name);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg p-4 shadow-md ${
|
||||
themeColor === name ? 'ring-2 ring-indigo-500 ring-offset-2' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isDarkMode
|
||||
? colors.dark['base-100']
|
||||
: colors.light['base-100'],
|
||||
color: isDarkMode ? colors.dark['base-content'] : colors.light['base-content'],
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={_(label)}
|
||||
type='radio'
|
||||
name='theme'
|
||||
value={name}
|
||||
checked={themeColor === name}
|
||||
onChange={() => setThemeColor(name)}
|
||||
className='hidden'
|
||||
/>
|
||||
{themeColor === name ? (
|
||||
<MdRadioButtonChecked size={iconSize24} />
|
||||
) : (
|
||||
<MdRadioButtonUnchecked size={iconSize24} />
|
||||
)}
|
||||
<span>{_(label)}</span>
|
||||
{isCustomizale && themeColor === name && (
|
||||
<button onClick={() => handleEditTheme(name)}>
|
||||
<CgColorPicker size={iconSize16} className='absolute right-2 top-2' />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed p-4 shadow-md`}
|
||||
onClick={() => setShowCustomThemeEditor(true)}
|
||||
>
|
||||
<PiPlus size={iconSize24} />
|
||||
<span>{_('Custom')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Code Highlighting')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Enable Highlighting')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={codeHighlighting}
|
||||
onChange={() => setcodeHighlighting(!codeHighlighting)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Code Language')}</span>
|
||||
<Select
|
||||
value={codeLanguage}
|
||||
onChange={(event) => setCodeLanguage(event.target.value as CodeLanguage)}
|
||||
options={CODE_LANGUAGES.map((lang) => ({
|
||||
value: lang,
|
||||
label: lang === 'auto-detect' ? _('Auto') : lang,
|
||||
}))}
|
||||
disabled={!codeHighlighting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPanel;
|
||||
@@ -1,248 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useResetViewSettings } from '../../hooks/useResetSettings';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { saveAndReload } from '@/utils/reload';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import { SettingsPanelPanelProp } from './SettingsDialog';
|
||||
import NumberInput from './NumberInput';
|
||||
|
||||
const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
|
||||
const bookData = getBookData(bookKey)!;
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const [isScrolledMode, setScrolledMode] = useState(viewSettings.scrolled!);
|
||||
const [isContinuousScroll, setIsContinuousScroll] = useState(viewSettings.continuousScroll!);
|
||||
const [scrollingOverlap, setScrollingOverlap] = useState(viewSettings.scrollingOverlap!);
|
||||
const [volumeKeysToFlip, setVolumeKeysToFlip] = useState(viewSettings.volumeKeysToFlip!);
|
||||
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
|
||||
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea!);
|
||||
const [isDisableDoubleClick, setIsDisableDoubleClick] = useState(
|
||||
viewSettings.disableDoubleClick!,
|
||||
);
|
||||
const [animated, setAnimated] = useState(viewSettings.animated!);
|
||||
const [allowScript, setAllowScript] = useState(viewSettings.allowScript!);
|
||||
|
||||
const resetToDefaults = useResetViewSettings();
|
||||
|
||||
const handleReset = () => {
|
||||
resetToDefaults({
|
||||
scrolled: setScrolledMode,
|
||||
continuousScroll: setIsContinuousScroll,
|
||||
scrollingOverlap: setScrollingOverlap,
|
||||
volumeKeysToFlip: setVolumeKeysToFlip,
|
||||
disableClick: setIsDisableClick,
|
||||
swapClickArea: setSwapClickArea,
|
||||
animated: setAnimated,
|
||||
allowScript: setAllowScript,
|
||||
});
|
||||
};
|
||||
|
||||
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(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'continuousScroll', isContinuousScroll, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isContinuousScroll]);
|
||||
|
||||
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, 'volumeKeysToFlip', volumeKeysToFlip, false, false);
|
||||
if (appService?.isMobileApp) {
|
||||
if (volumeKeysToFlip) {
|
||||
acquireVolumeKeyInterception();
|
||||
} else {
|
||||
releaseVolumeKeyInterception();
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [volumeKeysToFlip]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'disableClick', isDisableClick, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDisableClick]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'disableDoubleClick', isDisableDoubleClick, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDisableDoubleClick]);
|
||||
|
||||
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');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [animated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewSettings.allowScript === allowScript) return;
|
||||
saveViewSettings(envConfig, bookKey, 'allowScript', allowScript, true, false);
|
||||
saveAndReload();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allowScript]);
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Scroll')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Scrolled Mode')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={isScrolledMode}
|
||||
onChange={() => setScrolledMode(!isScrolledMode)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Continuous Scroll')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={isContinuousScroll}
|
||||
onChange={() => setIsContinuousScroll(!isContinuousScroll)}
|
||||
/>
|
||||
</div>
|
||||
<NumberInput
|
||||
label={_('Overlap Pixels')}
|
||||
value={scrollingOverlap}
|
||||
onChange={setScrollingOverlap}
|
||||
disabled={!viewSettings.scrolled}
|
||||
min={0}
|
||||
max={200}
|
||||
step={10}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Click')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Clicks for Page Flip')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={!isDisableClick}
|
||||
onChange={() => setIsDisableClick(!isDisableClick)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Swap Clicks Area')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={swapClickArea}
|
||||
disabled={isDisableClick}
|
||||
onChange={() => setSwapClickArea(!swapClickArea)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Disable Double Click')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={isDisableDoubleClick}
|
||||
onChange={() => setIsDisableDoubleClick(!isDisableDoubleClick)}
|
||||
/>
|
||||
</div>
|
||||
{appService?.isMobileApp && (
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Volume Keys for Page Flip')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={volumeKeysToFlip}
|
||||
onChange={() => setVolumeKeysToFlip(!volumeKeysToFlip)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Animation')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Paging Animation')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={animated}
|
||||
onChange={() => setAnimated(!animated)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Security')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item !h-16'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className=''>{_('Allow JavaScript')}</span>
|
||||
<span className='text-xs'>{_('Enable only if you trust the file.')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={allowScript}
|
||||
disabled={bookData.book?.format !== 'EPUB'}
|
||||
onChange={() => setAllowScript(!allowScript)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ControlPanel;
|
||||
@@ -1,217 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
import { MdAdd, MdDelete } from 'react-icons/md';
|
||||
import { IoMdCloseCircleOutline } from 'react-icons/io';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCustomFontStore } from '@/store/customFontStore';
|
||||
import { useFileSelector } from '@/hooks/useFileSelector';
|
||||
import { CustomFont, mountCustomFont } from '@/styles/fonts';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
|
||||
interface CustomFontsProps {
|
||||
bookKey: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type FontFamily = {
|
||||
name: string;
|
||||
fonts: CustomFont[];
|
||||
};
|
||||
|
||||
const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService, envConfig } = useEnv();
|
||||
const {
|
||||
fonts: customFonts,
|
||||
addFont,
|
||||
loadFont,
|
||||
removeFont,
|
||||
getAvailableFonts,
|
||||
saveCustomFonts,
|
||||
} = useCustomFontStore();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const [isDeleteMode, setIsDeleteMode] = useState(false);
|
||||
|
||||
const { selectFiles } = useFileSelector(appService, _);
|
||||
|
||||
const currentDefaultFont =
|
||||
viewSettings.defaultFont.toLowerCase() === 'serif' ? 'serif' : 'sans-serif';
|
||||
|
||||
const currentFontFamily =
|
||||
currentDefaultFont === 'serif' ? viewSettings.serifFont : viewSettings.sansSerifFont;
|
||||
|
||||
const handleImportFont = () => {
|
||||
selectFiles({ type: 'fonts', multiple: true }).then(async (result) => {
|
||||
if (result.error || result.files.length === 0) return;
|
||||
for (const selectedFile of result.files) {
|
||||
const fontInfo = await appService?.importFont(selectedFile.path || selectedFile.file);
|
||||
if (!fontInfo) continue;
|
||||
|
||||
const customFont = addFont(fontInfo.path, {
|
||||
name: fontInfo.name,
|
||||
family: fontInfo.family,
|
||||
style: fontInfo.style,
|
||||
weight: fontInfo.weight,
|
||||
variable: fontInfo.variable,
|
||||
});
|
||||
console.log('Added custom font:', customFont);
|
||||
if (customFont && !customFont.error) {
|
||||
const loadedFont = await loadFont(envConfig, customFont.id);
|
||||
mountCustomFont(document, loadedFont);
|
||||
}
|
||||
}
|
||||
saveCustomFonts(envConfig);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteFamily = (family: FontFamily) => {
|
||||
for (const font of family.fonts) {
|
||||
if (font) {
|
||||
if (removeFont(font.id)) {
|
||||
appService?.deleteFont(font);
|
||||
saveCustomFonts(envConfig);
|
||||
if (getAvailableFonts().length === 0) {
|
||||
setIsDeleteMode(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFamily = (family: FontFamily) => {
|
||||
if (currentDefaultFont === 'serif') {
|
||||
saveViewSettings(envConfig, bookKey, 'serifFont', family.name);
|
||||
} else {
|
||||
saveViewSettings(envConfig, bookKey, 'sansSerifFont', family.name);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDeleteMode = () => {
|
||||
setIsDeleteMode(!isDeleteMode);
|
||||
};
|
||||
|
||||
const getAvailableFamilies = (fonts: CustomFont[]): FontFamily[] => {
|
||||
const familyMap = new Map<string, string[]>();
|
||||
|
||||
for (const font of fonts) {
|
||||
const family = font.family || font.name;
|
||||
if (!familyMap.has(family)) {
|
||||
familyMap.set(family, []);
|
||||
}
|
||||
familyMap.get(family)!.push(font.id);
|
||||
}
|
||||
|
||||
return Array.from(familyMap.entries()).map(([family, ids]) => ({
|
||||
name: family,
|
||||
fonts: ids.map((id) => fonts.find((f) => f.id === id)!).filter((f): f is CustomFont => !!f),
|
||||
}));
|
||||
};
|
||||
|
||||
const availableFonts = customFonts
|
||||
.filter((font) => !font.deletedAt)
|
||||
.sort((a, b) => (b.downloadedAt || 0) - (a.downloadedAt || 0));
|
||||
|
||||
const availableFamilies = getAvailableFamilies(availableFonts);
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<div className='mb-6 flex h-8 items-center justify-between'>
|
||||
<div className='breadcrumbs py-1'>
|
||||
<ul>
|
||||
<li>
|
||||
<button className='font-semibold' onClick={onBack}>
|
||||
{_('Font')}
|
||||
</button>
|
||||
</li>
|
||||
<li className='font-medium'>{_('Custom Fonts')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
{availableFonts.length > 0 && (
|
||||
<button
|
||||
onClick={toggleDeleteMode}
|
||||
className={`btn btn-ghost btn-sm text-base-content gap-2`}
|
||||
title={isDeleteMode ? _('Cancel Delete') : _('Delete Font')}
|
||||
>
|
||||
{isDeleteMode ? (
|
||||
<>{_('Cancel')}</>
|
||||
) : (
|
||||
<>
|
||||
<MdDelete className='h-4 w-4' />
|
||||
{_('Delete')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='card border-primary/50 hover:border-primary/75 group h-12 border-2 transition-colors'>
|
||||
<button
|
||||
className='card-body flex cursor-pointer items-center justify-center p-2 text-center'
|
||||
onClick={handleImportFont}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex items-center justify-center'>
|
||||
<MdAdd className='text-primary/85 group-hover:text-primary h-6 w-6' />
|
||||
</div>
|
||||
<div className='text-primary/85 group-hover:text-primary line-clamp-1 font-medium'>
|
||||
{_('Import Font')}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{availableFamilies.map((family) => (
|
||||
<div
|
||||
role='none'
|
||||
key={family.name}
|
||||
className={clsx(
|
||||
'card h-12 border shadow-sm',
|
||||
currentFontFamily === family.name
|
||||
? 'border-primary/50 bg-primary/50'
|
||||
: `border-base-200 bg-base-200 ${isDeleteMode ? '' : 'cursor-pointer'}`,
|
||||
)}
|
||||
onClick={!isDeleteMode ? () => handleSelectFamily(family) : undefined}
|
||||
title={family.fonts.map((f) => f.name).join('\n')}
|
||||
>
|
||||
<div className='card-body flex items-center justify-center p-2'>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: `"${family.name}", sans-serif`,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
className='text-base-content line-clamp-1 break-all'
|
||||
>
|
||||
{family.name}
|
||||
</div>
|
||||
{isDeleteMode && (
|
||||
<button
|
||||
onClick={() => handleDeleteFamily(family)}
|
||||
className='btn btn-ghost btn-xs absolute right-[-10px] top-[-10px] h-6 min-h-0 w-6 p-0 hover:bg-transparent'
|
||||
title={_('Delete Font')}
|
||||
>
|
||||
<IoMdCloseCircleOutline className='text-base-content/75 h-6 w-6' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='bg-base-200/30 my-8 rounded-lg p-4'>
|
||||
<div className='text-base-content/70 text-sm sm:text-xs'>
|
||||
<div className='mb-1 indent-2 font-medium'>{_('Tips')}:</div>
|
||||
<ul className='list-outside list-disc space-y-1 ps-2'>
|
||||
<li>{_('Supported font formats: .ttf, .otf, .woff, .woff2')}</li>
|
||||
<li>{_('Custom fonts can be selected from the Font Face menu')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomFonts;
|
||||
@@ -1,69 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { SettingsPanelType } from './SettingsDialog';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
||||
interface DialogMenuProps {
|
||||
activePanel: SettingsPanelType;
|
||||
setIsDropdownOpen?: (open: boolean) => void;
|
||||
onReset: () => void;
|
||||
resetLabel?: string;
|
||||
}
|
||||
|
||||
const DialogMenu: React.FC<DialogMenuProps> = ({
|
||||
activePanel,
|
||||
setIsDropdownOpen,
|
||||
onReset,
|
||||
resetLabel,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const iconSize = useResponsiveSize(16);
|
||||
const { setFontPanelView, isFontLayoutSettingsGlobal, setFontLayoutSettingsGlobal } =
|
||||
useSettingsStore();
|
||||
|
||||
const handleToggleGlobal = () => {
|
||||
setFontLayoutSettingsGlobal(!isFontLayoutSettingsGlobal);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleResetToDefaults = () => {
|
||||
onReset();
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleManageCustomFont = () => {
|
||||
setFontPanelView('custom-fonts');
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'dropdown-content dropdown-right no-triangle border-base-200 z-20 mt-1 border shadow-2xl',
|
||||
'text-base sm:text-sm',
|
||||
)}
|
||||
>
|
||||
<MenuItem
|
||||
label={_('Global Settings')}
|
||||
tooltip={isFontLayoutSettingsGlobal ? _('Apply to All Books') : _('Apply to This Book')}
|
||||
buttonClass='lg:tooltip'
|
||||
Icon={
|
||||
isFontLayoutSettingsGlobal ? (
|
||||
<MdCheck size={iconSize} className='text-base-content' />
|
||||
) : null
|
||||
}
|
||||
onClick={handleToggleGlobal}
|
||||
/>
|
||||
<MenuItem label={resetLabel || _('Reset Settings')} onClick={handleResetToDefaults} />
|
||||
{activePanel === 'Font' && (
|
||||
<MenuItem label={_('Manage Custom Fonts')} onClick={handleManageCustomFont} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DialogMenu;
|
||||
@@ -1,193 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useMemo } from 'react';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { FiChevronUp, FiChevronLeft } from 'react-icons/fi';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
interface DropdownProps {
|
||||
family?: string;
|
||||
selected: string;
|
||||
options: { option: string; label?: string }[];
|
||||
moreOptions?: { option: string; label?: string }[];
|
||||
onSelect: (option: string) => void;
|
||||
onGetFontFamily: (option: string, family: string) => string;
|
||||
}
|
||||
|
||||
interface FontItemProps {
|
||||
index: number;
|
||||
style: React.CSSProperties;
|
||||
data: {
|
||||
options: { option: string; label?: string }[];
|
||||
selected: string;
|
||||
onSelect: (option: string) => void;
|
||||
onGetFontFamily: (option: string, family: string) => string;
|
||||
family: string;
|
||||
iconSize: number;
|
||||
};
|
||||
}
|
||||
|
||||
const FontItem: React.FC<FontItemProps> = ({ index, style, data }) => {
|
||||
const { options, selected, onSelect, onGetFontFamily, family, iconSize: iconSize16 } = data;
|
||||
const option = options[index]!;
|
||||
|
||||
return (
|
||||
<li
|
||||
role='option'
|
||||
aria-selected={selected === option.option}
|
||||
className='px-2'
|
||||
key={option.option}
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(option.option)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onSelect(option.option);
|
||||
}
|
||||
}}
|
||||
aria-label={option.label || option.option}
|
||||
className='flex w-full items-center overflow-hidden !px-0 text-sm'
|
||||
>
|
||||
<span style={{ minWidth: `${iconSize16}px` }}>
|
||||
{selected === option.option && (
|
||||
<MdCheck className='text-base-content' size={iconSize16} />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className='line-clamp-1 overflow-visible break-all leading-loose'
|
||||
style={{ fontFamily: onGetFontFamily(option.option, family) }}
|
||||
title={option.label || option.option}
|
||||
>
|
||||
{option.label || option.option}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const FontDropdown: React.FC<DropdownProps> = ({
|
||||
family,
|
||||
selected,
|
||||
options,
|
||||
moreOptions,
|
||||
onSelect,
|
||||
onGetFontFamily,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const iconSize = useResponsiveSize(16);
|
||||
const allOptions = [...options, ...(moreOptions ?? [])];
|
||||
const selectedOption = allOptions.find((option) => option.option === selected) ?? allOptions[0]!;
|
||||
|
||||
const ITEM_HEIGHT = 40;
|
||||
const MAX_HEIGHT = 320;
|
||||
|
||||
const mainListData = useMemo(
|
||||
() => ({
|
||||
options,
|
||||
selected,
|
||||
onSelect,
|
||||
onGetFontFamily,
|
||||
family: family ?? '',
|
||||
iconSize,
|
||||
appService,
|
||||
}),
|
||||
[options, selected, onSelect, onGetFontFamily, family, iconSize, appService],
|
||||
);
|
||||
|
||||
const moreListData = useMemo(
|
||||
() => ({
|
||||
options: moreOptions ?? [],
|
||||
selected,
|
||||
onSelect,
|
||||
onGetFontFamily,
|
||||
family: family ?? '',
|
||||
iconSize,
|
||||
}),
|
||||
[moreOptions, selected, onSelect, onGetFontFamily, family, iconSize],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='dropdown dropdown-top'>
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='btn btn-sm flex items-center px-[10px] font-normal normal-case sm:px-[20px]'
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
>
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<span
|
||||
className='line-clamp-1 break-all leading-loose'
|
||||
style={{
|
||||
fontFamily: onGetFontFamily(selectedOption.option, family ?? ''),
|
||||
}}
|
||||
>
|
||||
{selectedOption.label}
|
||||
</span>
|
||||
<FiChevronUp size={iconSize} />
|
||||
</div>
|
||||
</button>
|
||||
<ul
|
||||
role='listbox'
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute z-[1] mt-4 shadow',
|
||||
'right-[-32px] w-[46vw] !px-0 sm:right-0 sm:w-44',
|
||||
moreOptions?.length ? '' : 'inline overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{/* Virtualized main options */}
|
||||
<div style={{ height: Math.min(options.length * ITEM_HEIGHT, MAX_HEIGHT) }}>
|
||||
<List
|
||||
width='100%'
|
||||
height={Math.min(options.length * ITEM_HEIGHT, MAX_HEIGHT)}
|
||||
itemCount={options.length}
|
||||
itemSize={ITEM_HEIGHT}
|
||||
itemData={mainListData}
|
||||
>
|
||||
{FontItem}
|
||||
</List>
|
||||
</div>
|
||||
|
||||
{/* More options with nested dropdown */}
|
||||
{moreOptions && moreOptions.length > 0 && (
|
||||
<li className='dropdown dropdown-left dropdown-top px-2'>
|
||||
<div className='flex items-center px-0 text-sm'>
|
||||
<span style={{ minWidth: `${iconSize}px` }}>
|
||||
<FiChevronLeft size={iconSize} />
|
||||
</span>
|
||||
<span>{_('System Fonts')}</span>
|
||||
</div>
|
||||
<ul
|
||||
role='listbox'
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 menu rounded-box relative z-[1] shadow',
|
||||
'!mr-4 mb-[-46px] inline w-[46vw] overflow-hidden !px-0 sm:w-[200px]',
|
||||
)}
|
||||
>
|
||||
{/* Virtualized more options */}
|
||||
<div style={{ height: Math.min(moreOptions.length * ITEM_HEIGHT, MAX_HEIGHT) }}>
|
||||
<List
|
||||
width='100%'
|
||||
height={Math.min(moreOptions.length * ITEM_HEIGHT, MAX_HEIGHT)}
|
||||
itemCount={moreOptions.length}
|
||||
itemSize={ITEM_HEIGHT}
|
||||
itemData={moreListData}
|
||||
>
|
||||
{FontItem}
|
||||
</List>
|
||||
</div>
|
||||
</ul>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FontDropdown;
|
||||
@@ -1,431 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MdSettings } from 'react-icons/md';
|
||||
|
||||
import {
|
||||
ANDROID_FONTS,
|
||||
CJK_EXCLUDE_PATTENS,
|
||||
CJK_FONTS_PATTENS,
|
||||
CJK_SANS_SERIF_FONTS,
|
||||
CJK_SERIF_FONTS,
|
||||
IOS_FONTS,
|
||||
LINUX_FONTS,
|
||||
MACOS_FONTS,
|
||||
MONOSPACE_FONTS,
|
||||
NON_FREE_FONTS,
|
||||
SANS_SERIF_FONTS,
|
||||
SERIF_FONTS,
|
||||
WINDOWS_FONTS,
|
||||
} from '@/services/constants';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useCustomFontStore } from '@/store/customFontStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { getOSPlatform, isCJKEnv } from '@/utils/misc';
|
||||
import { getSysFontsList } from '@/utils/bridge';
|
||||
import { isCJKStr } from '@/utils/lang';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import { useResetViewSettings } from '../../hooks/useResetSettings';
|
||||
import { SettingsPanelPanelProp } from './SettingsDialog';
|
||||
import NumberInput from './NumberInput';
|
||||
import FontDropdown from './FontDropDown';
|
||||
import CustomFonts from './CustomFonts';
|
||||
|
||||
const genCJKFontsList = (sysFonts: string[]) => {
|
||||
return Array.from(new Set([...sysFonts, ...CJK_SERIF_FONTS, ...CJK_SANS_SERIF_FONTS]))
|
||||
.filter((font) => CJK_FONTS_PATTENS.test(font) || isCJKStr(font))
|
||||
.filter((font) => !CJK_EXCLUDE_PATTENS.test(font))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
const isSymbolicFontName = (font: string) =>
|
||||
/emoji|icons|symbol|dingbats|ornaments|webdings|wingdings|miuiex/i.test(font);
|
||||
|
||||
interface FontFaceProps {
|
||||
className?: string;
|
||||
family: string;
|
||||
label: string;
|
||||
options: string[];
|
||||
moreOptions?: string[];
|
||||
selected: string;
|
||||
onSelect: (option: string) => void;
|
||||
}
|
||||
|
||||
const handleFontFaceFont = (option: string, family: string) => {
|
||||
return `'${option}', ${family}`;
|
||||
};
|
||||
|
||||
const filterNonFreeFonts = (font: string) => {
|
||||
return !['android', 'linux'].includes(getOSPlatform()) || !NON_FREE_FONTS.includes(font);
|
||||
};
|
||||
|
||||
const FontFace = ({
|
||||
className,
|
||||
family,
|
||||
label,
|
||||
options,
|
||||
moreOptions,
|
||||
selected,
|
||||
onSelect,
|
||||
}: FontFaceProps) => {
|
||||
const _ = useTranslation();
|
||||
return (
|
||||
<div className={clsx('config-item', className)}>
|
||||
<span className='line-clamp-2 min-w-10'>{label}</span>
|
||||
<FontDropdown
|
||||
family={family}
|
||||
options={options.map((option) => ({ option, label: _(option) }))}
|
||||
moreOptions={moreOptions?.map((option) => ({ option, label: option })) ?? []}
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
onGetFontFamily={handleFontFaceFont}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { fontPanelView, setFontPanelView } = useSettingsStore();
|
||||
const {
|
||||
fonts: allCustomFonts,
|
||||
getAllFonts,
|
||||
getFontFamilies,
|
||||
removeFont,
|
||||
saveCustomFonts,
|
||||
} = useCustomFontStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const view = getView(bookKey)!;
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
|
||||
const fontFamilyOptions = [
|
||||
{
|
||||
option: 'Serif',
|
||||
label: _('Serif Font'),
|
||||
},
|
||||
{
|
||||
option: 'Sans-serif',
|
||||
label: _('Sans-Serif Font'),
|
||||
},
|
||||
];
|
||||
|
||||
const osPlatform = getOSPlatform();
|
||||
let defaultSysFonts: string[] = [];
|
||||
switch (osPlatform) {
|
||||
case 'macos':
|
||||
defaultSysFonts = MACOS_FONTS;
|
||||
break;
|
||||
case 'windows':
|
||||
defaultSysFonts = WINDOWS_FONTS;
|
||||
break;
|
||||
case 'linux':
|
||||
defaultSysFonts = LINUX_FONTS;
|
||||
break;
|
||||
case 'ios':
|
||||
defaultSysFonts = IOS_FONTS;
|
||||
break;
|
||||
case 'android':
|
||||
defaultSysFonts = ANDROID_FONTS;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
const [sysFonts, setSysFonts] = useState<string[]>(defaultSysFonts);
|
||||
const [defaultFont, setDefaultFont] = useState(viewSettings.defaultFont!);
|
||||
const [defaultFontSize, setDefaultFontSize] = useState(viewSettings.defaultFontSize!);
|
||||
const [minFontSize, setMinFontSize] = useState(viewSettings.minimumFontSize!);
|
||||
const [overrideFont, setOverrideFont] = useState(viewSettings.overrideFont!);
|
||||
const [defaultCJKFont, setDefaultCJKFont] = useState(viewSettings.defaultCJKFont!);
|
||||
const [serifFont, setSerifFont] = useState(viewSettings.serifFont!);
|
||||
const [sansSerifFont, setSansSerifFont] = useState(viewSettings.sansSerifFont!);
|
||||
const [monospaceFont, setMonospaceFont] = useState(viewSettings.monospaceFont!);
|
||||
const [fontWeight, setFontWeight] = useState(viewSettings.fontWeight!);
|
||||
|
||||
const [customFonts, setCustomFonts] = useState<string[]>(getFontFamilies());
|
||||
const [CJKFonts, setCJKFonts] = useState<string[]>(() => {
|
||||
return genCJKFontsList([...customFonts, ...sysFonts]);
|
||||
});
|
||||
|
||||
const resetToDefaults = useResetViewSettings();
|
||||
|
||||
const handleReset = () => {
|
||||
resetToDefaults({
|
||||
defaultFont: setDefaultFont,
|
||||
defaultFontSize: setDefaultFontSize,
|
||||
minimumFontSize: setMinFontSize,
|
||||
overrideFont: setOverrideFont,
|
||||
defaultCJKFont: setDefaultCJKFont,
|
||||
serifFont: setSerifFont,
|
||||
sansSerifFont: setSansSerifFont,
|
||||
monospaceFont: setMonospaceFont,
|
||||
fontWeight: setFontWeight,
|
||||
});
|
||||
getAllFonts().forEach((font) => {
|
||||
if (removeFont(font.id)) {
|
||||
appService!.deleteFont(font);
|
||||
}
|
||||
});
|
||||
saveCustomFonts(envConfig);
|
||||
};
|
||||
|
||||
const handleManageCustomFonts = () => {
|
||||
setFontPanelView('custom-fonts');
|
||||
};
|
||||
|
||||
const handleBackToMain = () => {
|
||||
setFontPanelView('main-fonts');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onRegisterReset(handleReset);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appService]);
|
||||
|
||||
useEffect(() => {
|
||||
setCJKFonts((prev) => {
|
||||
const newFonts = genCJKFontsList([...customFonts, ...sysFonts]);
|
||||
return prev.length !== newFonts.length ? newFonts : prev;
|
||||
});
|
||||
}, [customFonts, sysFonts]);
|
||||
|
||||
useEffect(() => {
|
||||
setCustomFonts(getFontFamilies());
|
||||
}, [allCustomFonts, getFontFamilies]);
|
||||
|
||||
useEffect(() => {
|
||||
setSerifFont(viewSettings.serifFont);
|
||||
setSansSerifFont(viewSettings.sansSerifFont);
|
||||
setMonospaceFont(viewSettings.monospaceFont);
|
||||
}, [viewSettings.serifFont, viewSettings.sansSerifFont, viewSettings.monospaceFont]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTauriAppPlatform()) {
|
||||
getSysFontsList().then((res) => {
|
||||
if (res.error || Object.keys(res.fonts).length === 0) {
|
||||
console.error('Failed to get system fonts list:', res.error);
|
||||
return;
|
||||
}
|
||||
const processedFonts: string[] = [];
|
||||
Object.entries(res.fonts).forEach(([fontName, fontFamily]) => {
|
||||
if (!fontName || isSymbolicFontName(fontName)) return;
|
||||
|
||||
const fontsInFamily = Object.entries(res.fonts).filter(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
([_, family]) => family === fontFamily,
|
||||
);
|
||||
|
||||
if (fontsInFamily.length === 1) {
|
||||
processedFonts.push(fontFamily);
|
||||
} else {
|
||||
processedFonts.push(fontName);
|
||||
}
|
||||
});
|
||||
setSysFonts([...new Set(processedFonts)].sort((a, b) => a.localeCompare(b)));
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'defaultFont', defaultFont);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultFont]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'defaultCJKFont', defaultCJKFont);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultCJKFont]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'defaultFontSize', defaultFontSize);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'minimumFontSize', minFontSize);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [minFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'fontWeight', fontWeight);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fontWeight]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'serifFont', serifFont);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serifFont]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'sansSerifFont', sansSerifFont);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sansSerifFont]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'monospaceFont', monospaceFont);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [monospaceFont]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'overrideFont', overrideFont);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [overrideFont]);
|
||||
|
||||
const handleFontFamilyFont = (option: string) => {
|
||||
switch (option) {
|
||||
case 'Serif':
|
||||
return `'${serifFont}', serif`;
|
||||
case 'Sans-serif':
|
||||
return `'${sansSerifFont}', sans-serif`;
|
||||
case 'Monospace':
|
||||
return `'${monospaceFont}', monospace`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
if (fontPanelView === 'custom-fonts') {
|
||||
return (
|
||||
<div className='my-4 w-full'>
|
||||
<CustomFonts bookKey={bookKey} onBack={handleBackToMain} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Override Book Font')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={overrideFont}
|
||||
onChange={() => setOverrideFont(!overrideFont)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Font Size')}</h2>
|
||||
<div className='card border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<NumberInput
|
||||
label={_('Default Font Size')}
|
||||
value={defaultFontSize}
|
||||
onChange={setDefaultFontSize}
|
||||
min={minFontSize}
|
||||
max={120}
|
||||
/>
|
||||
<NumberInput
|
||||
label={_('Minimum Font Size')}
|
||||
value={minFontSize}
|
||||
onChange={setMinFontSize}
|
||||
min={1}
|
||||
max={120}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Font Weight')}</h2>
|
||||
<div className='card border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<NumberInput
|
||||
label={_('Font Weight')}
|
||||
value={fontWeight}
|
||||
onChange={setFontWeight}
|
||||
min={100}
|
||||
max={900}
|
||||
step={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Font Family')}</h2>
|
||||
<div className='card border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Default Font')}</span>
|
||||
<FontDropdown
|
||||
options={fontFamilyOptions}
|
||||
selected={defaultFont}
|
||||
onSelect={setDefaultFont}
|
||||
onGetFontFamily={handleFontFamilyFont}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(isCJKEnv() || view?.language.isCJK) && (
|
||||
<FontFace
|
||||
className='config-item-top'
|
||||
family='serif'
|
||||
label={_('CJK Font')}
|
||||
options={CJKFonts}
|
||||
selected={defaultCJKFont}
|
||||
onSelect={setDefaultCJKFont}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Font Face')}</h2>
|
||||
<button
|
||||
onClick={handleManageCustomFonts}
|
||||
className='btn btn-ghost btn-xs gap-1 hover:bg-transparent'
|
||||
title={_('Manage Custom Fonts')}
|
||||
>
|
||||
<MdSettings size={iconSize18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className='card border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<FontFace
|
||||
className='config-item-top'
|
||||
family='serif'
|
||||
label={_('Serif Font')}
|
||||
options={[
|
||||
...customFonts,
|
||||
...SERIF_FONTS.filter(filterNonFreeFonts),
|
||||
...CJK_SERIF_FONTS,
|
||||
]}
|
||||
moreOptions={sysFonts}
|
||||
selected={serifFont}
|
||||
onSelect={setSerifFont}
|
||||
/>
|
||||
<FontFace
|
||||
family='sans-serif'
|
||||
label={_('Sans-Serif Font')}
|
||||
options={[
|
||||
...customFonts,
|
||||
...SANS_SERIF_FONTS.filter(filterNonFreeFonts),
|
||||
...CJK_SANS_SERIF_FONTS,
|
||||
]}
|
||||
moreOptions={sysFonts}
|
||||
selected={sansSerifFont}
|
||||
onSelect={setSansSerifFont}
|
||||
/>
|
||||
<FontFace
|
||||
className='config-item-bottom'
|
||||
family='monospace'
|
||||
label={_('Monospace Font')}
|
||||
options={[...customFonts, ...MONOSPACE_FONTS]}
|
||||
moreOptions={sysFonts}
|
||||
selected={monospaceFont}
|
||||
onSelect={setMonospaceFont}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FontPanel;
|
||||
@@ -1,211 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import { getTranslators } from '@/services/translators';
|
||||
import { TRANSLATED_LANGS, TRANSLATOR_LANGS } from '@/services/constants';
|
||||
import { SettingsPanelPanelProp } from './SettingsDialog';
|
||||
import { useResetViewSettings } from '../../hooks/useResetSettings';
|
||||
import { saveAndReload } from '@/utils/reload';
|
||||
import Select from '@/components/Select';
|
||||
|
||||
const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
|
||||
const _ = useTranslation();
|
||||
const { token } = useAuth();
|
||||
const { envConfig } = useEnv();
|
||||
const { applyUILanguage } = useSettingsStore();
|
||||
const { getViewSettings, setViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const [uiLanguage, setUILanguage] = useState(viewSettings.uiLanguage!);
|
||||
const [translationEnabled, setTranslationEnabled] = useState(viewSettings.translationEnabled!);
|
||||
const [translationProvider, setTranslationProvider] = useState(viewSettings.translationProvider!);
|
||||
const [translateTargetLang, setTranslateTargetLang] = useState(viewSettings.translateTargetLang!);
|
||||
const [showTranslateSource, setShowTranslateSource] = useState(viewSettings.showTranslateSource!);
|
||||
|
||||
const resetToDefaults = useResetViewSettings();
|
||||
|
||||
const handleReset = () => {
|
||||
resetToDefaults({
|
||||
uiLanguage: setUILanguage,
|
||||
translationEnabled: setTranslationEnabled,
|
||||
translationProvider: setTranslationProvider,
|
||||
translateTargetLang: setTranslateTargetLang,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onRegisterReset(handleReset);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const getCurrentUILangOption = () => {
|
||||
const uiLanguage = viewSettings.uiLanguage;
|
||||
return {
|
||||
value: uiLanguage,
|
||||
label:
|
||||
uiLanguage === ''
|
||||
? _('Auto')
|
||||
: TRANSLATED_LANGS[uiLanguage as keyof typeof TRANSLATED_LANGS],
|
||||
};
|
||||
};
|
||||
|
||||
const getLangOptions = (langs: Record<string, string>) => {
|
||||
const options = Object.entries(langs).map(([value, label]) => ({ value, label }));
|
||||
options.sort((a, b) => a.label.localeCompare(b.label));
|
||||
options.unshift({ value: '', label: _('System Language') });
|
||||
return options;
|
||||
};
|
||||
|
||||
const handleSelectUILang = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const option = event.target.value;
|
||||
setUILanguage(option);
|
||||
};
|
||||
|
||||
const getTranslationProviderOptions = () => {
|
||||
const translators = getTranslators();
|
||||
const availableProviders = translators.map((t) => {
|
||||
let label = t.label;
|
||||
if (t.authRequired && !token) {
|
||||
label = `${label} (${_('Login Required')})`;
|
||||
} else if (t.quotaExceeded) {
|
||||
label = `${label} (${_('Quota Exceeded')})`;
|
||||
}
|
||||
return { value: t.name, label };
|
||||
});
|
||||
return availableProviders;
|
||||
};
|
||||
|
||||
const getCurrentTranslationProviderOption = () => {
|
||||
const value = translationProvider;
|
||||
const allProviders = getTranslationProviderOptions();
|
||||
const availableTranslators = getTranslators().filter(
|
||||
(t) => (t.authRequired ? !!token : true) && !t.quotaExceeded,
|
||||
);
|
||||
const currentProvider = availableTranslators.find((t) => t.name === value)
|
||||
? value
|
||||
: availableTranslators[0]?.name;
|
||||
return allProviders.find((p) => p.value === currentProvider) || allProviders[0]!;
|
||||
};
|
||||
|
||||
const handleSelectTranslationProvider = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const option = event.target.value;
|
||||
setTranslationProvider(option);
|
||||
saveViewSettings(envConfig, bookKey, 'translationProvider', option, false, false);
|
||||
viewSettings.translationProvider = option;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
};
|
||||
|
||||
const getCurrentTargetLangOption = () => {
|
||||
const value = translateTargetLang;
|
||||
const availableOptions = getLangOptions(TRANSLATOR_LANGS);
|
||||
return availableOptions.find((o) => o.value === value) || availableOptions[0]!;
|
||||
};
|
||||
|
||||
const handleSelectTargetLang = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const option = event.target.value;
|
||||
setTranslateTargetLang(option);
|
||||
saveViewSettings(envConfig, bookKey, 'translateTargetLang', option, false, false);
|
||||
viewSettings.translateTargetLang = option;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (uiLanguage === viewSettings.uiLanguage) return;
|
||||
saveViewSettings(envConfig, bookKey, 'uiLanguage', uiLanguage, false, false);
|
||||
applyUILanguage(uiLanguage);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uiLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (translationEnabled === viewSettings.translationEnabled) return;
|
||||
saveViewSettings(envConfig, bookKey, 'translationEnabled', translationEnabled, true, false);
|
||||
viewSettings.translationEnabled = translationEnabled;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
if (!showTranslateSource && !translationEnabled) {
|
||||
saveAndReload();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [translationEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showTranslateSource === viewSettings.showTranslateSource) return;
|
||||
saveViewSettings(envConfig, bookKey, 'showTranslateSource', showTranslateSource, false, false);
|
||||
saveAndReload();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showTranslateSource]);
|
||||
|
||||
return (
|
||||
<div className={clsx('my-4 w-full space-y-6')}>
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Language')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Interface Language')}</span>
|
||||
<Select
|
||||
value={getCurrentUILangOption().value}
|
||||
onChange={handleSelectUILang}
|
||||
options={getLangOptions(TRANSLATED_LANGS)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Translation')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Enable Translation')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={translationEnabled}
|
||||
onChange={() => setTranslationEnabled(!translationEnabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Show Source Text')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={showTranslateSource}
|
||||
disabled={!translationEnabled}
|
||||
onChange={() => setShowTranslateSource(!showTranslateSource)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Translation Service')}</span>
|
||||
<Select
|
||||
value={getCurrentTranslationProviderOption().value}
|
||||
onChange={handleSelectTranslationProvider}
|
||||
options={getTranslationProviderOptions()}
|
||||
disabled={!translationEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Translate To')}</span>
|
||||
<Select
|
||||
value={getCurrentTargetLangOption().value}
|
||||
onChange={handleSelectTargetLang}
|
||||
options={getLangOptions(TRANSLATOR_LANGS)}
|
||||
disabled={!translationEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LangPanel;
|
||||
@@ -1,737 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MdOutlineAutoMode, MdOutlineScreenRotation } from 'react-icons/md';
|
||||
import { MdOutlineTextRotationNone, MdTextRotateVertical } from 'react-icons/md';
|
||||
import { IoPhoneLandscapeOutline, IoPhonePortraitOutline } from 'react-icons/io5';
|
||||
import { TbTextDirectionRtl } from 'react-icons/tb';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResetViewSettings } from '../../hooks/useResetSettings';
|
||||
import { isCJKEnv } from '@/utils/misc';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { saveAndReload } from '@/utils/reload';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { lockScreenOrientation } from '@/utils/bridge';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import { getBookDirFromWritingMode, getBookLangCode } from '@/utils/book';
|
||||
import { MIGHT_BE_RTL_LANGS } from '@/services/constants';
|
||||
import { SettingsPanelPanelProp } from './SettingsDialog';
|
||||
import Select from '@/components/Select';
|
||||
import NumberInput from './NumberInput';
|
||||
|
||||
const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getView, getViewSettings, getGridInsets, setViewSettings } = useReaderStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const view = getView(bookKey);
|
||||
const bookData = getBookData(bookKey)!;
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const gridInsets = getGridInsets(bookKey)!;
|
||||
|
||||
const [paragraphMargin, setParagraphMargin] = useState(viewSettings.paragraphMargin!);
|
||||
const [lineHeight, setLineHeight] = useState(viewSettings.lineHeight!);
|
||||
const [wordSpacing, setWordSpacing] = useState(viewSettings.wordSpacing!);
|
||||
const [letterSpacing, setLetterSpacing] = useState(viewSettings.letterSpacing!);
|
||||
const [textIndent, setTextIndent] = useState(viewSettings.textIndent!);
|
||||
const [fullJustification, setFullJustification] = useState(viewSettings.fullJustification!);
|
||||
const [hyphenation, setHyphenation] = useState(viewSettings.hyphenation!);
|
||||
const [marginTopPx, setMarginTopPx] = useState(
|
||||
viewSettings.marginPx || viewSettings.marginTopPx!,
|
||||
);
|
||||
const [marginBottomPx, setMarginBottomPx] = useState(viewSettings.marginBottomPx!);
|
||||
const [marginLeftPx, setMarginLeftPx] = useState(viewSettings.marginLeftPx!);
|
||||
const [marginRightPx, setMarginRightPx] = useState(viewSettings.marginRightPx!);
|
||||
const [compactMarginTopPx, setCompactMarginTopPx] = useState(
|
||||
viewSettings.compactMarginPx || viewSettings.compactMarginTopPx!,
|
||||
);
|
||||
const [compactMarginBottomPx, setCompactMarginBottomPx] = useState(
|
||||
viewSettings.compactMarginBottomPx!,
|
||||
);
|
||||
const [gapPercent, setGapPercent] = useState(viewSettings.gapPercent!);
|
||||
const [compactMarginLeftPx, setCompactMarginLeftPx] = useState(viewSettings.compactMarginLeftPx!);
|
||||
const [compactMarginRightPx, setCompactMarginRightPx] = useState(
|
||||
viewSettings.compactMarginRightPx!,
|
||||
);
|
||||
const [maxColumnCount, setMaxColumnCount] = useState(viewSettings.maxColumnCount!);
|
||||
const [maxInlineSize, setMaxInlineSize] = useState(viewSettings.maxInlineSize!);
|
||||
const [maxBlockSize, setMaxBlockSize] = useState(viewSettings.maxBlockSize!);
|
||||
const [writingMode, setWritingMode] = useState(viewSettings.writingMode!);
|
||||
const [overrideLayout, setOverrideLayout] = useState(viewSettings.overrideLayout!);
|
||||
const [doubleBorder, setDoubleBorder] = useState(viewSettings.doubleBorder!);
|
||||
const [borderColor, setBorderColor] = useState(viewSettings.borderColor!);
|
||||
const [showHeader, setShowHeader] = useState(viewSettings.showHeader!);
|
||||
const [showFooter, setShowFooter] = useState(viewSettings.showFooter!);
|
||||
const [showBarsOnScroll, setShowBarsOnScroll] = useState(viewSettings.showBarsOnScroll!);
|
||||
const [showRemainingTime, setShowRemainingTime] = useState(viewSettings.showRemainingTime!);
|
||||
const [showRemainingPages, setShowRemainingPages] = useState(viewSettings.showRemainingPages!);
|
||||
const [showProgressInfo, setShowProgressInfo] = useState(viewSettings.showProgressInfo!);
|
||||
const [progressStyle, setProgressStyle] = useState(viewSettings.progressStyle);
|
||||
const [screenOrientation, setScreenOrientation] = useState(viewSettings.screenOrientation!);
|
||||
const resetToDefaults = useResetViewSettings();
|
||||
|
||||
const handleReset = () => {
|
||||
resetToDefaults({
|
||||
paragraphMargin: setParagraphMargin,
|
||||
lineHeight: setLineHeight,
|
||||
wordSpacing: setWordSpacing,
|
||||
letterSpacing: setLetterSpacing,
|
||||
textIndent: setTextIndent,
|
||||
fullJustification: setFullJustification,
|
||||
hyphenation: setHyphenation,
|
||||
marginTopPx: setMarginTopPx,
|
||||
marginBottomPx: setMarginBottomPx,
|
||||
marginLeftPx: setMarginLeftPx,
|
||||
marginRightPx: setMarginRightPx,
|
||||
compactMarginTopPx: setCompactMarginTopPx,
|
||||
compactMarginBottomPx: setCompactMarginBottomPx,
|
||||
compactMarginLeftPx: setCompactMarginLeftPx,
|
||||
compactMarginRightPx: setCompactMarginRightPx,
|
||||
gapPercent: setGapPercent,
|
||||
maxColumnCount: setMaxColumnCount,
|
||||
maxInlineSize: setMaxInlineSize,
|
||||
maxBlockSize: setMaxBlockSize,
|
||||
overrideLayout: setOverrideLayout,
|
||||
doubleBorder: setDoubleBorder,
|
||||
borderColor: setBorderColor,
|
||||
showHeader: setShowHeader,
|
||||
showFooter: setShowFooter,
|
||||
showBarsOnScroll: setShowBarsOnScroll,
|
||||
showRemainingTime: setShowRemainingTime,
|
||||
showRemainingPages: setShowRemainingPages,
|
||||
showProgressInfo: setShowProgressInfo,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onRegisterReset(handleReset);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'paragraphMargin', paragraphMargin);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paragraphMargin]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'lineHeight', lineHeight);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lineHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'wordSpacing', wordSpacing);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wordSpacing]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'letterSpacing', letterSpacing);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [letterSpacing]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'textIndent', textIndent);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [textIndent]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'fullJustification', fullJustification);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fullJustification]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'hyphenation', hyphenation);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hyphenation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (marginTopPx === viewSettings.marginTopPx) return;
|
||||
if (viewSettings.marginPx !== undefined) {
|
||||
saveViewSettings(envConfig, bookKey, 'marginPx', undefined, false, false);
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'marginTopPx', marginTopPx, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [marginTopPx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (marginBottomPx === viewSettings.marginBottomPx) return;
|
||||
if (viewSettings.marginPx !== undefined) {
|
||||
saveViewSettings(envConfig, bookKey, 'marginPx', undefined, false, false);
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'marginBottomPx', marginBottomPx, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [marginBottomPx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (marginRightPx === viewSettings.marginRightPx) return;
|
||||
if (viewSettings.marginPx !== undefined) {
|
||||
saveViewSettings(envConfig, bookKey, 'marginPx', undefined, false, false);
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'marginRightPx', marginRightPx, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [marginRightPx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (marginLeftPx === viewSettings.marginLeftPx) return;
|
||||
if (viewSettings.marginPx !== undefined) {
|
||||
saveViewSettings(envConfig, bookKey, 'marginPx', undefined, false, false);
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'marginLeftPx', marginLeftPx, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [marginLeftPx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (compactMarginTopPx === viewSettings.compactMarginTopPx) return;
|
||||
if (viewSettings.compactMarginPx !== undefined) {
|
||||
saveViewSettings(envConfig, bookKey, 'compactMarginPx', undefined, false, false);
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'compactMarginTopPx', compactMarginTopPx, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [compactMarginTopPx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (compactMarginBottomPx === viewSettings.compactMarginBottomPx) return;
|
||||
if (viewSettings.compactMarginPx !== undefined) {
|
||||
saveViewSettings(envConfig, bookKey, 'compactMarginPx', undefined, false, false);
|
||||
}
|
||||
saveViewSettings(
|
||||
envConfig,
|
||||
bookKey,
|
||||
'compactMarginBottomPx',
|
||||
compactMarginBottomPx,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [compactMarginBottomPx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (compactMarginRightPx === viewSettings.compactMarginRightPx) return;
|
||||
if (viewSettings.compactMarginPx !== undefined) {
|
||||
saveViewSettings(envConfig, bookKey, 'compactMarginPx', undefined, false, false);
|
||||
}
|
||||
saveViewSettings(
|
||||
envConfig,
|
||||
bookKey,
|
||||
'compactMarginRightPx',
|
||||
compactMarginRightPx,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [compactMarginRightPx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (compactMarginLeftPx === viewSettings.compactMarginLeftPx) return;
|
||||
if (viewSettings.compactMarginPx !== undefined) {
|
||||
saveViewSettings(envConfig, bookKey, 'compactMarginPx', undefined, false, false);
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'compactMarginLeftPx', compactMarginLeftPx, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [compactMarginLeftPx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gapPercent === viewSettings.gapPercent) return;
|
||||
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent, false, false);
|
||||
view?.renderer.setAttribute('gap', `${gapPercent}%`);
|
||||
if (viewSettings.scrolled) {
|
||||
view?.renderer.setAttribute('flow', 'scrolled');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gapPercent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (maxColumnCount === viewSettings.maxColumnCount) return;
|
||||
saveViewSettings(envConfig, bookKey, 'maxColumnCount', maxColumnCount, false, false);
|
||||
view?.renderer.setAttribute('max-column-count', maxColumnCount);
|
||||
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [maxColumnCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (maxInlineSize === viewSettings.maxInlineSize) return;
|
||||
saveViewSettings(envConfig, bookKey, 'maxInlineSize', maxInlineSize, false, false);
|
||||
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [maxInlineSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (maxBlockSize === viewSettings.maxBlockSize) return;
|
||||
saveViewSettings(envConfig, bookKey, 'maxBlockSize', maxBlockSize, false, false);
|
||||
view?.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [maxBlockSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (writingMode === viewSettings.writingMode) return;
|
||||
// global settings are not supported for writing mode
|
||||
const prevWritingMode = viewSettings.writingMode;
|
||||
if (writingMode.includes('vertical')) {
|
||||
viewSettings.vertical = true;
|
||||
} else {
|
||||
viewSettings.vertical = false;
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'writingMode', writingMode, true);
|
||||
if (view) {
|
||||
view.renderer.setStyles?.(getStyles(viewSettings));
|
||||
view.book.dir = getBookDirFromWritingMode(writingMode);
|
||||
}
|
||||
if (
|
||||
prevWritingMode !== writingMode &&
|
||||
(['horizontal-rl', 'vertical-rl'].includes(writingMode) ||
|
||||
['horizontal-rl', 'vertical-rl'].includes(prevWritingMode))
|
||||
) {
|
||||
saveAndReload();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [writingMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (overrideLayout === viewSettings.overrideLayout) return;
|
||||
saveViewSettings(envConfig, bookKey, 'overrideLayout', overrideLayout);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [overrideLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (doubleBorder === viewSettings.doubleBorder) return;
|
||||
saveViewSettings(envConfig, bookKey, 'doubleBorder', doubleBorder, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [doubleBorder]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'borderColor', borderColor, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [borderColor]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'showBarsOnScroll', showBarsOnScroll, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showBarsOnScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'showRemainingTime', showRemainingTime, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showRemainingTime]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'showRemainingPages', showRemainingPages, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showRemainingPages]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'showProgressInfo', showProgressInfo, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showProgressInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'progressStyle', progressStyle, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progressStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showHeader === viewSettings.showHeader) return;
|
||||
if (showHeader && !viewSettings.vertical) {
|
||||
const minMarginTop = Math.max(0, Math.round((44 - gridInsets.top) / 4) * 4);
|
||||
viewSettings.marginTopPx = Math.max(viewSettings.marginTopPx, minMarginTop);
|
||||
setMarginTopPx(viewSettings.marginTopPx);
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'showHeader', showHeader, false, false);
|
||||
// Margin and gap settings will be applied in FoliateViewer
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showHeader]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showFooter === viewSettings.showFooter) return;
|
||||
if (showFooter && !viewSettings.vertical) {
|
||||
const minMarginBottom = Math.max(0, Math.round((44 - gridInsets.bottom) / 4) * 4);
|
||||
viewSettings.marginBottomPx = Math.max(viewSettings.marginBottomPx, minMarginBottom);
|
||||
setMarginBottomPx(viewSettings.marginBottomPx);
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
}
|
||||
saveViewSettings(envConfig, bookKey, 'showFooter', showFooter, false, false);
|
||||
// Margin and gap settings will be applied in FoliateViewer
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showFooter]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'screenOrientation', screenOrientation, false, false);
|
||||
if (appService?.isMobileApp) {
|
||||
lockScreenOrientation({ orientation: screenOrientation });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [screenOrientation]);
|
||||
|
||||
const langCode = getBookLangCode(bookData.bookDoc?.metadata?.language);
|
||||
const mightBeRTLBook = MIGHT_BE_RTL_LANGS.includes(langCode) || isCJKEnv();
|
||||
const isVertical = viewSettings.vertical || writingMode.includes('vertical');
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Override Book Layout')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={overrideLayout}
|
||||
onChange={() => setOverrideLayout(!overrideLayout)}
|
||||
/>
|
||||
</div>
|
||||
{mightBeRTLBook && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Writing Mode')}</h2>
|
||||
<div className='flex gap-4'>
|
||||
<button
|
||||
title={_('Default')}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('auto')}
|
||||
>
|
||||
<MdOutlineAutoMode />
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={_('Horizontal Direction')}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('horizontal-tb')}
|
||||
>
|
||||
<MdOutlineTextRotationNone />
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={_('Vertical Direction')}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('vertical-rl')}
|
||||
>
|
||||
<MdTextRotateVertical />
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={_('RTL Direction')}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-rl' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('horizontal-rl')}
|
||||
>
|
||||
<TbTextDirectionRtl />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewSettings.vertical && (
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Border Frame')}</h2>
|
||||
<div className='card bg-base-100 border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Double Border')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={doubleBorder}
|
||||
onChange={() => setDoubleBorder(!doubleBorder)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Border Color')}</span>
|
||||
<div className='flex gap-4'>
|
||||
<button
|
||||
className={`btn btn-circle btn-sm bg-red-300 hover:bg-red-500 ${borderColor === 'red' ? 'btn-active !bg-red-500' : ''}`}
|
||||
onClick={() => setBorderColor('red')}
|
||||
></button>
|
||||
|
||||
<button
|
||||
className={`btn btn-circle btn-sm bg-black/50 hover:bg-black ${borderColor === 'black' ? 'btn-active !bg-black' : ''}`}
|
||||
onClick={() => setBorderColor('black')}
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Paragraph')}</h2>
|
||||
<div className='card bg-base-100 border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<NumberInput
|
||||
label={_('Paragraph Margin')}
|
||||
value={paragraphMargin}
|
||||
onChange={setParagraphMargin}
|
||||
min={0}
|
||||
max={4}
|
||||
step={0.2}
|
||||
/>
|
||||
<NumberInput
|
||||
label={_('Line Spacing')}
|
||||
value={lineHeight}
|
||||
onChange={setLineHeight}
|
||||
min={1.0}
|
||||
max={3.0}
|
||||
step={0.1}
|
||||
/>
|
||||
{langCode !== 'zh' && (
|
||||
<NumberInput
|
||||
label={_('Word Spacing')}
|
||||
value={wordSpacing}
|
||||
onChange={setWordSpacing}
|
||||
min={-4}
|
||||
max={8}
|
||||
step={0.5}
|
||||
/>
|
||||
)}
|
||||
<NumberInput
|
||||
label={_('Letter Spacing')}
|
||||
value={letterSpacing}
|
||||
onChange={setLetterSpacing}
|
||||
min={-2}
|
||||
max={4}
|
||||
step={0.5}
|
||||
/>
|
||||
<NumberInput
|
||||
label={_('Text Indent')}
|
||||
value={textIndent}
|
||||
onChange={setTextIndent}
|
||||
min={-2}
|
||||
max={4}
|
||||
step={1}
|
||||
/>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Full Justification')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={fullJustification}
|
||||
onChange={() => setFullJustification(!fullJustification)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Hyphenation')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={hyphenation}
|
||||
onChange={() => setHyphenation(!hyphenation)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Page')}</h2>
|
||||
<div className='card bg-base-100 border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<NumberInput
|
||||
label={_('Top Margin (px)')}
|
||||
value={showHeader && !isVertical ? marginTopPx : compactMarginTopPx}
|
||||
onChange={showHeader && !isVertical ? setMarginTopPx : setCompactMarginTopPx}
|
||||
min={
|
||||
showHeader && !isVertical
|
||||
? Math.max(0, Math.round((44 - gridInsets.top) / 4) * 4)
|
||||
: 0
|
||||
}
|
||||
max={88}
|
||||
step={4}
|
||||
/>
|
||||
<NumberInput
|
||||
label={_('Bottom Margin (px)')}
|
||||
value={showFooter && !isVertical ? marginBottomPx : compactMarginBottomPx}
|
||||
onChange={showFooter && !isVertical ? setMarginBottomPx : setCompactMarginBottomPx}
|
||||
min={
|
||||
showFooter && !isVertical
|
||||
? Math.max(0, Math.round((44 - gridInsets.bottom) / 4) * 4)
|
||||
: 0
|
||||
}
|
||||
max={88}
|
||||
step={4}
|
||||
/>
|
||||
<NumberInput
|
||||
label={_('Left Margin (px)')}
|
||||
value={showFooter && isVertical ? marginLeftPx : compactMarginLeftPx}
|
||||
onChange={showFooter && isVertical ? setMarginLeftPx : setCompactMarginLeftPx}
|
||||
min={0}
|
||||
max={88}
|
||||
step={4}
|
||||
/>
|
||||
<NumberInput
|
||||
label={_('Right Margin (px)')}
|
||||
value={showHeader && isVertical ? marginRightPx : compactMarginRightPx}
|
||||
onChange={showHeader && isVertical ? setMarginRightPx : setCompactMarginRightPx}
|
||||
min={0}
|
||||
max={88}
|
||||
step={4}
|
||||
/>
|
||||
<NumberInput
|
||||
label={_('Column Gap (%)')}
|
||||
value={gapPercent}
|
||||
onChange={setGapPercent}
|
||||
min={0}
|
||||
max={30}
|
||||
/>
|
||||
<NumberInput
|
||||
label={_('Maximum Number of Columns')}
|
||||
value={maxColumnCount}
|
||||
onChange={setMaxColumnCount}
|
||||
min={1}
|
||||
max={4}
|
||||
/>
|
||||
<NumberInput
|
||||
label={viewSettings.vertical ? _('Maximum Column Height') : _('Maximum Column Width')}
|
||||
value={maxInlineSize}
|
||||
onChange={setMaxInlineSize}
|
||||
disabled={maxColumnCount === 1 || viewSettings.scrolled}
|
||||
min={400}
|
||||
max={9999}
|
||||
step={100}
|
||||
/>
|
||||
<NumberInput
|
||||
label={viewSettings.vertical ? _('Maximum Column Width') : _('Maximum Column Height')}
|
||||
value={maxBlockSize}
|
||||
onChange={setMaxBlockSize}
|
||||
disabled={maxColumnCount === 1 || viewSettings.scrolled}
|
||||
min={400}
|
||||
max={9999}
|
||||
step={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Header & Footer')}</h2>
|
||||
<div className='card bg-base-100 border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Show Header')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={showHeader}
|
||||
onChange={() => setShowHeader(!showHeader)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Show Footer')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={showFooter}
|
||||
onChange={() => setShowFooter(!showFooter)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Show Remaining Time')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={showRemainingTime}
|
||||
disabled={!showFooter}
|
||||
onChange={() => {
|
||||
if (!showRemainingTime) {
|
||||
setShowRemainingTime(true);
|
||||
setShowRemainingPages(false);
|
||||
} else {
|
||||
setShowRemainingTime(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Show Remaining Pages')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={showRemainingPages}
|
||||
disabled={!showFooter}
|
||||
onChange={() => {
|
||||
if (!showRemainingPages) {
|
||||
setShowRemainingPages(true);
|
||||
setShowRemainingTime(false);
|
||||
} else {
|
||||
setShowRemainingPages(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Show Reading Progress')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={showProgressInfo}
|
||||
disabled={!showFooter}
|
||||
onChange={() => setShowProgressInfo(!showProgressInfo)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Reading Progress Style')}</span>
|
||||
<Select
|
||||
value={progressStyle}
|
||||
onChange={(e) => setProgressStyle(e.target.value as 'percentage' | 'fraction')}
|
||||
options={[
|
||||
{ value: 'fraction', label: _('Page Number') },
|
||||
{ value: 'percentage', label: _('Percentage') },
|
||||
]}
|
||||
disabled={!showProgressInfo}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Apply also in Scrolled Mode')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={showBarsOnScroll}
|
||||
onChange={() => setShowBarsOnScroll(!showBarsOnScroll)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appService?.hasOrientationLock && (
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Screen')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Orientation')}</span>
|
||||
<div className='flex gap-4'>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Auto')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'auto' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setScreenOrientation('auto')}
|
||||
>
|
||||
<MdOutlineScreenRotation />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Portrait')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'portrait' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setScreenOrientation('portrait')}
|
||||
>
|
||||
<IoPhonePortraitOutline />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Landscape')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'landscape' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setScreenOrientation('landscape')}
|
||||
>
|
||||
<IoPhoneLandscapeOutline />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutPanel;
|
||||
@@ -1,222 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import cssbeautify from 'cssbeautify';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResetViewSettings } from '../../hooks/useResetSettings';
|
||||
import { SettingsPanelPanelProp } from './SettingsDialog';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import cssValidate from '@/utils/css';
|
||||
|
||||
type CSSType = 'book' | 'reader';
|
||||
|
||||
const MiscPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
|
||||
const { getView, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const [draftContentStylesheet, setDraftContentStylesheet] = useState(viewSettings.userStylesheet);
|
||||
const [draftContentStylesheetSaved, setDraftContentStylesheetSaved] = useState(true);
|
||||
const [contentError, setContentError] = useState<string | null>(null);
|
||||
const [draftUIStylesheet, setDraftUIStylesheet] = useState(viewSettings.userUIStylesheet);
|
||||
const [draftUIStylesheetSaved, setDraftUIStylesheetSaved] = useState(true);
|
||||
const [uiError, setUIError] = useState<string | null>(null);
|
||||
|
||||
const [inputFocusInAndroid, setInputFocusInAndroid] = useState(false);
|
||||
const contentTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const uiTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const resetToDefaults = useResetViewSettings();
|
||||
|
||||
const handleReset = () => {
|
||||
resetToDefaults({
|
||||
userStylesheet: setDraftContentStylesheet,
|
||||
userUIStylesheet: setDraftUIStylesheet,
|
||||
});
|
||||
applyStyles('book', true);
|
||||
applyStyles('reader', true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onRegisterReset(handleReset);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const validateCSS = (cssInput: string): { isValid: boolean; error?: string } => {
|
||||
if (!cssInput.trim()) return { isValid: true };
|
||||
|
||||
try {
|
||||
const { isValid, error } = cssValidate(cssInput);
|
||||
if (!isValid) {
|
||||
return { isValid: false, error: error || 'Invalid CSS' };
|
||||
}
|
||||
return { isValid: true };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
return { isValid: false, error: err.message };
|
||||
}
|
||||
return { isValid: false, error: 'Invalid CSS: Please check your input.' };
|
||||
}
|
||||
};
|
||||
|
||||
const handleStylesheetChange = (e: React.ChangeEvent<HTMLTextAreaElement>, type: CSSType) => {
|
||||
const cssInput = e.target.value;
|
||||
|
||||
if (type === 'book') {
|
||||
setDraftContentStylesheet(cssInput);
|
||||
setDraftContentStylesheetSaved(false);
|
||||
|
||||
const { isValid, error } = validateCSS(cssInput);
|
||||
setContentError(isValid ? null : error || 'Invalid CSS');
|
||||
} else {
|
||||
setDraftUIStylesheet(cssInput);
|
||||
setDraftUIStylesheetSaved(false);
|
||||
|
||||
const { isValid, error } = validateCSS(cssInput);
|
||||
setUIError(isValid ? null : error || 'Invalid CSS');
|
||||
}
|
||||
};
|
||||
|
||||
const applyStyles = (type: CSSType, clear = false) => {
|
||||
const cssInput = type === 'book' ? draftContentStylesheet : draftUIStylesheet;
|
||||
const formattedCSS = cssbeautify(clear ? '' : cssInput, {
|
||||
indent: ' ',
|
||||
openbrace: 'end-of-line',
|
||||
autosemicolon: true,
|
||||
});
|
||||
|
||||
if (type === 'book') {
|
||||
setDraftContentStylesheet(formattedCSS);
|
||||
setDraftContentStylesheetSaved(true);
|
||||
viewSettings.userStylesheet = formattedCSS;
|
||||
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.userStylesheet = formattedCSS;
|
||||
setSettings(settings);
|
||||
}
|
||||
} else {
|
||||
setDraftUIStylesheet(formattedCSS);
|
||||
setDraftUIStylesheetSaved(true);
|
||||
viewSettings.userUIStylesheet = formattedCSS;
|
||||
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.userUIStylesheet = formattedCSS;
|
||||
setSettings(settings);
|
||||
}
|
||||
}
|
||||
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
};
|
||||
|
||||
const handleInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
const handleInputFocus = (textareaRef: React.RefObject<HTMLTextAreaElement>) => {
|
||||
if (appService?.isAndroidApp) {
|
||||
setInputFocusInAndroid(true);
|
||||
}
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.scrollIntoView({
|
||||
behavior: 'instant',
|
||||
block: 'center',
|
||||
});
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
if (appService?.isAndroidApp) {
|
||||
setTimeout(() => {
|
||||
setInputFocusInAndroid(false);
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCSSEditor = (
|
||||
type: CSSType,
|
||||
title: string,
|
||||
placeholder: string,
|
||||
value: string,
|
||||
error: string | null,
|
||||
saved: boolean,
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement>,
|
||||
) => (
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium' aria-label={_(title)}>
|
||||
{_(title)}
|
||||
</h2>
|
||||
<div
|
||||
className={`card border-base-200 bg-base-100 border shadow ${error ? 'border-red-500' : ''}`}
|
||||
>
|
||||
<div className='relative p-1'>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={clsx(
|
||||
'textarea textarea-ghost h-48 w-full border-0 p-3 text-base !outline-none sm:text-sm',
|
||||
'placeholder:text-base-content/70',
|
||||
)}
|
||||
placeholder={_(placeholder)}
|
||||
spellCheck='false'
|
||||
value={value}
|
||||
onFocus={() => handleInputFocus(textareaRef)}
|
||||
onBlur={handleInputBlur}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleInput}
|
||||
onKeyUp={handleInput}
|
||||
onChange={(e) => handleStylesheetChange(e, type)}
|
||||
/>
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-ghost bg-base-200 absolute bottom-2 right-4 h-8 min-h-8 px-4 py-2',
|
||||
saved ? 'hidden' : '',
|
||||
error ? 'btn-disabled' : '',
|
||||
)}
|
||||
onClick={() => applyStyles(type)}
|
||||
disabled={!!error}
|
||||
>
|
||||
{_('Apply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className='mt-1 text-sm text-red-500'>{error}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'my-4 w-full space-y-6',
|
||||
inputFocusInAndroid && 'h-[50%] overflow-y-auto pb-[200px]',
|
||||
)}
|
||||
>
|
||||
{renderCSSEditor(
|
||||
'book',
|
||||
_('Custom Content CSS'),
|
||||
_('Enter CSS for book content styling...'),
|
||||
draftContentStylesheet,
|
||||
contentError,
|
||||
draftContentStylesheetSaved,
|
||||
contentTextareaRef,
|
||||
)}
|
||||
|
||||
{renderCSSEditor(
|
||||
'reader',
|
||||
_('Custom Reader UI CSS'),
|
||||
_('Enter CSS for reader interface styling...'),
|
||||
draftUIStylesheet,
|
||||
uiError,
|
||||
draftUIStylesheetSaved,
|
||||
uiTextareaRef,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiscPanel;
|
||||
@@ -1,110 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { FiMinus, FiPlus } from 'react-icons/fi';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
interface NumberInputProps {
|
||||
className?: string;
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const NumberInput: React.FC<NumberInputProps> = ({
|
||||
className,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
disabled,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const numberStep = step || 1;
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
|
||||
// Allow empty string or valid numbers without leading zeros
|
||||
if (value === '' || /^[1-9]\d*\.?\d*$|^0?\.?\d*$/.test(value)) {
|
||||
const newValue = value === '' ? 0 : parseFloat(value);
|
||||
setLocalValue(newValue);
|
||||
|
||||
if (!isNaN(newValue)) {
|
||||
const roundedValue = Math.round(newValue * 10) / 10;
|
||||
onChange(Math.max(min, Math.min(max, roundedValue)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const increment = () => {
|
||||
const newValue = Math.min(max, localValue + numberStep);
|
||||
const roundedValue = Math.round(newValue * 10) / 10;
|
||||
setLocalValue(roundedValue);
|
||||
onChange(roundedValue);
|
||||
};
|
||||
|
||||
const decrement = () => {
|
||||
const newValue = Math.max(min, localValue - numberStep);
|
||||
const roundedValue = Math.round(newValue * 10) / 10;
|
||||
setLocalValue(roundedValue);
|
||||
onChange(roundedValue);
|
||||
};
|
||||
|
||||
const handleOnBlur = () => {
|
||||
const newValue = Math.max(min, Math.min(max, localValue));
|
||||
setLocalValue(newValue);
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx('config-item', className)}>
|
||||
<span className='text-base-content line-clamp-2'>{label}</span>
|
||||
<div className='text-base-content flex items-center gap-2'>
|
||||
<input
|
||||
type='text'
|
||||
inputMode='decimal'
|
||||
disabled={disabled}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleOnBlur}
|
||||
className={clsx(
|
||||
'input input-ghost settings-content text-base-content w-16 max-w-xs rounded border-0 bg-transparent py-1 pe-3 ps-1 text-right !outline-none',
|
||||
disabled && 'input-disabled cursor-not-allowed disabled:bg-transparent',
|
||||
)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
<button
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
disabled={value <= min || disabled}
|
||||
aria-label={_('Decrease')}
|
||||
onClick={decrement}
|
||||
className={`btn btn-circle btn-sm ${value <= min || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
|
||||
>
|
||||
<FiMinus className='h-4 w-4' />
|
||||
</button>
|
||||
<button
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
disabled={value >= max || disabled}
|
||||
aria-label={_('Increase')}
|
||||
onClick={increment}
|
||||
className={`btn btn-circle btn-sm ${value >= max || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
|
||||
>
|
||||
<FiPlus className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NumberInput;
|
||||
@@ -1,293 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { BookConfig } from '@/types/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { RiFontSize } from 'react-icons/ri';
|
||||
import { RiDashboardLine, RiTranslate } from 'react-icons/ri';
|
||||
import { VscSymbolColor } from 'react-icons/vsc';
|
||||
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
|
||||
import { LiaHandPointerSolid } from 'react-icons/lia';
|
||||
import { IoAccessibilityOutline } from 'react-icons/io5';
|
||||
import { MdArrowBackIosNew, MdArrowForwardIos, MdClose } from 'react-icons/md';
|
||||
import { getDirFromUILanguage } from '@/utils/rtl';
|
||||
import FontPanel from './FontPanel';
|
||||
import LayoutPanel from './LayoutPanel';
|
||||
import ColorPanel from './ColorPanel';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import Dialog from '@/components/Dialog';
|
||||
import DialogMenu from './DialogMenu';
|
||||
import ControlPanel from './ControlPanel';
|
||||
import LangPanel from './LangPanel';
|
||||
import MiscPanel from './MiscPanel';
|
||||
|
||||
export type SettingsPanelType = 'Font' | 'Layout' | 'Color' | 'Control' | 'Language' | 'Custom';
|
||||
export type SettingsPanelPanelProp = {
|
||||
bookKey: string;
|
||||
onRegisterReset: (resetFn: () => void) => void;
|
||||
};
|
||||
|
||||
type TabConfig = {
|
||||
tab: SettingsPanelType;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const closeIconSize = useResponsiveSize(16);
|
||||
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
|
||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showAllTabLabels, setShowAllTabLabels] = useState(false);
|
||||
const { setFontPanelView, setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
|
||||
const tabConfig = [
|
||||
{
|
||||
tab: 'Font',
|
||||
icon: RiFontSize,
|
||||
label: _('Font'),
|
||||
},
|
||||
{
|
||||
tab: 'Layout',
|
||||
icon: RiDashboardLine,
|
||||
label: _('Layout'),
|
||||
},
|
||||
{
|
||||
tab: 'Color',
|
||||
icon: VscSymbolColor,
|
||||
label: _('Color'),
|
||||
},
|
||||
{
|
||||
tab: 'Control',
|
||||
icon: LiaHandPointerSolid,
|
||||
label: _('Behavior'),
|
||||
},
|
||||
{
|
||||
tab: 'Language',
|
||||
icon: RiTranslate,
|
||||
label: _('Language'),
|
||||
},
|
||||
{
|
||||
tab: 'Custom',
|
||||
icon: IoAccessibilityOutline,
|
||||
label: _('Custom'),
|
||||
},
|
||||
] as TabConfig[];
|
||||
|
||||
const [activePanel, setActivePanel] = useState<SettingsPanelType>(() => {
|
||||
const lastPanel = localStorage.getItem('lastConfigPanel');
|
||||
if (lastPanel && tabConfig.some((tab) => tab.tab === lastPanel)) {
|
||||
return lastPanel as SettingsPanelType;
|
||||
}
|
||||
return 'Font' as SettingsPanelType;
|
||||
});
|
||||
|
||||
const handleSetActivePanel = (tab: SettingsPanelType) => {
|
||||
setActivePanel(tab);
|
||||
setFontPanelView('main-fonts');
|
||||
localStorage.setItem('lastConfigPanel', tab);
|
||||
};
|
||||
|
||||
const [resetFunctions, setResetFunctions] = useState<
|
||||
Record<SettingsPanelType, (() => void) | null>
|
||||
>({
|
||||
Font: null,
|
||||
Layout: null,
|
||||
Color: null,
|
||||
Control: null,
|
||||
Language: null,
|
||||
Custom: null,
|
||||
});
|
||||
|
||||
const registerResetFunction = (panel: SettingsPanelType, resetFn: () => void) => {
|
||||
setResetFunctions((prev) => ({ ...prev, [panel]: resetFn }));
|
||||
};
|
||||
|
||||
const handleResetCurrentPanel = () => {
|
||||
const resetFn = resetFunctions[activePanel];
|
||||
if (resetFn) {
|
||||
resetFn();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setFontLayoutSettingsDialogOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFontPanelView('main-fonts');
|
||||
|
||||
const container = tabsRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const checkButtonWidths = () => {
|
||||
const threshold = (container.clientWidth - 64) * 0.22;
|
||||
const hideLabel = Array.from(container.querySelectorAll('button')).some((button) => {
|
||||
const labelSpan = button.querySelector('span');
|
||||
const labelText = labelSpan?.textContent || '';
|
||||
const clone = button.cloneNode(true) as HTMLButtonElement;
|
||||
clone.style.position = 'absolute';
|
||||
clone.style.visibility = 'hidden';
|
||||
clone.style.width = 'auto';
|
||||
const cloneSpan = clone.querySelector('span');
|
||||
if (cloneSpan) {
|
||||
cloneSpan.classList.remove('hidden');
|
||||
cloneSpan.textContent = labelText;
|
||||
}
|
||||
document.body.appendChild(clone);
|
||||
const fullWidth = clone.scrollWidth;
|
||||
document.body.removeChild(clone);
|
||||
return fullWidth > threshold;
|
||||
});
|
||||
setShowAllTabLabels(!hideLabel);
|
||||
};
|
||||
|
||||
checkButtonWidths();
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkButtonWidths);
|
||||
resizeObserver.observe(container);
|
||||
const mutationObserver = new MutationObserver(checkButtonWidths);
|
||||
mutationObserver.observe(container, {
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
};
|
||||
}, [setFontPanelView]);
|
||||
|
||||
const currentPanel = tabConfig.find((tab) => tab.tab === activePanel);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
onClose={handleClose}
|
||||
className='modal-open'
|
||||
bgClassName='sm:!bg-black/20'
|
||||
boxClassName={clsx('sm:min-w-[520px]', appService?.isMobile && 'sm:max-w-[90%] sm:w-3/4')}
|
||||
snapHeight={appService?.isMobile ? 0.7 : undefined}
|
||||
header={
|
||||
<div className='flex w-full flex-col items-center'>
|
||||
<div className='tab-title flex pb-2 text-base font-semibold sm:hidden'>
|
||||
{currentPanel?.label || ''}
|
||||
</div>
|
||||
<div className='flex w-full flex-row items-center justify-between'>
|
||||
<button
|
||||
tabIndex={-1}
|
||||
aria-label={_('Close')}
|
||||
onClick={handleClose}
|
||||
className={
|
||||
'btn btn-ghost btn-circle flex h-8 min-h-8 w-8 hover:bg-transparent focus:outline-none sm:hidden'
|
||||
}
|
||||
>
|
||||
{isRtl ? <MdArrowForwardIos /> : <MdArrowBackIosNew />}
|
||||
</button>
|
||||
<div
|
||||
ref={tabsRef}
|
||||
role='group'
|
||||
aria-label={_('Settings Panels') + ' - ' + (currentPanel?.label || '')}
|
||||
className={clsx('dialog-tabs ms-1 flex h-10 w-full items-center gap-1 sm:ms-0')}
|
||||
>
|
||||
{tabConfig.map(({ tab, icon: Icon, label }) => (
|
||||
<button
|
||||
key={tab}
|
||||
data-tab={tab}
|
||||
tabIndex={0}
|
||||
title={label}
|
||||
className={clsx(
|
||||
'btn btn-ghost text-base-content btn-sm gap-1 px-2',
|
||||
activePanel === tab ? 'btn-active' : '',
|
||||
)}
|
||||
onClick={() => handleSetActivePanel(tab)}
|
||||
>
|
||||
<Icon className='mr-0' />
|
||||
<span
|
||||
className={clsx(
|
||||
window.innerWidth < 640 && 'hidden',
|
||||
!(showAllTabLabels || activePanel === tab) && 'hidden',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex h-full items-center justify-end gap-x-2'>
|
||||
<Dropdown
|
||||
label={_('Settings Menu')}
|
||||
className='dropdown-bottom dropdown-end'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0 flex items-center justify-center'
|
||||
toggleButton={<PiDotsThreeVerticalBold />}
|
||||
>
|
||||
<DialogMenu
|
||||
activePanel={activePanel}
|
||||
onReset={handleResetCurrentPanel}
|
||||
resetLabel={
|
||||
currentPanel
|
||||
? _('Reset {{settings}}', { settings: currentPanel.label })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Dropdown>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
aria-label={_('Close')}
|
||||
className={
|
||||
'bg-base-300/65 btn btn-ghost btn-circle hidden h-6 min-h-6 w-6 p-0 sm:flex'
|
||||
}
|
||||
>
|
||||
<MdClose size={closeIconSize} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div role='group' aria-label={`${_(currentPanel?.label || '')} - ${_('Settings')}`}>
|
||||
{activePanel === 'Font' && (
|
||||
<FontPanel
|
||||
bookKey={bookKey}
|
||||
onRegisterReset={(fn) => registerResetFunction('Font', fn)}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'Layout' && (
|
||||
<LayoutPanel
|
||||
bookKey={bookKey}
|
||||
onRegisterReset={(fn) => registerResetFunction('Layout', fn)}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'Color' && (
|
||||
<ColorPanel
|
||||
bookKey={bookKey}
|
||||
onRegisterReset={(fn) => registerResetFunction('Color', fn)}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'Control' && (
|
||||
<ControlPanel
|
||||
bookKey={bookKey}
|
||||
onRegisterReset={(fn) => registerResetFunction('Control', fn)}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'Language' && (
|
||||
<LangPanel
|
||||
bookKey={bookKey}
|
||||
onRegisterReset={(fn) => registerResetFunction('Language', fn)}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'Custom' && (
|
||||
<MiscPanel
|
||||
bookKey={bookKey}
|
||||
onRegisterReset={(fn) => registerResetFunction('Custom', fn)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsDialog;
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { CustomTheme } from '@/styles/themes';
|
||||
import { md5Fingerprint } from '@/utils/md5';
|
||||
import { CUSTOM_THEME_TEMPLATES } from '@/services/constants';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import clsx from 'clsx';
|
||||
import ColorInput from './ColorInput';
|
||||
|
||||
type ThemeEditorProps = {
|
||||
customTheme: CustomTheme | null;
|
||||
onSave: (customTheme: CustomTheme) => void;
|
||||
onDelete: (customTheme: CustomTheme) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete, onCancel }) => {
|
||||
const _ = useTranslation();
|
||||
const { settings } = useSettingsStore();
|
||||
const template =
|
||||
CUSTOM_THEME_TEMPLATES[Math.floor(Math.random() * CUSTOM_THEME_TEMPLATES.length)]!;
|
||||
const [lightTextColor, setLightTextColor] = useState(
|
||||
customTheme?.colors.light.fg || template.light.fg,
|
||||
);
|
||||
const [lightBackgroundColor, setLightBackgroundColor] = useState(
|
||||
customTheme?.colors.light.bg || template.light.bg,
|
||||
);
|
||||
const [lightPrimaryColor, setLightPrimaryColor] = useState(
|
||||
customTheme?.colors.light.primary || template.light.primary,
|
||||
);
|
||||
const [darkTextColor, setDarkTextColor] = useState(
|
||||
customTheme?.colors.dark.fg || template.dark.fg,
|
||||
);
|
||||
const [darkBackgroundColor, setDarkBackgroundColor] = useState(
|
||||
customTheme?.colors.dark.bg || template.dark.bg,
|
||||
);
|
||||
const [darkPrimaryColor, setDarkPrimaryColor] = useState(
|
||||
customTheme?.colors.dark.primary || template.dark.primary,
|
||||
);
|
||||
|
||||
const [themeName, setThemeName] = useState(customTheme?.label || _('Custom'));
|
||||
|
||||
const ThemePreview: React.FC<{
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
primaryColor: string;
|
||||
label: string;
|
||||
}> = ({ textColor, backgroundColor, primaryColor, label }) => (
|
||||
<div className='mb-2 mt-4'>
|
||||
<label className='mb-1 block text-sm font-medium'>{label}</label>
|
||||
<div
|
||||
className='border-base-300 overflow-hidden rounded border p-2'
|
||||
style={{
|
||||
backgroundColor: backgroundColor,
|
||||
color: textColor,
|
||||
}}
|
||||
>
|
||||
<p className='mb-2 whitespace-pre-line text-xs'>
|
||||
{_(
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare",
|
||||
)}
|
||||
{'\n\n'}
|
||||
<span
|
||||
className='mt-4 cursor-pointer italic'
|
||||
style={{
|
||||
color: primaryColor,
|
||||
}}
|
||||
>
|
||||
{_("(from 'As You Like It', Act II)")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const getCustomTheme = () => {
|
||||
return {
|
||||
name: md5Fingerprint(themeName),
|
||||
label: themeName,
|
||||
colors: {
|
||||
light: {
|
||||
fg: lightTextColor,
|
||||
bg: lightBackgroundColor,
|
||||
primary: lightPrimaryColor,
|
||||
},
|
||||
dark: {
|
||||
fg: darkTextColor,
|
||||
bg: darkBackgroundColor,
|
||||
primary: darkPrimaryColor,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='mt-6 rounded-lg'>
|
||||
<div className='mb-4'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<label className='font-medium'>{_('Custom Theme')}</label>
|
||||
<div className='flex w-[calc(50%-12px)] justify-between'>
|
||||
<button
|
||||
className='btn btn-ghost btn-sm text-base-content px-2'
|
||||
onClick={() => onSave(getCustomTheme())}
|
||||
>
|
||||
{_('Save')}
|
||||
</button>
|
||||
|
||||
{settings.globalReadSettings.customThemes.find(
|
||||
(theme) => theme.name === md5Fingerprint(themeName),
|
||||
) && (
|
||||
<button
|
||||
className={clsx('btn btn-ghost btn-sm px-2')}
|
||||
onClick={() => onDelete(getCustomTheme())}
|
||||
>
|
||||
{_('Delete')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className='btn btn-ghost btn-sm px-2' onClick={onCancel}>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<label className='font-medium'>{_('Theme Name')}</label>
|
||||
<input
|
||||
type='text'
|
||||
value={themeName}
|
||||
onChange={(e) => setThemeName(e.target.value)}
|
||||
className='bg-base-100 text-base-content border-base-200 w-[calc(50%-12px)] rounded border p-2 text-sm'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-6'>
|
||||
<div className='bg-base-200 rounded-lg p-3'>
|
||||
<h3 className='mb-3 text-center font-medium'>{_('Light Mode')}</h3>
|
||||
|
||||
<ColorInput label={_('Text Color')} value={lightTextColor} onChange={setLightTextColor} />
|
||||
|
||||
<ColorInput
|
||||
label={_('Background Color')}
|
||||
value={lightBackgroundColor}
|
||||
onChange={setLightBackgroundColor}
|
||||
/>
|
||||
|
||||
<ColorInput
|
||||
label={_('Link Color')}
|
||||
value={lightPrimaryColor}
|
||||
onChange={setLightPrimaryColor}
|
||||
/>
|
||||
|
||||
<ThemePreview
|
||||
textColor={lightTextColor}
|
||||
backgroundColor={lightBackgroundColor}
|
||||
primaryColor={lightPrimaryColor}
|
||||
label={_('Preview')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='bg-base-300 rounded-lg p-3'>
|
||||
<h3 className='mb-3 text-center font-medium'>{_('Dark Mode')}</h3>
|
||||
|
||||
<ColorInput label={_('Text Color')} value={darkTextColor} onChange={setDarkTextColor} />
|
||||
|
||||
<ColorInput
|
||||
label={_('Background Color')}
|
||||
value={darkBackgroundColor}
|
||||
onChange={setDarkBackgroundColor}
|
||||
/>
|
||||
|
||||
<ColorInput
|
||||
label={_('Link Color')}
|
||||
value={darkPrimaryColor}
|
||||
onChange={setDarkPrimaryColor}
|
||||
/>
|
||||
|
||||
<ThemePreview
|
||||
textColor={darkTextColor}
|
||||
backgroundColor={darkBackgroundColor}
|
||||
primaryColor={darkPrimaryColor}
|
||||
label={_('Preview')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeEditor;
|
||||
Reference in New Issue
Block a user