forked from akai/readest
feat: custom css for the reader UI (#1466)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MdOutlineAutoMode } from 'react-icons/md';
|
||||
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';
|
||||
@@ -10,6 +11,7 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { isCJKEnv } from '@/utils/misc';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { lockScreenOrientation } from '@/utils/bridge';
|
||||
import { getBookDirFromWritingMode, getBookLangCode } from '@/utils/book';
|
||||
import { MIGHT_BE_RTL_LANGS, RELOAD_BEFREE_SAVED_TIMEOUT_MS } from '@/services/constants';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
@@ -17,7 +19,7 @@ import NumberInput from './NumberInput';
|
||||
|
||||
const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getView, getViewSettings, getGridInsets, setViewSettings } = useReaderStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const view = getView(bookKey);
|
||||
@@ -62,6 +64,8 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const [showRemainingTime, setShowRemainingTime] = useState(viewSettings.showRemainingTime!);
|
||||
const [showPageNumber, setShowPageNumber] = useState(viewSettings.showPageNumber!);
|
||||
|
||||
const [screenOrientation, setScreenOrientation] = useState(viewSettings.screenOrientation!);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'paragraphMargin', paragraphMargin);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -297,6 +301,14 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// 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');
|
||||
@@ -591,6 +603,47 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appService?.isMobileApp && (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,70 +1,98 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { IoPhoneLandscapeOutline, IoPhonePortraitOutline } from 'react-icons/io5';
|
||||
import { MdOutlineScreenRotation } from 'react-icons/md';
|
||||
import cssbeautify from 'cssbeautify';
|
||||
import React, { 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 { getStyles } from '@/utils/style';
|
||||
import { lockScreenOrientation } from '@/utils/bridge';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import cssbeautify from 'cssbeautify';
|
||||
import cssValidate from '@/utils/css';
|
||||
|
||||
type CSSType = 'book' | 'reader';
|
||||
|
||||
const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { appService } = useEnv();
|
||||
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
|
||||
const { getView, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
|
||||
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
|
||||
const [screenOrientation, setScreenOrientation] = useState(viewSettings.screenOrientation!);
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [inputFocusInAndroid, setInputFocusInAndroid] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const contentTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const uiTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const handleUserStylesheetChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const cssInput = e.target.value;
|
||||
setDraftStylesheet(cssInput);
|
||||
setDraftStylesheetSaved(false);
|
||||
const validateCSS = (cssInput: string): { isValid: boolean; error?: string } => {
|
||||
if (!cssInput.trim()) return { isValid: true };
|
||||
|
||||
try {
|
||||
const { isValid, error } = cssValidate(cssInput);
|
||||
if (cssInput && !isValid) {
|
||||
throw new Error(error || 'Invalid CSS');
|
||||
if (!isValid) {
|
||||
return { isValid: false, error: error || 'Invalid CSS' };
|
||||
}
|
||||
setError(null);
|
||||
return { isValid: true };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Invalid CSS: Please check your input.');
|
||||
return { isValid: false, error: err.message };
|
||||
}
|
||||
console.log('CSS Error:', err);
|
||||
return { isValid: false, error: 'Invalid CSS: Please check your input.' };
|
||||
}
|
||||
};
|
||||
|
||||
const applyStyles = () => {
|
||||
const formattedCSS = cssbeautify(draftStylesheet, {
|
||||
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) => {
|
||||
const cssInput = type === 'book' ? draftContentStylesheet : draftUIStylesheet;
|
||||
const formattedCSS = cssbeautify(cssInput, {
|
||||
indent: ' ',
|
||||
openbrace: 'end-of-line',
|
||||
autosemicolon: true,
|
||||
});
|
||||
|
||||
setDraftStylesheet(formattedCSS);
|
||||
setDraftStylesheetSaved(true);
|
||||
viewSettings.userStylesheet = formattedCSS;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
if (type === 'book') {
|
||||
setDraftContentStylesheet(formattedCSS);
|
||||
setDraftContentStylesheetSaved(true);
|
||||
viewSettings.userStylesheet = formattedCSS;
|
||||
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.userStylesheet = formattedCSS;
|
||||
setSettings(settings);
|
||||
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));
|
||||
};
|
||||
|
||||
@@ -73,7 +101,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
const handleInputFocus = () => {
|
||||
const handleInputFocus = (textareaRef: React.RefObject<HTMLTextAreaElement>) => {
|
||||
if (appService?.isAndroidApp) {
|
||||
setInputFocusInAndroid(true);
|
||||
}
|
||||
@@ -93,13 +121,53 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'screenOrientation', screenOrientation, false, false);
|
||||
if (appService?.isMobileApp) {
|
||||
lockScreenOrientation({ orientation: screenOrientation });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [screenOrientation]);
|
||||
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'>{_(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
|
||||
@@ -108,84 +176,25 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
inputFocusInAndroid && 'h-[50%] overflow-y-auto pb-[200px]',
|
||||
)}
|
||||
>
|
||||
{appService?.isMobileApp && (
|
||||
<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>
|
||||
{renderCSSEditor(
|
||||
'book',
|
||||
_('Custom Content CSS'),
|
||||
_('Enter CSS for book content styling...'),
|
||||
draftContentStylesheet,
|
||||
contentError,
|
||||
draftContentStylesheetSaved,
|
||||
contentTextareaRef,
|
||||
)}
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Custom CSS')}</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={_('Enter your custom CSS here...')}
|
||||
spellCheck='false'
|
||||
value={draftStylesheet}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleInput}
|
||||
onKeyUp={handleInput}
|
||||
onChange={handleUserStylesheetChange}
|
||||
/>
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-ghost bg-base-200 absolute bottom-2 right-4 h-8 min-h-8 px-4 py-2',
|
||||
draftStylesheetSaved ? 'hidden' : '',
|
||||
error ? 'btn-disabled' : '',
|
||||
)}
|
||||
onClick={applyStyles}
|
||||
disabled={!!error}
|
||||
>
|
||||
{_('Apply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className='mt-1 text-sm text-red-500'>{error}</p>}
|
||||
</div>
|
||||
{renderCSSEditor(
|
||||
'reader',
|
||||
_('Custom Reader UI CSS'),
|
||||
_('Enter CSS for reader interface styling...'),
|
||||
draftUIStylesheet,
|
||||
uiError,
|
||||
draftUIStylesheetSaved,
|
||||
uiTextareaRef,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user