feat: add options to customize the style of highlighted text of current sentence of TTS (#2358)
This commit is contained in:
@@ -1,9 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { MdOutlineLightMode, MdOutlineDarkMode, MdClose, MdAdd } 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,
|
||||
@@ -17,48 +12,55 @@ 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 { useCustomTextureStore } from '@/store/customTextureStore';
|
||||
import { saveViewSettings } from '@/helpers/settings';
|
||||
import { CODE_LANGUAGES, CodeLanguage, manageSyntaxHighlighting } from '@/utils/highlightjs';
|
||||
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
|
||||
import { SettingsPanelPanelProp } from './SettingsDialog';
|
||||
import { useFileSelector } from '@/hooks/useFileSelector';
|
||||
import { PREDEFINED_TEXTURES } from '@/styles/textures';
|
||||
import { HighlightColor } from '@/types/book';
|
||||
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
|
||||
import Select from '@/components/Select';
|
||||
import ThemeEditor from './ThemeEditor';
|
||||
import ColorInput from './ColorInput';
|
||||
import ThemeEditor from './color/ThemeEditor';
|
||||
import ThemeModeSelector from './color/ThemeModeSelector';
|
||||
import ThemeColorSelector from './color/ThemeColorSelector';
|
||||
import BackgroundTextureSelector from './color/BackgroundTextureSelector';
|
||||
import HighlightColorsEditor from './color/HighlightColorsEditor';
|
||||
import TTSHighlightStyleEditor, { TTSHighlightStyle } from './color/TTSHighlightStyleEditor';
|
||||
import CodeHighlightingSettings from './color/CodeHighlightingSettings';
|
||||
|
||||
const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
|
||||
const _ = useTranslation();
|
||||
const { themeMode, themeColor, isDarkMode, setThemeMode, setThemeColor, saveCustomTheme } =
|
||||
useThemeStore();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
|
||||
|
||||
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 [selectedTextureId, setSelectedTextureId] = useState(viewSettings.backgroundTextureId);
|
||||
const [backgroundOpacity, setBackgroundOpacity] = useState(viewSettings.backgroundOpacity);
|
||||
const [backgroundSize, setBackgroundSize] = useState(viewSettings.backgroundSize);
|
||||
|
||||
const [ttsHighlightStyle, setTtsHighlightStyle] = useState(
|
||||
viewSettings.ttsHighlightOptions.style,
|
||||
);
|
||||
const [ttsHighlightColor, setTtsHighlightColor] = useState(
|
||||
viewSettings.ttsHighlightOptions.color,
|
||||
);
|
||||
const [customHighlightColors, setCustomHighlightColors] = useState(
|
||||
settings.globalReadSettings.customHighlightColors,
|
||||
);
|
||||
const [customTtsHighlightColors, setCustomTtsHighlightColors] = useState(
|
||||
settings.globalReadSettings.customTtsHighlightColors || [],
|
||||
);
|
||||
|
||||
const {
|
||||
textures: customTextures,
|
||||
@@ -70,7 +72,6 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
saveCustomTextures,
|
||||
} = useCustomTextureStore();
|
||||
const resetToDefaults = useResetViewSettings();
|
||||
|
||||
const { selectFiles } = useFileSelector(appService, _);
|
||||
|
||||
const handleReset = () => {
|
||||
@@ -110,7 +111,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
}, [overrideColor]);
|
||||
|
||||
useEffect(() => {
|
||||
let update = false; // check if we need to update syntax highlighting
|
||||
let update = false;
|
||||
if (codeHighlighting !== viewSettings.codeHighlighting) {
|
||||
saveViewSettings(envConfig, bookKey, 'codeHighlighting', codeHighlighting);
|
||||
update = true;
|
||||
@@ -172,7 +173,6 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
const handleSaveCustomTheme = (customTheme: CustomTheme) => {
|
||||
applyCustomTheme(customTheme);
|
||||
saveCustomTheme(envConfig, settings, customTheme);
|
||||
|
||||
setSettings({ ...settings });
|
||||
setThemeColor(customTheme.name);
|
||||
setShowCustomThemeEditor(false);
|
||||
@@ -180,7 +180,6 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
|
||||
const handleDeleteCustomTheme = (customTheme: CustomTheme) => {
|
||||
saveCustomTheme(envConfig, settings, customTheme, true);
|
||||
|
||||
setSettings({ ...settings });
|
||||
setThemeColor('default');
|
||||
setShowCustomThemeEditor(false);
|
||||
@@ -224,7 +223,35 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
saveCustomTextures(envConfig);
|
||||
};
|
||||
|
||||
const allTextures = [...PREDEFINED_TEXTURES, ...customTextures.filter((t) => !t.deletedAt)];
|
||||
const handleHighlightColorsChange = (colors: typeof customHighlightColors) => {
|
||||
setCustomHighlightColors(colors);
|
||||
settings.globalReadSettings.customHighlightColors = colors;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
};
|
||||
|
||||
const handleTTSStyleChange = (style: TTSHighlightStyle) => {
|
||||
setTtsHighlightStyle(style);
|
||||
saveViewSettings(envConfig, bookKey, 'ttsHighlightOptions', {
|
||||
style,
|
||||
color: ttsHighlightColor,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTTSColorChange = (color: string) => {
|
||||
setTtsHighlightColor(color);
|
||||
saveViewSettings(envConfig, bookKey, 'ttsHighlightOptions', {
|
||||
style: ttsHighlightStyle,
|
||||
color,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCustomTtsColorsChange = (colors: string[]) => {
|
||||
setCustomTtsHighlightColors(colors);
|
||||
settings.globalReadSettings.customTtsHighlightColors = colors;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
@@ -237,32 +264,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
<ThemeModeSelector themeMode={themeMode} onThemeModeChange={setThemeMode} />
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Invert Image In Dark Mode')}</h2>
|
||||
@@ -285,226 +287,48 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
/>
|
||||
</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>
|
||||
<ThemeColorSelector
|
||||
themes={themes.concat(customThemes)}
|
||||
themeColor={themeColor}
|
||||
isDarkMode={isDarkMode}
|
||||
onThemeColorChange={setThemeColor}
|
||||
onEditTheme={handleEditTheme}
|
||||
onCreateTheme={() => setShowCustomThemeEditor(true)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 className='mb-2 font-medium'>{_('Background Image')}</h2>
|
||||
<div className='mb-4 grid grid-cols-2 gap-4'>
|
||||
{allTextures.map((texture) => (
|
||||
<button
|
||||
key={texture.id}
|
||||
onClick={() => setSelectedTextureId(texture.id)}
|
||||
className={`bg-base-100 relative flex flex-col items-center justify-center rounded-lg border-2 p-4 shadow-md transition-all ${
|
||||
selectedTextureId === texture.id
|
||||
? 'ring-2 ring-indigo-500 ring-offset-2'
|
||||
: 'border-base-300'
|
||||
}`}
|
||||
style={{
|
||||
backgroundImage: texture.loaded
|
||||
? `url("${texture.blobUrl || texture.url}")`
|
||||
: 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'top',
|
||||
minHeight: '80px',
|
||||
}}
|
||||
>
|
||||
{selectedTextureId === texture.id && (
|
||||
<MdRadioButtonChecked
|
||||
size={iconSize24}
|
||||
className='absolute right-2 top-2 rounded-full bg-white text-indigo-500'
|
||||
/>
|
||||
)}
|
||||
{!PREDEFINED_TEXTURES.find((t) => t.id === texture.id) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteCustomTexture(texture.id);
|
||||
}}
|
||||
className='absolute left-2 top-2 rounded-full bg-red-500 p-1 text-white transition-colors hover:bg-red-600'
|
||||
title={_('Delete')}
|
||||
>
|
||||
<MdClose size={16} />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<BackgroundTextureSelector
|
||||
predefinedTextures={PREDEFINED_TEXTURES}
|
||||
customTextures={customTextures.filter((t) => !t.deletedAt)}
|
||||
selectedTextureId={selectedTextureId}
|
||||
backgroundOpacity={backgroundOpacity}
|
||||
backgroundSize={backgroundSize}
|
||||
onTextureSelect={setSelectedTextureId}
|
||||
onOpacityChange={setBackgroundOpacity}
|
||||
onSizeChange={setBackgroundSize}
|
||||
onImportImage={handleImportImage}
|
||||
onDeleteTexture={handleDeleteCustomTexture}
|
||||
/>
|
||||
|
||||
{/* Custom Image Upload */}
|
||||
<div
|
||||
className={`border-base-300 relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 shadow-md transition-all`}
|
||||
style={{ minHeight: '80px' }}
|
||||
>
|
||||
<button
|
||||
className='card-body flex cursor-pointer items-center justify-center p-2 text-center'
|
||||
onClick={handleImportImage}
|
||||
>
|
||||
<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 Image')}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<HighlightColorsEditor
|
||||
customHighlightColors={customHighlightColors}
|
||||
onChange={handleHighlightColorsChange}
|
||||
/>
|
||||
|
||||
{/* Background Image Settings */}
|
||||
{selectedTextureId !== 'none' && (
|
||||
<div className='card border-base-200 bg-base-100 space-y-4 border p-4 shadow'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-sm font-medium'>{_('Opacity')}</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<input
|
||||
type='range'
|
||||
min='0'
|
||||
max='1'
|
||||
step='0.05'
|
||||
value={backgroundOpacity}
|
||||
onChange={(e) => setBackgroundOpacity(parseFloat(e.target.value))}
|
||||
className='range range-sm w-32'
|
||||
/>
|
||||
<span className='w-12 text-right text-sm'>
|
||||
{Math.round(backgroundOpacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<TTSHighlightStyleEditor
|
||||
style={ttsHighlightStyle}
|
||||
color={ttsHighlightColor}
|
||||
customColors={customTtsHighlightColors}
|
||||
onStyleChange={handleTTSStyleChange}
|
||||
onColorChange={handleTTSColorChange}
|
||||
onCustomColorsChange={handleCustomTtsColorsChange}
|
||||
/>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-sm font-medium'>{_('Size')}</span>
|
||||
<Select
|
||||
value={backgroundSize}
|
||||
onChange={(e) => setBackgroundSize(e.target.value)}
|
||||
options={[
|
||||
{ value: 'auto', label: _('Auto') },
|
||||
{ value: 'cover', label: _('Cover') },
|
||||
{ value: 'contain', label: _('Contain') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className='mb-2 font-medium'>{_('Highlight Colors')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 overflow-visible border p-4 shadow'>
|
||||
<div className='grid grid-cols-3 gap-3 sm:grid-cols-5'>
|
||||
{(['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[]).map(
|
||||
(color, index, array) => {
|
||||
const position =
|
||||
index === 0 ? 'left' : index === array.length - 1 ? 'right' : 'center';
|
||||
return (
|
||||
<div key={color} className='flex flex-col items-center gap-2'>
|
||||
<div
|
||||
className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'
|
||||
style={{ backgroundColor: customHighlightColors[color] }}
|
||||
/>
|
||||
<ColorInput
|
||||
label=''
|
||||
value={customHighlightColors[color]}
|
||||
compact={true}
|
||||
pickerPosition={position}
|
||||
onChange={(value: string) => {
|
||||
customHighlightColors[color] = value;
|
||||
setCustomHighlightColors({ ...customHighlightColors });
|
||||
settings.globalReadSettings.customHighlightColors =
|
||||
customHighlightColors;
|
||||
setSettings(settings);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
<CodeHighlightingSettings
|
||||
codeHighlighting={codeHighlighting}
|
||||
codeLanguage={codeLanguage}
|
||||
onToggle={setcodeHighlighting}
|
||||
onLanguageChange={setCodeLanguage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import { MdRadioButtonChecked, MdClose, MdAdd } from 'react-icons/md';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import Select from '@/components/Select';
|
||||
|
||||
interface Texture {
|
||||
id: string;
|
||||
url?: string;
|
||||
blobUrl?: string;
|
||||
loaded?: boolean;
|
||||
}
|
||||
|
||||
interface BackgroundTextureSelectorProps {
|
||||
predefinedTextures: Texture[];
|
||||
customTextures: Texture[];
|
||||
selectedTextureId: string;
|
||||
backgroundOpacity: number;
|
||||
backgroundSize: string;
|
||||
onTextureSelect: (id: string) => void;
|
||||
onOpacityChange: (opacity: number) => void;
|
||||
onSizeChange: (size: string) => void;
|
||||
onImportImage: () => void;
|
||||
onDeleteTexture: (id: string) => void;
|
||||
}
|
||||
|
||||
const BackgroundTextureSelector: React.FC<BackgroundTextureSelectorProps> = ({
|
||||
predefinedTextures,
|
||||
customTextures,
|
||||
selectedTextureId,
|
||||
backgroundOpacity,
|
||||
backgroundSize,
|
||||
onTextureSelect,
|
||||
onOpacityChange,
|
||||
onSizeChange,
|
||||
onImportImage,
|
||||
onDeleteTexture,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const iconSize24 = useResponsiveSize(24);
|
||||
|
||||
const allTextures = [...predefinedTextures, ...customTextures];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className='mb-2 font-medium'>{_('Background Image')}</h2>
|
||||
<div className='mb-4 grid grid-cols-2 gap-4'>
|
||||
{allTextures.map((texture) => (
|
||||
<button
|
||||
key={texture.id}
|
||||
onClick={() => onTextureSelect(texture.id)}
|
||||
className={`bg-base-100 relative flex flex-col items-center justify-center rounded-lg border-2 p-4 shadow-md transition-all ${
|
||||
selectedTextureId === texture.id
|
||||
? 'ring-2 ring-indigo-500 ring-offset-2'
|
||||
: 'border-base-300'
|
||||
}`}
|
||||
style={{
|
||||
backgroundImage: texture.loaded ? `url("${texture.blobUrl || texture.url}")` : 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'top',
|
||||
minHeight: '80px',
|
||||
}}
|
||||
>
|
||||
{selectedTextureId === texture.id && (
|
||||
<MdRadioButtonChecked
|
||||
size={iconSize24}
|
||||
className='absolute right-2 top-2 rounded-full bg-white text-indigo-500'
|
||||
/>
|
||||
)}
|
||||
{!predefinedTextures.find((t) => t.id === texture.id) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteTexture(texture.id);
|
||||
}}
|
||||
className='absolute left-2 top-2 rounded-full bg-red-500 p-1 text-white transition-colors hover:bg-red-600'
|
||||
title={_('Delete')}
|
||||
>
|
||||
<MdClose size={16} />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Custom Image Upload */}
|
||||
<div
|
||||
className='border-base-300 relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 shadow-md transition-all'
|
||||
style={{ minHeight: '80px' }}
|
||||
>
|
||||
<button
|
||||
className='card-body flex cursor-pointer items-center justify-center p-2 text-center'
|
||||
onClick={onImportImage}
|
||||
>
|
||||
<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 Image')}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background Image Settings */}
|
||||
{selectedTextureId !== 'none' && (
|
||||
<div className='card border-base-200 bg-base-100 space-y-4 border p-4 shadow'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-sm font-medium'>{_('Opacity')}</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<input
|
||||
type='range'
|
||||
min='0'
|
||||
max='1'
|
||||
step='0.05'
|
||||
value={backgroundOpacity}
|
||||
onChange={(e) => onOpacityChange(parseFloat(e.target.value))}
|
||||
className='range range-sm w-32'
|
||||
/>
|
||||
<span className='w-12 text-right text-sm'>
|
||||
{Math.round(backgroundOpacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-sm font-medium'>{_('Size')}</span>
|
||||
<Select
|
||||
value={backgroundSize}
|
||||
onChange={(e) => onSizeChange(e.target.value)}
|
||||
options={[
|
||||
{ value: 'auto', label: _('Auto') },
|
||||
{ value: 'cover', label: _('Cover') },
|
||||
{ value: 'contain', label: _('Contain') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackgroundTextureSelector;
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { CODE_LANGUAGES, CodeLanguage } from '@/utils/highlightjs';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import Select from '@/components/Select';
|
||||
|
||||
interface CodeHighlightingSettingsProps {
|
||||
codeHighlighting: boolean;
|
||||
codeLanguage: string;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onLanguageChange: (language: CodeLanguage) => void;
|
||||
}
|
||||
|
||||
const CodeHighlightingSettings: React.FC<CodeHighlightingSettingsProps> = ({
|
||||
codeHighlighting,
|
||||
codeLanguage,
|
||||
onToggle,
|
||||
onLanguageChange,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
|
||||
return (
|
||||
<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>{_('Enable Highlighting')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={codeHighlighting}
|
||||
onChange={() => onToggle(!codeHighlighting)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='config-item'>
|
||||
<span>{_('Code Language')}</span>
|
||||
<Select
|
||||
value={codeLanguage}
|
||||
onChange={(event) => onLanguageChange(event.target.value as CodeLanguage)}
|
||||
options={CODE_LANGUAGES.map((lang) => ({
|
||||
value: lang,
|
||||
label: lang === 'auto-detect' ? _('Auto') : lang,
|
||||
}))}
|
||||
disabled={!codeHighlighting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeHighlightingSettings;
|
||||
+10
-4
@@ -9,7 +9,13 @@ type ColorInputProps = {
|
||||
pickerPosition?: 'left' | 'center' | 'right';
|
||||
};
|
||||
|
||||
const ColorInput: React.FC<ColorInputProps> = ({ label, value, onChange, compact = false, pickerPosition = 'left' }) => {
|
||||
const ColorInput: React.FC<ColorInputProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
compact = false,
|
||||
pickerPosition = 'left',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -53,9 +59,9 @@ const ColorInput: React.FC<ColorInputProps> = ({ label, value, onChange, compact
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={pickerRef}
|
||||
className={`absolute top-full z-50 mt-1 ${getPickerPositionClass()}`}
|
||||
<div
|
||||
ref={pickerRef}
|
||||
className={`absolute top-full z-50 py-1 ${getPickerPositionClass()}`}
|
||||
>
|
||||
<SketchPicker
|
||||
width='200px'
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { HighlightColor } from '@/types/book';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import ColorInput from './ColorInput';
|
||||
|
||||
interface HighlightColorsEditorProps {
|
||||
customHighlightColors: Record<HighlightColor, string>;
|
||||
onChange: (colors: Record<HighlightColor, string>) => void;
|
||||
}
|
||||
|
||||
const HighlightColorsEditor: React.FC<HighlightColorsEditorProps> = ({
|
||||
customHighlightColors,
|
||||
onChange,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
|
||||
const handleColorChange = (color: HighlightColor, value: string) => {
|
||||
const updated = { ...customHighlightColors, [color]: value };
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className='mb-2 font-medium'>{_('Highlight Colors')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 overflow-visible border p-4 shadow'>
|
||||
<div className='grid grid-cols-3 gap-3 sm:grid-cols-5'>
|
||||
{(['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[]).map(
|
||||
(color, index, array) => {
|
||||
const position =
|
||||
index === 0 ? 'left' : index === array.length - 1 ? 'right' : 'center';
|
||||
return (
|
||||
<div key={color} className='flex flex-col items-center gap-2'>
|
||||
<div
|
||||
className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'
|
||||
style={{ backgroundColor: customHighlightColors[color] }}
|
||||
/>
|
||||
<ColorInput
|
||||
label=''
|
||||
value={customHighlightColors[color]}
|
||||
compact={true}
|
||||
pickerPosition={position}
|
||||
onChange={(value: string) => handleColorChange(color, value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HighlightColorsEditor;
|
||||
@@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
import { MdClose } from 'react-icons/md';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import Select from '@/components/Select';
|
||||
import ColorInput from './ColorInput';
|
||||
|
||||
export type TTSHighlightStyle =
|
||||
| 'highlight'
|
||||
| 'underline'
|
||||
| 'strikethrough'
|
||||
| 'squiggly'
|
||||
| 'outline';
|
||||
|
||||
interface TTSHighlightStyleEditorProps {
|
||||
style: TTSHighlightStyle;
|
||||
color: string;
|
||||
customColors: string[];
|
||||
onStyleChange: (style: TTSHighlightStyle) => void;
|
||||
onColorChange: (color: string) => void;
|
||||
onCustomColorsChange: (colors: string[]) => void;
|
||||
}
|
||||
|
||||
const TTSHighlightStyleEditor: React.FC<TTSHighlightStyleEditorProps> = ({
|
||||
style,
|
||||
color,
|
||||
customColors,
|
||||
onStyleChange,
|
||||
onColorChange,
|
||||
onCustomColorsChange,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
|
||||
const defaultQuickColors = [
|
||||
{ color: '#FFD700', label: 'Gold' },
|
||||
{ color: '#00CED1', label: 'Cyan' },
|
||||
{ color: '#FF69B4', label: 'Pink' },
|
||||
{ color: '#90EE90', label: 'Green' },
|
||||
{ color: '#FFA500', label: 'Orange' },
|
||||
];
|
||||
|
||||
const handleAddCustomColor = () => {
|
||||
if (!customColors.includes(color) && !defaultQuickColors.some((c) => c.color === color)) {
|
||||
const updatedColors = [...customColors, color];
|
||||
onCustomColorsChange(updatedColors);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCustomColor = (colorToDelete: string) => {
|
||||
const updatedColors = customColors.filter((c) => c !== colorToDelete);
|
||||
onCustomColorsChange(updatedColors);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('TTS Highlighting')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border p-4 shadow'>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-sm font-medium'>{_('Style')}</span>
|
||||
<Select
|
||||
value={style}
|
||||
onChange={(e) => onStyleChange(e.target.value as TTSHighlightStyle)}
|
||||
options={[
|
||||
{ value: 'highlight', label: _('Highlight') },
|
||||
{ value: 'underline', label: _('Underline') },
|
||||
{ value: 'strikethrough', label: _('Strikethrough') },
|
||||
{ value: 'squiggly', label: _('Squiggly') },
|
||||
{ value: 'outline', label: _('Outline') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-sm font-medium'>{_('Color')}</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className='border-base-300 pointer-events-none h-8 w-8 cursor-pointer rounded-full border-2 shadow-sm'
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<ColorInput
|
||||
label=''
|
||||
value={color}
|
||||
compact={true}
|
||||
pickerPosition='center'
|
||||
onChange={onColorChange}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddCustomColor}
|
||||
disabled={
|
||||
customColors.includes(color) || defaultQuickColors.some((c) => c.color === color)
|
||||
}
|
||||
className='btn btn-ghost btn-sm gap-1 bg-transparent disabled:bg-transparent'
|
||||
title={_('Save Current Color')}
|
||||
>
|
||||
<span className='text-xs'>{_('Save')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='border-base-200 flex justify-between border-t pt-4'>
|
||||
<div className='flex items-center'>
|
||||
<span className='text-sm font-medium'>{_('Quick Colors')}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex max-w-[70%] flex-wrap justify-end gap-2'>
|
||||
{defaultQuickColors.map(({ color: quickColor }) => (
|
||||
<button
|
||||
key={quickColor}
|
||||
onClick={() => onColorChange(quickColor)}
|
||||
className={`h-8 w-8 rounded-full border-2 transition-all hover:scale-110 ${
|
||||
color === quickColor
|
||||
? 'ring-2 ring-indigo-500 ring-offset-2'
|
||||
: 'border-base-300'
|
||||
}`}
|
||||
style={{ backgroundColor: quickColor }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{customColors.map((customColor) => (
|
||||
<div key={customColor} className='group relative'>
|
||||
<button
|
||||
onClick={() => onColorChange(customColor)}
|
||||
className={`h-8 w-8 rounded-full border-2 transition-all hover:scale-110 ${
|
||||
color === customColor
|
||||
? 'ring-2 ring-indigo-500 ring-offset-2'
|
||||
: 'border-base-300'
|
||||
}`}
|
||||
style={{ backgroundColor: customColor }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleDeleteCustomColor(customColor)}
|
||||
className='absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white opacity-0 transition-opacity hover:opacity-100 group-hover:opacity-100'
|
||||
title={_('Delete')}
|
||||
>
|
||||
<MdClose size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TTSHighlightStyleEditor;
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { CgColorPicker } from 'react-icons/cg';
|
||||
import { MdRadioButtonUnchecked, MdRadioButtonChecked } from 'react-icons/md';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { Theme } from '@/styles/themes';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
interface ThemeColorSelectorProps {
|
||||
themes: Theme[];
|
||||
themeColor: string;
|
||||
isDarkMode: boolean;
|
||||
onThemeColorChange: (name: string) => void;
|
||||
onEditTheme: (name: string) => void;
|
||||
onCreateTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeColorSelector: React.FC<ThemeColorSelectorProps> = ({
|
||||
themes,
|
||||
themeColor,
|
||||
isDarkMode,
|
||||
onThemeColorChange,
|
||||
onEditTheme,
|
||||
onCreateTheme,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const iconSize24 = useResponsiveSize(24);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
{themes.map(({ name, label, colors, isCustomizale }) => (
|
||||
<button
|
||||
key={name}
|
||||
tabIndex={0}
|
||||
onClick={() => onThemeColorChange(name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onThemeColorChange(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={() => onThemeColorChange(name)}
|
||||
className='hidden'
|
||||
/>
|
||||
{themeColor === name ? (
|
||||
<MdRadioButtonChecked size={iconSize24} />
|
||||
) : (
|
||||
<MdRadioButtonUnchecked size={iconSize24} />
|
||||
)}
|
||||
<span>{_(label)}</span>
|
||||
{isCustomizale && themeColor === name && (
|
||||
<button onClick={() => onEditTheme(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={onCreateTheme}
|
||||
>
|
||||
<PiPlus size={iconSize24} />
|
||||
<span>{_('Custom')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeColorSelector;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { MdOutlineLightMode, MdOutlineDarkMode } from 'react-icons/md';
|
||||
import { TbSunMoon } from 'react-icons/tb';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
interface ThemeModeSelectorProps {
|
||||
themeMode: 'auto' | 'light' | 'dark';
|
||||
onThemeModeChange: (mode: 'auto' | 'light' | 'dark') => void;
|
||||
}
|
||||
|
||||
const ThemeModeSelector: React.FC<ThemeModeSelectorProps> = ({ themeMode, onThemeModeChange }) => {
|
||||
const _ = useTranslation();
|
||||
|
||||
return (
|
||||
<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={() => onThemeModeChange('auto')}
|
||||
>
|
||||
<TbSunMoon />
|
||||
</button>
|
||||
<button
|
||||
title={_('Light Mode')}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'light' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => onThemeModeChange('light')}
|
||||
>
|
||||
<MdOutlineLightMode />
|
||||
</button>
|
||||
<button
|
||||
title={_('Dark Mode')}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'dark' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => onThemeModeChange('dark')}
|
||||
>
|
||||
<MdOutlineDarkMode />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeModeSelector;
|
||||
Reference in New Issue
Block a user