mirror of
https://github.com/readest/readest.git
synced 2026-05-20 01:01:05 +00:00
refactor(a11y): add basic lint for accessibility (#2021)
Some checks are pending
Deploy to vercel on merge / build_and_deploy (push) Waiting to run
Some checks are pending
Deploy to vercel on merge / build_and_deploy (push) Waiting to run
This commit is contained in:
parent
2571ceede4
commit
9fd152d727
56 changed files with 511 additions and 314 deletions
5
.github/workflows/pull-request.yml
vendored
5
.github/workflows/pull-request.yml
vendored
|
|
@ -72,6 +72,11 @@ jobs:
|
|||
run: |
|
||||
pnpm test -- --watch=false
|
||||
|
||||
- name: run lint
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm lint
|
||||
|
||||
- name: build the web App
|
||||
if: matrix.config.platform == 'web'
|
||||
working-directory: apps/readest-app
|
||||
|
|
|
|||
|
|
@ -1,13 +1,25 @@
|
|||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import js from "@eslint/js";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import path from 'node:path';
|
||||
import js from '@eslint/js';
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
export default [...compat.extends("next/core-web-vitals", "next/typescript")];
|
||||
|
||||
export default [
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||
{
|
||||
plugins: {
|
||||
'jsx-a11y': jsxA11y,
|
||||
},
|
||||
rules: {
|
||||
...jsxA11y.configs.recommended.rules,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@
|
|||
"dotenv-cli": "^7.4.4",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-next": "15.0.3",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"i18next-scanner": "^4.6.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"mkdirp": "^3.0.1",
|
||||
|
|
|
|||
|
|
@ -41,20 +41,9 @@ export const viewport = {
|
|||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html>
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
const themeMode = localStorage.getItem('themeMode');
|
||||
const themeColor = localStorage.getItem('themeColor');
|
||||
if (themeMode && themeColor) {
|
||||
document.documentElement.setAttribute('data-theme', \`\${themeColor}-\${themeMode}\`);
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<meta
|
||||
name='viewport'
|
||||
content='minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no, user-scalable=no, viewport-fit=cover'
|
||||
|
|
|
|||
|
|
@ -330,16 +330,15 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
|||
/>
|
||||
))}
|
||||
{viewMode === 'grid' && !navBooksGroup && allBookshelfItems.length > 0 && (
|
||||
<div
|
||||
<button
|
||||
className={clsx(
|
||||
'border-1 bg-base-100 hover:bg-base-300/50 flex items-center justify-center',
|
||||
'mx-0 my-4 aspect-[28/41] sm:mx-4',
|
||||
)}
|
||||
role='button'
|
||||
onClick={handleImportBooks}
|
||||
>
|
||||
<PiPlus className='size-10' color='gray' />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loading && (
|
||||
|
|
|
|||
|
|
@ -150,29 +150,26 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
|
|||
<h2 className='text-center text-lg font-bold'>{_('Group Books')}</h2>
|
||||
<div className={clsx('mt-4 grid grid-cols-1 gap-2 text-base md:grid-cols-2')}>
|
||||
{isSelectedBooksHasGroup && (
|
||||
<div
|
||||
<button
|
||||
onClick={handleRemoveFromGroup}
|
||||
role='button'
|
||||
className='flex items-center space-x-2 p-2 text-blue-500'
|
||||
>
|
||||
<HiOutlineFolderRemove size={iconSize} />
|
||||
<span>{_('Remove From Group')}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
<button
|
||||
onClick={handleCreateGroup}
|
||||
role='button'
|
||||
className='flex items-center space-x-2 p-2 text-blue-500'
|
||||
>
|
||||
<HiOutlineFolderAdd size={iconSize} />
|
||||
<span>{_('Create New Group')}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{showInput && (
|
||||
<div className='mt-4 flex items-center gap-2'>
|
||||
<input
|
||||
type='text'
|
||||
autoFocus
|
||||
ref={editorRef}
|
||||
value={editGroupName}
|
||||
onChange={(e) => setEditGroupName(e.target.value)}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
|||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsAlwaysShowStatusBar(settings.alwaysShowStatusBar);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const toggleAutoUploadBooks = () => {
|
||||
|
|
@ -169,7 +168,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
|||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'settings-menu dropdown-content no-triangle border-base-100',
|
||||
'z-20 mt-2 max-w-[90vw] shadow-2xl',
|
||||
|
|
@ -191,10 +189,10 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
|||
)
|
||||
}
|
||||
>
|
||||
<ul>
|
||||
<div onClick={handleUserProfile} className='cursor-pointer'>
|
||||
<ul className='flex flex-col'>
|
||||
<button onClick={handleUserProfile} className='w-full'>
|
||||
<Quota quotas={quotas} labelClassName='h-10 pl-3 pr-2' />
|
||||
</div>
|
||||
</button>
|
||||
<MenuItem label={_('Account')} noIcon onClick={handleUserProfile} />
|
||||
</ul>
|
||||
</MenuItem>
|
||||
|
|
|
|||
|
|
@ -92,10 +92,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
|
||||
>
|
||||
<div className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'>
|
||||
{viewOptions.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import { lockScreenOrientation } from '@/utils/bridge';
|
|||
import { useTextTranslation } from '../hooks/useTextTranslation';
|
||||
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
|
||||
import { getViewInsets } from '@/utils/insets';
|
||||
import { removeTabIndex } from '@/utils/a11y';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import KOSyncConflictResolver from './KOSyncResolver';
|
||||
|
||||
|
|
@ -160,6 +161,8 @@ const FoliateViewer: React.FC<{
|
|||
|
||||
applyImageStyle(detail.doc);
|
||||
|
||||
removeTabIndex(detail.doc);
|
||||
|
||||
// Inline scripts in tauri platforms are not executed by default
|
||||
if (viewSettings.allowScript && isTauriAppPlatform()) {
|
||||
evalInlineScripts(detail.doc);
|
||||
|
|
|
|||
|
|
@ -173,17 +173,20 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
|||
return (
|
||||
<>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'absolute bottom-0 left-0 z-10 hidden w-full sm:flex sm:h-[52px]',
|
||||
// show scroll bar when vertical and scrolled in desktop
|
||||
viewSettings?.vertical && viewSettings?.scrolled && 'sm:!bottom-3 sm:!h-7',
|
||||
)}
|
||||
onFocus={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
|
||||
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
|
||||
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'footer-bar shadow-xs bottom-0 z-30 flex w-full flex-col',
|
||||
'footer-bar shadow-xs bottom-0 z-10 flex w-full flex-col',
|
||||
'sm:h-[52px] sm:justify-center',
|
||||
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
|
||||
'transition-[opacity,transform] duration-300',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { mountAdditionalFonts } from '@/styles/fonts';
|
|||
import { eventDispatcher } from '@/utils/event';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { isCJKLang } from '@/utils/lang';
|
||||
import { Overlay } from '@/components/Overlay';
|
||||
import Popup from '@/components/Popup';
|
||||
|
||||
interface FootnotePopupProps {
|
||||
|
|
@ -226,13 +227,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{showPopup && (
|
||||
<div
|
||||
className='fixed inset-0'
|
||||
onClick={handleDismissPopup}
|
||||
onContextMenu={handleDismissPopup}
|
||||
/>
|
||||
)}
|
||||
{showPopup && <Overlay onDismiss={handleDismissPopup} />}
|
||||
<Popup
|
||||
width={responsiveWidth}
|
||||
height={responsiveHeight}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,10 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
|||
}}
|
||||
>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className={clsx('absolute top-0 z-10 h-11 w-full')}
|
||||
onFocus={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
|
||||
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
|
||||
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -39,12 +39,12 @@ Z-Index Layering Guide:
|
|||
• Floats above the content but below global dialogs.
|
||||
40 – TTS Bar
|
||||
• Mini controls for TTS playback on top of the TTS Control.
|
||||
30 – Footbar (TTS Control)
|
||||
• Persistent bottom controls and the TTS icon/panel.
|
||||
30 – TTS Control
|
||||
• Persistent TTS icon/panel.
|
||||
20 – Menu / Sidebar / Notebook (Pinned)
|
||||
• Docked navigation or note views.
|
||||
10 – Headerbar / Ribbon
|
||||
• Top toolbar and ribbon elements.
|
||||
10 – Headerbar / Footbar / Ribbon
|
||||
• Top toolbar, bottom footbar and ribbon elements.
|
||||
0 – Base Content
|
||||
• Main reading area or background content.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -146,7 +146,6 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'view-menu dropdown-content dropdown-right no-triangle z-20 mt-1 border',
|
||||
'bgcolor-base-200 border-base-200 shadow-2xl',
|
||||
|
|
|
|||
|
|
@ -109,7 +109,6 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
|
|||
onSave={handleSaveNote}
|
||||
onEscape={handleEscape}
|
||||
placeholder={_('Add your notes here...')}
|
||||
autoFocus={true}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ import { useNotebookStore } from '@/store/notebookStore';
|
|||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useDrag } from '@/hooks/useDrag';
|
||||
import { DragKey, useDrag } from '@/hooks/useDrag';
|
||||
import { TextSelection } from '@/utils/sel';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { uniqueId } from '@/utils/misc';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getBookDirFromLanguage } from '@/utils/book';
|
||||
import { Overlay } from '@/components/Overlay';
|
||||
import BooknoteItem from '../sidebar/BooknoteItem';
|
||||
import NotebookHeader from './Header';
|
||||
import NoteEditor from './NoteEditor';
|
||||
|
|
@ -33,7 +34,8 @@ const Notebook: React.FC = ({}) => {
|
|||
const { notebookNewAnnotation, notebookEditAnnotation, setNotebookPin } = useNotebookStore();
|
||||
const { getBookData, getConfig, saveConfig, updateBooknotes } = useBookDataStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { setNotebookWidth, setNotebookVisible, toggleNotebookPin } = useNotebookStore();
|
||||
const { getNotebookWidth, setNotebookWidth, setNotebookVisible, toggleNotebookPin } =
|
||||
useNotebookStore();
|
||||
const { setNotebookNewAnnotation, setNotebookEditAnnotation } = useNotebookStore();
|
||||
|
||||
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
|
||||
|
|
@ -79,9 +81,7 @@ const Notebook: React.FC = ({}) => {
|
|||
settings.globalReadSettings.isNotebookPinned = !isNotebookPinned;
|
||||
};
|
||||
|
||||
const handleClickOverlay = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const handleClickOverlay = () => {
|
||||
setNotebookVisible(false);
|
||||
setNotebookNewAnnotation(null);
|
||||
setNotebookEditAnnotation(null);
|
||||
|
|
@ -138,7 +138,19 @@ const Notebook: React.FC = ({}) => {
|
|||
handleNotebookResize(`${Math.round(newWidth * 10000) / 100}%`);
|
||||
};
|
||||
|
||||
const { handleDragStart } = useDrag(onDragMove);
|
||||
const onDragKeyDown = (data: { key: DragKey; step: number }) => {
|
||||
const currentWidth = parseFloat(getNotebookWidth()) / 100;
|
||||
let newWidth = currentWidth;
|
||||
|
||||
if (data.key === 'ArrowLeft') {
|
||||
newWidth = Math.max(MIN_NOTEBOOK_WIDTH, currentWidth + data.step);
|
||||
} else if (data.key === 'ArrowRight') {
|
||||
newWidth = Math.min(MAX_NOTEBOOK_WIDTH, currentWidth - data.step);
|
||||
}
|
||||
handleNotebookResize(`${Math.round(newWidth * 10000) / 100}%`);
|
||||
};
|
||||
|
||||
const { handleDragStart, handleDragKeyDown } = useDrag(onDragMove, onDragKeyDown);
|
||||
|
||||
const config = getConfig(sideBarBookKey);
|
||||
const { booknotes: allNotes = [] } = config || {};
|
||||
|
|
@ -189,7 +201,7 @@ const Notebook: React.FC = ({}) => {
|
|||
return isNotebookVisible ? (
|
||||
<>
|
||||
{!isNotebookPinned && (
|
||||
<div className='overlay fixed inset-0 z-[45] bg-black/20' onClick={handleClickOverlay} />
|
||||
<Overlay className='z-[45] bg-black/20' onDismiss={handleClickOverlay} />
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
|
|
@ -216,9 +228,15 @@ const Notebook: React.FC = ({}) => {
|
|||
}
|
||||
`}</style>
|
||||
<div
|
||||
className='drag-bar absolute left-0 top-0 -m-2 h-full w-0.5 cursor-col-resize bg-transparent p-2'
|
||||
className='drag-bar absolute -left-2 top-0 h-full w-0.5 cursor-col-resize bg-transparent p-2'
|
||||
role='slider'
|
||||
tabIndex={0}
|
||||
aria-label={_('Resize Notebook')}
|
||||
aria-orientation='horizontal'
|
||||
aria-valuenow={parseFloat(notebookWidth)}
|
||||
onMouseDown={handleDragStart}
|
||||
onTouchStart={handleDragStart}
|
||||
onKeyDown={handleDragKeyDown}
|
||||
/>
|
||||
<div className='flex-shrink-0'>
|
||||
<NotebookHeader
|
||||
|
|
@ -263,7 +281,13 @@ const Notebook: React.FC = ({}) => {
|
|||
{filteredExcerptNotes.map((item, index) => (
|
||||
<li key={`${index}-${item.id}`} className='my-2'>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
handleEditNote(item, true);
|
||||
}
|
||||
}}
|
||||
className='collapse-arrow border-base-300 bg-base-100 collapse border'
|
||||
>
|
||||
<div
|
||||
|
|
@ -283,9 +307,11 @@ const Notebook: React.FC = ({}) => {
|
|||
<div className='collapse-content font-size-xs select-text px-3 pb-0'>
|
||||
<p className='hyphens-auto text-justify'>{item.text}</p>
|
||||
<div className='flex justify-end' dir='ltr'>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions*/}
|
||||
<div
|
||||
className='font-size-xs cursor-pointer align-bottom text-red-500 hover:text-red-600'
|
||||
onClick={handleEditNote.bind(null, item, true)}
|
||||
aria-label={_('Delete')}
|
||||
>
|
||||
{_('Delete')}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ const ColorInput: React.FC<ColorInputProps> = ({ label, value, onChange }) => {
|
|||
<div className='mb-3'>
|
||||
<label className='mb-1 block text-sm font-medium'>{label}</label>
|
||||
<div className='flex items-center'>
|
||||
<div
|
||||
className='border-base-200 relative mr-2 flex h-7 w-8 cursor-pointer items-center justify-center overflow-hidden rounded border'
|
||||
<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)}
|
||||
/>
|
||||
|
|
@ -44,7 +44,7 @@ const ColorInput: React.FC<ColorInputProps> = ({ label, value, onChange }) => {
|
|||
value={value}
|
||||
spellCheck={false}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className='bg-base-100 text-base-content border-base-200 min-w-4 max-w-36 flex-1 rounded border p-1 font-mono text-sm'
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -200,8 +200,16 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
|||
<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 }) => (
|
||||
<label
|
||||
<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' : ''
|
||||
}`}
|
||||
|
|
@ -213,6 +221,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
|||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={_(label)}
|
||||
type='radio'
|
||||
name='theme'
|
||||
value={name}
|
||||
|
|
@ -231,15 +240,15 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
|||
<CgColorPicker size={iconSize16} className='absolute right-2 top-2' />
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
</button>
|
||||
))}
|
||||
<label
|
||||
<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>
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -141,9 +141,9 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
|
|||
<div className='breadcrumbs py-1'>
|
||||
<ul>
|
||||
<li>
|
||||
<a className='font-semibold' onClick={onBack}>
|
||||
<button className='font-semibold' onClick={onBack}>
|
||||
{_('Font')}
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li className='font-medium'>{_('Custom Fonts')}</li>
|
||||
</ul>
|
||||
|
|
@ -168,7 +168,7 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
|
|||
|
||||
<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'>
|
||||
<div
|
||||
<button
|
||||
className='card-body flex cursor-pointer items-center justify-center p-2 text-center'
|
||||
onClick={handleImportFont}
|
||||
>
|
||||
|
|
@ -180,11 +180,11 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
|
|||
{_('Import Font')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{availableFamilies.map((family) => (
|
||||
<div
|
||||
<button
|
||||
key={family.name}
|
||||
className={clsx(
|
||||
'card h-12 border shadow-sm',
|
||||
|
|
@ -215,7 +215,7 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ const DialogMenu: React.FC<DialogMenuProps> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'dropdown-content dropdown-right no-triangle border-base-200 z-20 mt-1 border shadow-2xl',
|
||||
'text-base sm:text-sm',
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { FiChevronDown } from 'react-icons/fi';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
interface DropDownProps {
|
||||
selected: { option: string; label: string };
|
||||
options: { option: string; label: string }[];
|
||||
onSelect: (option: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
listClassName?: string;
|
||||
}
|
||||
|
||||
const DropDown: React.FC<DropDownProps> = ({
|
||||
selected,
|
||||
options,
|
||||
onSelect,
|
||||
className,
|
||||
listClassName,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const defaultIconSize = useDefaultIconSize();
|
||||
|
||||
return (
|
||||
<div className={clsx('dropdown dropdown-bottom', className)}>
|
||||
<button
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case',
|
||||
disabled && 'btn-disabled',
|
||||
)}
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
>
|
||||
<span>{selected.label}</span>
|
||||
<FiChevronDown size={iconSize16} />
|
||||
</button>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute z-[1] shadow',
|
||||
'menu-vertical right-[-32px] mt-2 inline max-h-80 w-44 overflow-y-scroll sm:right-0',
|
||||
listClassName,
|
||||
)}
|
||||
>
|
||||
{options.map(({ option, label }) => (
|
||||
<li key={option} onClick={() => onSelect(option)}>
|
||||
<div className='flex items-center px-0'>
|
||||
<span style={{ minWidth: `${defaultIconSize}px` }}>
|
||||
{selected.option === option && <MdCheck className='text-base-content' />}
|
||||
</span>
|
||||
<span>{label || option}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropDown;
|
||||
|
|
@ -34,7 +34,19 @@ const FontItem: React.FC<FontItemProps> = ({ index, style, data }) => {
|
|||
const option = options[index]!;
|
||||
|
||||
return (
|
||||
<li className='px-2' key={option.option} style={style} onClick={() => onSelect(option.option)}>
|
||||
<li
|
||||
role='option'
|
||||
aria-selected={selected === option.option}
|
||||
className='px-2'
|
||||
key={option.option}
|
||||
style={style}
|
||||
onClick={() => onSelect(option.option)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onSelect(option.option);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center overflow-hidden !px-0 text-sm'>
|
||||
<span style={{ minWidth: `${iconSize16}px` }}>
|
||||
{selected === option.option && (
|
||||
|
|
@ -115,6 +127,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
|
|||
</div>
|
||||
</button>
|
||||
<ul
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute z-[1] mt-4 shadow',
|
||||
|
|
@ -145,6 +158,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
|
|||
<span>{_('System Fonts')}</span>
|
||||
</div>
|
||||
<ul
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 menu rounded-box relative z-[1] shadow',
|
||||
|
|
|
|||
|
|
@ -72,19 +72,27 @@ const NumberInput: React.FC<NumberInputProps> = ({
|
|||
<input
|
||||
type='text'
|
||||
inputMode='decimal'
|
||||
disabled={disabled}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleOnBlur}
|
||||
className='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'
|
||||
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}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
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}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onClick={increment}
|
||||
className={`btn btn-circle btn-sm ${value >= max || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -41,8 +41,9 @@ const BookCard = ({ book }: { book: Book }) => {
|
|||
<button
|
||||
className='btn btn-ghost hover:bg-base-300 h-6 min-h-6 w-6 rounded-full p-0 transition-colors'
|
||||
aria-label={_('More Info')}
|
||||
onClick={showBookDetails}
|
||||
>
|
||||
<MdInfoOutline size={iconSize18} className='fill-base-content' onClick={showBookDetails} />
|
||||
<MdInfoOutline size={iconSize18} className='fill-base-content' />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -85,11 +85,8 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
|||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const isWebApp = isWebAppPlatform();
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={clsx('book-menu dropdown-content border-base-100 z-20 shadow-2xl', menuClassName)}
|
||||
>
|
||||
<MenuItem
|
||||
|
|
@ -149,7 +146,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
|||
/>
|
||||
<MenuItem label={_('Reload Page')} shortcut='Shift+R' onClick={handleReloadPage} />
|
||||
<hr className='border-base-200 my-1' />
|
||||
{isWebApp && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
|
||||
{isWebAppPlatform() && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
|
||||
<MenuItem label={_('About Readest')} onClick={showAboutReadest} />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
|||
const progress = getProgress(bookKey);
|
||||
const { isCurrent, viewRef } = useScrollToItem(cfi, progress);
|
||||
|
||||
const handleClickItem = (event: React.MouseEvent) => {
|
||||
const handleClickItem = (event: React.MouseEvent | React.KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
eventDispatcher.dispatch('navigate', { bookKey, cfi });
|
||||
|
||||
|
|
@ -108,7 +108,6 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
|||
onChange={(value) => (editorDraftRef.current = value)}
|
||||
onSave={handleSaveBookmark}
|
||||
onEscape={() => setInlineEditMode(false)}
|
||||
autoFocus={true}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -124,14 +123,25 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
|||
|
||||
return (
|
||||
<li
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
|
||||
role='button'
|
||||
ref={viewRef}
|
||||
className={clsx(
|
||||
'border-base-300 content group relative my-2 cursor-pointer rounded-lg p-2',
|
||||
isCurrent ? 'bg-base-300/85 hover:bg-base-300' : 'hover:bg-base-300/55 bg-base-100',
|
||||
isCurrent
|
||||
? 'bg-base-300/85 hover:bg-base-300 focus:bg-base-300'
|
||||
: 'hover:bg-base-300/55 focus:bg-base-300/55 bg-base-100',
|
||||
'transition-all duration-300 ease-in-out',
|
||||
)}
|
||||
tabIndex={0}
|
||||
onClick={handleClickItem}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleClickItem(e);
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx('min-h-4 p-0 transition-all duration-300 ease-in-out')}
|
||||
|
|
@ -175,17 +185,20 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={clsx(
|
||||
'max-h-0 overflow-hidden p-0',
|
||||
'transition-[max-height] duration-300 ease-in-out',
|
||||
'group-hover:max-h-8 group-hover:overflow-visible',
|
||||
'group-focus-within:max-h-8 group-focus-within:overflow-visible',
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--bottom-override': 0,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
// This is needed to prevent the parent onClick from being triggered
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='flex cursor-default items-center justify-between'>
|
||||
|
|
@ -199,7 +212,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
|||
<TextButton
|
||||
onClick={item.type === 'bookmark' ? editBookmark : editNote.bind(null, item)}
|
||||
variant='primary'
|
||||
className='opacity-0 transition duration-300 ease-in-out group-hover:opacity-100'
|
||||
className='opacity-0 transition duration-300 ease-in-out group-focus-within:opacity-100 group-hover:opacity-100'
|
||||
>
|
||||
{_('Edit')}
|
||||
</TextButton>
|
||||
|
|
@ -208,7 +221,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
|||
<TextButton
|
||||
onClick={deleteNote.bind(null, item)}
|
||||
variant='danger'
|
||||
className='opacity-0 transition duration-300 ease-in-out group-hover:opacity-100'
|
||||
className='opacity-0 transition duration-300 ease-in-out group-focus-within:opacity-100 group-hover:opacity-100'
|
||||
>
|
||||
{_('Delete')}
|
||||
</TextButton>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ const SearchOptions: React.FC<SearchOptionsProps> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'book-menu dropdown-content dropdown-center border-base-200 z-20 w-56 border shadow-2xl',
|
||||
menuClassName,
|
||||
|
|
|
|||
|
|
@ -23,12 +23,22 @@ const SearchResultItem: React.FC<SearchResultItemProps> = ({
|
|||
|
||||
return (
|
||||
<li
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
|
||||
role='button'
|
||||
ref={viewRef}
|
||||
className={clsx(
|
||||
'my-2 cursor-pointer rounded-lg p-2 text-sm',
|
||||
isCurrent ? 'bg-base-300 hover:bg-gray-300/70' : 'hover:bg-base-300 bg-base-100',
|
||||
)}
|
||||
tabIndex={0}
|
||||
onClick={() => onSelectResult(cfi)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onSelectResult(cfi);
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='line-clamp-3'>
|
||||
<span className=''>{excerpt.pre}</span>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,26 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { impactFeedback } from '@tauri-apps/plugin-haptics';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { BookSearchResult } from '@/types/book';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getBookDirFromLanguage } from '@/utils/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useDrag } from '@/hooks/useDrag';
|
||||
import { DragKey, useDrag } from '@/hooks/useDrag';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { Overlay } from '@/components/Overlay';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
import SidebarHeader from './Header';
|
||||
import SidebarContent from './Content';
|
||||
import BookCard from './BookCard';
|
||||
import useSidebar from '../../hooks/useSidebar';
|
||||
import SearchBar from './SearchBar';
|
||||
import SearchResults from './SearchResults';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
|
||||
const MIN_SIDEBAR_WIDTH = 0.05;
|
||||
const MAX_SIDEBAR_WIDTH = 0.45;
|
||||
|
|
@ -28,6 +30,7 @@ const VELOCITY_THRESHOLD = 0.5;
|
|||
const SideBar: React.FC<{
|
||||
onGoToLibrary: () => void;
|
||||
}> = ({ onGoToLibrary }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { updateAppTheme, safeAreaInsets } = useThemeStore();
|
||||
const { settings } = useSettingsStore();
|
||||
|
|
@ -37,11 +40,13 @@ const SideBar: React.FC<{
|
|||
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<BookSearchResult[] | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const sidebarHeight = useRef(1.0);
|
||||
const isMobile = window.innerWidth < 640;
|
||||
const {
|
||||
sideBarWidth,
|
||||
isSideBarPinned,
|
||||
isSideBarVisible,
|
||||
getSideBarWidth,
|
||||
setSideBarVisible,
|
||||
handleSideBarResize,
|
||||
handleSideBarTogglePin,
|
||||
|
|
@ -89,6 +94,7 @@ const SideBar: React.FC<{
|
|||
|
||||
const heightFraction = data.clientY / window.innerHeight;
|
||||
const newTop = Math.max(0.0, Math.min(1, heightFraction));
|
||||
sidebarHeight.current = newTop;
|
||||
|
||||
const sidebar = document.querySelector('.sidebar-container') as HTMLElement;
|
||||
const overlay = document.querySelector('.overlay') as HTMLElement;
|
||||
|
|
@ -135,11 +141,29 @@ const SideBar: React.FC<{
|
|||
handleSideBarResize(`${Math.round(newWidth * 10000) / 100}%`);
|
||||
};
|
||||
|
||||
const handleHorizontalDragKeyDown = (data: { key: DragKey; step: number }) => {
|
||||
const currentWidth = parseFloat(getSideBarWidth()) / 100;
|
||||
let newWidth = currentWidth;
|
||||
|
||||
if (data.key === 'ArrowLeft') {
|
||||
newWidth = Math.max(MIN_SIDEBAR_WIDTH, currentWidth - data.step);
|
||||
} else if (data.key === 'ArrowRight') {
|
||||
newWidth = Math.min(MAX_SIDEBAR_WIDTH, currentWidth + data.step);
|
||||
}
|
||||
handleSideBarResize(`${Math.round(newWidth * 10000) / 100}%`);
|
||||
};
|
||||
|
||||
const handleVerticalDragKeyDown = () => {};
|
||||
|
||||
const { handleDragStart: handleVerticalDragStart } = useDrag(
|
||||
handleVerticalDragMove,
|
||||
handleVerticalDragKeyDown,
|
||||
handleVerticalDragEnd,
|
||||
);
|
||||
const { handleDragStart: handleHorizontalDragStart } = useDrag(handleHorizontalDragMove);
|
||||
const { handleDragStart: handleHorizontalDragStart, handleDragKeyDown } = useDrag(
|
||||
handleHorizontalDragMove,
|
||||
handleHorizontalDragKeyDown,
|
||||
);
|
||||
|
||||
const handleClickOverlay = () => {
|
||||
setSideBarVisible(false);
|
||||
|
|
@ -182,10 +206,7 @@ const SideBar: React.FC<{
|
|||
return isSideBarVisible ? (
|
||||
<>
|
||||
{!isSideBarPinned && (
|
||||
<div
|
||||
className='overlay fixed inset-0 z-[45] bg-black/50 sm:bg-black/20'
|
||||
onClick={handleClickOverlay}
|
||||
/>
|
||||
<Overlay className='z-[45] bg-black/50 sm:bg-black/20' onDismiss={handleClickOverlay} />
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
|
|
@ -222,6 +243,11 @@ const SideBar: React.FC<{
|
|||
<div className='flex-shrink-0'>
|
||||
{isMobile && (
|
||||
<div
|
||||
role='slider'
|
||||
tabIndex={0}
|
||||
aria-label={_('Resize Sidebar')}
|
||||
aria-orientation='vertical'
|
||||
aria-valuenow={sidebarHeight.current}
|
||||
className='drag-handle flex h-10 w-full cursor-row-resize items-center justify-center'
|
||||
onMouseDown={handleVerticalDragStart}
|
||||
onTouchStart={handleVerticalDragStart}
|
||||
|
|
@ -265,11 +291,17 @@ const SideBar: React.FC<{
|
|||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'drag-bar absolute right-0 top-0 -m-2 h-full w-0.5 cursor-col-resize bg-transparent p-2',
|
||||
'drag-bar absolute -right-2 top-0 h-full w-0.5 cursor-col-resize bg-transparent p-2',
|
||||
isMobile && 'hidden',
|
||||
)}
|
||||
role='slider'
|
||||
tabIndex={0}
|
||||
aria-label={_('Resize Sidebar')}
|
||||
aria-orientation='horizontal'
|
||||
aria-valuenow={parseFloat(sideBarWidth)}
|
||||
onMouseDown={handleHorizontalDragStart}
|
||||
onTouchStart={handleHorizontalDragStart}
|
||||
onKeyDown={handleDragKeyDown}
|
||||
></div>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ const TOCItemView = React.memo<{
|
|||
);
|
||||
|
||||
const handleClickItem = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
(event: React.MouseEvent | React.KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
onItemClick(item);
|
||||
},
|
||||
|
|
@ -58,9 +58,10 @@ const TOCItemView = React.memo<{
|
|||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
role='treeitem'
|
||||
tabIndex={-1}
|
||||
onClick={item.href ? handleClickItem : undefined}
|
||||
onKeyDown={item.href ? (e) => e.key === 'Enter' && handleClickItem(e) : undefined}
|
||||
aria-expanded={flatItem.isExpanded ? 'true' : 'false'}
|
||||
aria-selected={isActive ? 'true' : 'false'}
|
||||
data-href={item.href ? getContentMd5(item.href) : undefined}
|
||||
|
|
@ -76,8 +77,11 @@ const TOCItemView = React.memo<{
|
|||
}}
|
||||
>
|
||||
{item.subitems && (
|
||||
<div
|
||||
<button
|
||||
onClick={handleToggleExpand}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className='inline-block cursor-pointer'
|
||||
style={{
|
||||
padding: '12px',
|
||||
|
|
@ -85,7 +89,7 @@ const TOCItemView = React.memo<{
|
|||
}}
|
||||
>
|
||||
{createExpanderIcon(flatItem.isExpanded || false)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className='ms-2 truncate text-ellipsis'
|
||||
|
|
|
|||
|
|
@ -36,12 +36,22 @@ const TabNavigation: React.FC<{
|
|||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab}
|
||||
className='lg:tooltip lg:tooltip-top z-20 m-1.5 flex-1 cursor-pointer rounded-md p-2'
|
||||
data-tip={
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
className='z-20 m-1.5 flex-1 cursor-pointer rounded-md p-2'
|
||||
onClick={() => onTabChange(tab)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onTabChange(tab);
|
||||
}
|
||||
}}
|
||||
title={tab === 'toc' ? _('TOC') : tab === 'annotations' ? _('Annotate') : _('Bookmark')}
|
||||
aria-label={
|
||||
tab === 'toc' ? _('TOC') : tab === 'annotations' ? _('Annotate') : _('Bookmark')
|
||||
}
|
||||
>
|
||||
<div className={clsx('flex h-6 items-center')} onClick={() => onTabChange(tab)}>
|
||||
<div className={clsx('m-0 flex h-6 items-center p-0')}>
|
||||
{tab === 'toc' ? (
|
||||
<TOCIcon className='mx-auto' />
|
||||
) : tab === 'annotations' ? (
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { throttle } from '@/utils/throttle';
|
|||
import { invokeUseBackgroundAudio } from '@/utils/bridge';
|
||||
import { CFI } from '@/libs/document';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { Overlay } from '@/components/Overlay';
|
||||
import Popup from '@/components/Popup';
|
||||
import TTSPanel from './TTSPanel';
|
||||
import TTSIcon from './TTSIcon';
|
||||
|
|
@ -464,13 +465,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
{showPanel && (
|
||||
<div
|
||||
className='fixed inset-0'
|
||||
onClick={handleDismissPopup}
|
||||
onContextMenu={handleDismissPopup}
|
||||
/>
|
||||
)}
|
||||
{showPanel && <Overlay onDismiss={handleDismissPopup} />}
|
||||
{showIndicator && (
|
||||
<div
|
||||
ref={iconRef}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, ttsInited, onClick }) => {
|
|||
const bars = [1, 2, 3, 4];
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
className={clsx(
|
||||
'relative h-full w-full rounded-full',
|
||||
ttsInited ? 'cursor-pointer' : 'cursor-not-allowed',
|
||||
|
|
@ -59,7 +59,7 @@ const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, ttsInited, onClick }) => {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -281,6 +281,7 @@ const TTSPanel = ({
|
|||
)}
|
||||
</button>
|
||||
<ul
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 no-triangle menu menu-vertical rounded-box absolute right-0 z-[1] shadow',
|
||||
|
|
@ -288,6 +289,7 @@ const TTSPanel = ({
|
|||
)}
|
||||
>
|
||||
{timeoutOptions.map((option, index) => (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
|
||||
<li
|
||||
key={`${index}-${option.value}`}
|
||||
onClick={() => onSelectTimeout(bookKey, option.value)}
|
||||
|
|
@ -317,6 +319,7 @@ const TTSPanel = ({
|
|||
<RiVoiceAiFill size={iconSize32} />
|
||||
</button>
|
||||
<ul
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 no-triangle menu menu-vertical rounded-box absolute right-0 z-[1] shadow',
|
||||
|
|
@ -338,6 +341,7 @@ const TTSPanel = ({
|
|||
</span>
|
||||
</div>
|
||||
{voiceGroup.voices.map((voice, voiceIndex) => (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
|
||||
<li
|
||||
key={`${index}-${voiceGroup.id}-${voiceIndex}`}
|
||||
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const useSidebar = (initialWidth: string, isPinned: boolean) => {
|
|||
sideBarWidth,
|
||||
isSideBarVisible,
|
||||
isSideBarPinned,
|
||||
getSideBarWidth,
|
||||
setSideBarWidth,
|
||||
setSideBarVisible,
|
||||
setSideBarPin,
|
||||
|
|
@ -37,6 +38,7 @@ const useSidebar = (initialWidth: string, isPinned: boolean) => {
|
|||
sideBarWidth,
|
||||
isSideBarPinned,
|
||||
isSideBarVisible,
|
||||
getSideBarWidth,
|
||||
handleSideBarResize,
|
||||
handleSideBarTogglePin,
|
||||
setSideBarVisible,
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-6 space-y-3' onClick={() => onSelectPlan(index)}>
|
||||
<button className='mb-6 space-y-3' onClick={() => onSelectPlan(index)}>
|
||||
{plan.features.map((feature, featureIndex) => (
|
||||
<div key={featureIndex} className='flex flex-col'>
|
||||
<div className='flex items-center gap-2'>
|
||||
|
|
@ -62,9 +62,9 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className='mb-6 rounded-lg bg-white/50 p-4' onClick={() => onSelectPlan(index)}>
|
||||
<button className='mb-6 rounded-lg bg-white/50 p-4' onClick={() => onSelectPlan(index)}>
|
||||
<h5 className='mb-3 font-semibold'>{_('Plan Limits')}</h5>
|
||||
<div className='space-y-2'>
|
||||
{Object.entries(plan.limits).map(([key, value]) => (
|
||||
|
|
@ -74,7 +74,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<PlanActionButton
|
||||
plan={plan}
|
||||
|
|
|
|||
|
|
@ -85,67 +85,71 @@ export const AboutWindow = () => {
|
|||
onClose={handleClose}
|
||||
boxClassName='sm:!w-96 sm:h-auto'
|
||||
>
|
||||
<div className='about-content flex h-full flex-col items-center justify-center'>
|
||||
<div className='flex flex-col items-center gap-2 px-8'>
|
||||
<div className='mb-2 mt-8'>
|
||||
<Image src='/icon.png' alt='App Logo' className='h-20 w-20' width={64} height={64} />
|
||||
{isOpen && (
|
||||
<div className='about-content flex h-full flex-col items-center justify-center'>
|
||||
<div className='flex flex-col items-center gap-2 px-8'>
|
||||
<div className='mb-2 mt-8'>
|
||||
<Image src='/icon.png' alt='App Logo' className='h-20 w-20' width={64} height={64} />
|
||||
</div>
|
||||
<div className='flex select-text flex-col items-center'>
|
||||
<h2 className='mb-2 text-2xl font-bold'>Readest</h2>
|
||||
<p className='text-neutral-content text-center text-sm'>
|
||||
{_('Version {{version}}', { version: getAppVersion() })} {`(${browserInfo})`}
|
||||
</p>
|
||||
</div>
|
||||
<div className='h-5'>
|
||||
{!updateStatus && (
|
||||
<button
|
||||
className='badge badge-primary cursor-pointer p-2'
|
||||
onClick={appService?.hasUpdater ? handleCheckUpdate : handleShowRecentUpdates}
|
||||
>
|
||||
{_('Check Update')}
|
||||
</button>
|
||||
)}
|
||||
{updateStatus === 'updated' && (
|
||||
<p className='text-neutral-content mt-2 text-xs'>
|
||||
{_('Already the latest version')}
|
||||
</p>
|
||||
)}
|
||||
{updateStatus === 'checking' && (
|
||||
<p className='text-neutral-content mt-2 text-xs'>{_('Checking for updates...')}</p>
|
||||
)}
|
||||
{updateStatus === 'error' && (
|
||||
<p className='text-error mt-2 text-xs'>{_('Error checking for updates')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex select-text flex-col items-center'>
|
||||
<h2 className='mb-2 text-2xl font-bold'>Readest</h2>
|
||||
<p className='text-neutral-content text-center text-sm'>
|
||||
{_('Version {{version}}', { version: getAppVersion() })} {`(${browserInfo})`}
|
||||
|
||||
<div className='divider py-16 sm:py-2'></div>
|
||||
|
||||
<div className='flex flex-col items-center px-4 text-center' dir='ltr'>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
© {new Date().getFullYear()} Bilingify LLC. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
<div className='h-5'>
|
||||
{!updateStatus && (
|
||||
<span
|
||||
className='badge badge-primary cursor-pointer p-2'
|
||||
onClick={appService?.hasUpdater ? handleCheckUpdate : handleShowRecentUpdates}
|
||||
|
||||
<p className='text-neutral-content mt-2 text-xs'>
|
||||
This software is licensed under the{' '}
|
||||
<Link
|
||||
href='https://www.gnu.org/licenses/agpl-3.0.html'
|
||||
className='text-blue-500 underline'
|
||||
>
|
||||
{_('Check Update')}
|
||||
</span>
|
||||
)}
|
||||
{updateStatus === 'updated' && (
|
||||
<p className='text-neutral-content mt-2 text-xs'>{_('Already the latest version')}</p>
|
||||
)}
|
||||
{updateStatus === 'checking' && (
|
||||
<p className='text-neutral-content mt-2 text-xs'>{_('Checking for updates...')}</p>
|
||||
)}
|
||||
{updateStatus === 'error' && (
|
||||
<p className='text-error mt-2 text-xs'>{_('Error checking for updates')}</p>
|
||||
)}
|
||||
GNU Affero General Public License v3.0
|
||||
</Link>
|
||||
. You are free to use, modify, and distribute this software under the terms of the
|
||||
AGPL v3 license. Please see the license for more details.
|
||||
</p>
|
||||
<p className='text-neutral-content my-2 text-xs'>
|
||||
Source code is available at{' '}
|
||||
<Link href='https://github.com/readest/readest' className='text-blue-500 underline'>
|
||||
GitHub
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
<LegalLinks />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='divider py-16 sm:py-2'></div>
|
||||
|
||||
<div className='flex flex-col items-center px-4 text-center' dir='ltr'>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
© {new Date().getFullYear()} Bilingify LLC. All rights reserved.
|
||||
</p>
|
||||
|
||||
<p className='text-neutral-content mt-2 text-xs'>
|
||||
This software is licensed under the{' '}
|
||||
<Link
|
||||
href='https://www.gnu.org/licenses/agpl-3.0.html'
|
||||
className='text-blue-500 underline'
|
||||
>
|
||||
GNU Affero General Public License v3.0
|
||||
</Link>
|
||||
. You are free to use, modify, and distribute this software under the terms of the AGPL
|
||||
v3 license. Please see the license for more details.
|
||||
</p>
|
||||
<p className='text-neutral-content my-2 text-xs'>
|
||||
Source code is available at{' '}
|
||||
<Link href='https://github.com/readest/readest' className='text-blue-500 underline'>
|
||||
GitHub
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
<LegalLinks />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
|||
import { impactFeedback } from '@tauri-apps/plugin-haptics';
|
||||
import { getDirFromUILanguage } from '@/utils/rtl';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { Overlay } from './Overlay';
|
||||
|
||||
const VELOCITY_THRESHOLD = 0.5;
|
||||
const SNAP_THRESHOLD = 0.2;
|
||||
|
|
@ -146,7 +147,9 @@ const Dialog: React.FC<DialogProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const { handleDragStart } = useDrag(handleDragMove, handleDragEnd);
|
||||
const handleDragKeyDown = () => {};
|
||||
|
||||
const { handleDragStart } = useDrag(handleDragMove, handleDragKeyDown, handleDragEnd);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
|
|
@ -159,13 +162,13 @@ const Dialog: React.FC<DialogProps> = ({
|
|||
)}
|
||||
dir={isRtl ? 'rtl' : undefined}
|
||||
>
|
||||
<div
|
||||
<Overlay
|
||||
className={clsx(
|
||||
'overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/50',
|
||||
'z-10 bg-black/50 sm:bg-black/50',
|
||||
appService?.hasRoundedWindow && 'rounded-window',
|
||||
bgClassName,
|
||||
)}
|
||||
onClick={onClose}
|
||||
onDismiss={onClose}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
|
|
@ -184,6 +187,7 @@ const Dialog: React.FC<DialogProps> = ({
|
|||
...(isMobile ? { height: snapHeight ? `${snapHeight * 100}%` : '100%', bottom: 0 } : {}),
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={clsx(
|
||||
'drag-handle h-10 max-h-10 min-h-10 w-full cursor-row-resize items-center justify-center',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { useState, isValidElement, ReactElement, ReactNode } from 'react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { Overlay } from './Overlay';
|
||||
import MenuItem from './MenuItem';
|
||||
|
||||
interface DropdownProps {
|
||||
|
|
@ -57,12 +59,12 @@ const Dropdown: React.FC<DropdownProps> = ({
|
|||
children,
|
||||
onToggle,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
const newIsOpen = !isOpen;
|
||||
setIsOpen(newIsOpen);
|
||||
onToggle?.(newIsOpen);
|
||||
setIsDropdownOpen(newIsOpen);
|
||||
};
|
||||
|
||||
const setIsDropdownOpen = (isOpen: boolean) => {
|
||||
|
|
@ -82,12 +84,24 @@ const Dropdown: React.FC<DropdownProps> = ({
|
|||
|
||||
return (
|
||||
<div className='dropdown-container flex'>
|
||||
{isOpen && (
|
||||
<div className='fixed inset-0 bg-transparent' onClick={() => setIsDropdownOpen(false)} />
|
||||
)}
|
||||
<div className={clsx('dropdown', className)}>
|
||||
{isOpen && <Overlay onDismiss={() => setIsDropdownOpen(false)} />}
|
||||
<div
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
aria-label={_('Menu')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (!isOpen) toggleDropdown();
|
||||
} else if (e.key === 'Escape' && isOpen) {
|
||||
toggleDropdown();
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className={clsx('dropdown', className)}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
tabIndex={-1}
|
||||
onClick={toggleDropdown}
|
||||
className={clsx('dropdown-toggle', buttonClassName, isOpen && 'bg-base-300/50')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
|
|||
const iconSize = useResponsiveSize(16);
|
||||
const menuButton = (
|
||||
<button
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'hover:bg-base-300 text-base-content flex w-full flex-col items-center justify-center rounded-md p-1 py-[10px]',
|
||||
disabled && 'btn-disabled text-gray-400',
|
||||
|
|
|
|||
32
apps/readest-app/src/components/Overlay.tsx
Normal file
32
apps/readest-app/src/components/Overlay.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
interface OverlayProps {
|
||||
onDismiss: () => void;
|
||||
dismissLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Overlay: React.FC<OverlayProps> = ({ onDismiss, dismissLabel, className }) => {
|
||||
const _ = useTranslation();
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('overlay fixed inset-0 cursor-default', className)}
|
||||
role='button'
|
||||
tabIndex={-1}
|
||||
aria-label={dismissLabel || _('Dismiss')}
|
||||
onClick={onDismiss}
|
||||
onContextMenu={onDismiss}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -6,9 +6,11 @@ import { AuthProvider } from '@/context/AuthContext';
|
|||
import { useEnv } from '@/context/EnvContext';
|
||||
import { CSPostHogProvider } from '@/context/PHContext';
|
||||
import { SyncProvider } from '@/context/SyncContext';
|
||||
import { initSystemThemeListener } from '@/store/themeStore';
|
||||
import { initSystemThemeListener, loadDataTheme } from '@/store/themeStore';
|
||||
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
|
||||
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
import i18n from '@/i18n/i18n';
|
||||
|
||||
const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
const { appService } = useEnv();
|
||||
|
|
@ -16,6 +18,20 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
|
|||
useSafeAreaInsets(); // Initialize safe area insets
|
||||
|
||||
useEffect(() => {
|
||||
const handlerLanguageChanged = (lng: string) => {
|
||||
document.documentElement.lang = lng;
|
||||
};
|
||||
|
||||
const locale = getLocale();
|
||||
handlerLanguageChanged(locale);
|
||||
i18n.on('languageChanged', handlerLanguageChanged);
|
||||
return () => {
|
||||
i18n.off('languageChanged', handlerLanguageChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadDataTheme();
|
||||
if (appService) {
|
||||
initSystemThemeListener(appService);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,9 +47,9 @@ const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className, labelCla
|
|||
labelClassName,
|
||||
)}
|
||||
>
|
||||
<div className='lg:tooltip lg:tooltip-right' data-tip={quota.tooltip}>
|
||||
<span className='truncate'>{quota.name}</span>
|
||||
</div>
|
||||
<span className='truncate' title={quota.tooltip}>
|
||||
{quota.name}
|
||||
</span>
|
||||
<div className='text-right text-sm'>
|
||||
{quota.used} / {quota.total} {quota.unit}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,11 +25,8 @@ export default function Select({
|
|||
<select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={clsx(
|
||||
'select h-8 min-h-8 rounded-md border-none text-sm',
|
||||
'bg-base-200 focus:outline-none focus:ring-0',
|
||||
className,
|
||||
)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className={clsx('select bg-base-200 h-8 min-h-8 rounded-md border-none text-sm', className)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
textAlignLast: 'end',
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
|
|||
<div className='bg-base-100 relative w-full rounded-lg'>
|
||||
<div className='mb-6 flex items-start gap-4'>
|
||||
<div className='flex w-[30%] max-w-32 flex-col gap-2'>
|
||||
<div
|
||||
<button
|
||||
className='aspect-[28/41] h-full shadow-md'
|
||||
onClick={!isCoverLocked ? handleSelectLocalImage : undefined}
|
||||
>
|
||||
|
|
@ -186,7 +186,7 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
|
|||
...(newCoverImageUrl ? { coverImageUrl: newCoverImageUrl } : {}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<div className='flex w-full gap-1'>
|
||||
<button
|
||||
onClick={handleSelectLocalImage}
|
||||
|
|
|
|||
|
|
@ -232,12 +232,14 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
|
|||
</Dialog>
|
||||
|
||||
{/* Source Selection Modal */}
|
||||
<SourceSelector
|
||||
sources={availableSources}
|
||||
isOpen={showSourceSelection}
|
||||
onSelect={handleSourceSelection}
|
||||
onClose={handleCloseSourceSelection}
|
||||
/>
|
||||
{showSourceSelection && (
|
||||
<SourceSelector
|
||||
sources={availableSources}
|
||||
isOpen={showSourceSelection}
|
||||
onSelect={handleSourceSelection}
|
||||
onClose={handleCloseSourceSelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDeleteAction && currentDeleteConfig && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -75,10 +75,9 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
|
|||
toggleButton={<MdOutlineDelete className='fill-red-500' />}
|
||||
>
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'delete-menu dropdown-content dropdown-center no-triangle',
|
||||
'border-base-200 z-20 mt-1 max-w-[90vw] shadow-2xl',
|
||||
'border-base-300 !bg-base-200 z-20 mt-1 max-w-[90vw] shadow-2xl',
|
||||
)}
|
||||
>
|
||||
<MenuItem
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { MdOutlineCheck, MdOutlineEdit } from 'react-icons/md';
|
||||
|
||||
import { BookMetadata } from '@/libs/document';
|
||||
|
|
@ -24,6 +24,13 @@ interface SourceSelectorProps {
|
|||
|
||||
const SourceSelector: React.FC<SourceSelectorProps> = ({ sources, isOpen, onSelect, onClose }) => {
|
||||
const _ = useTranslation();
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && modalRef.current) {
|
||||
modalRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const getConfidenceIcon = (confidence: number) => {
|
||||
if (confidence >= 90) return <MdOutlineCheck className='text-green-500' />;
|
||||
|
|
@ -34,16 +41,23 @@ const SourceSelector: React.FC<SourceSelectorProps> = ({ sources, isOpen, onSele
|
|||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/50'>
|
||||
<div className='bg-base-100 mx-4 max-h-[80vh] w-full max-w-md overflow-y-auto rounded-lg p-6'>
|
||||
<div className='source-selector fixed inset-0 z-[60] flex items-center justify-center bg-black/50'>
|
||||
<div
|
||||
ref={modalRef}
|
||||
tabIndex={-1}
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
className='bg-base-100 mx-4 max-h-[80vh] w-full max-w-md overflow-y-auto rounded-lg p-6'
|
||||
>
|
||||
<h3 className='mb-4 text-lg font-semibold'>{_('Select Metadata Source')}</h3>
|
||||
|
||||
<div className='space-y-3'>
|
||||
{sources.map((source, index) => (
|
||||
<div
|
||||
<button
|
||||
tabIndex={0}
|
||||
key={index}
|
||||
onClick={() => onSelect(source)}
|
||||
className='hover:bg-base-200 cursor-pointer rounded-md border p-3 transition-colors'
|
||||
className='hover:bg-base-300/75 bg-base-200 border-base-200 cursor-pointer rounded-md border p-3 transition-colors'
|
||||
>
|
||||
<div className='flex items-start gap-4'>
|
||||
<div className='aspect-[28/41] h-full w-[40%] max-w-32 shadow-md'>
|
||||
|
|
@ -86,18 +100,19 @@ const SourceSelector: React.FC<SourceSelectorProps> = ({ sources, isOpen, onSele
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div
|
||||
<button
|
||||
tabIndex={0}
|
||||
onClick={onClose}
|
||||
className='hover:bg-base-200 cursor-pointer rounded-md border p-3 transition-colors'
|
||||
className='hover:bg-base-300/75 border-base-200 bg-base-200 cursor-pointer rounded-md border p-3 transition-colors'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MdOutlineEdit className='h-4 w-4' />
|
||||
<span className='font-medium'>{_('Keep manual input')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 flex justify-end gap-2'>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import { EnvConfigType } from '../services/environment';
|
||||
import env from '../services/environment';
|
||||
import { AppService } from '@/types/system';
|
||||
import env from '../services/environment';
|
||||
|
||||
interface EnvContextType {
|
||||
envConfig: EnvConfigType;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { useCallback, useRef } from 'react';
|
||||
|
||||
export type DragKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown';
|
||||
|
||||
export const useDrag = (
|
||||
onDragMove: (data: { clientX: number; clientY: number; deltaX: number; deltaY: number }) => void,
|
||||
onDragKeyDown: (data: { key: DragKey; step: number }) => void,
|
||||
onDragEnd?: (data: {
|
||||
velocity: number;
|
||||
deltaT: number;
|
||||
|
|
@ -31,6 +34,10 @@ export const useDrag = (
|
|||
}
|
||||
startTime.current = performance.now();
|
||||
|
||||
document.body.style.pointerEvents = 'none';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.documentElement.style.cursor = 'col-resize';
|
||||
|
||||
const handleMove = (event: MouseEvent | TouchEvent) => {
|
||||
if (isDragging.current) {
|
||||
let deltaX = 0;
|
||||
|
|
@ -58,6 +65,11 @@ export const useDrag = (
|
|||
|
||||
const handleEnd = (event: MouseEvent | TouchEvent) => {
|
||||
isDragging.current = false;
|
||||
|
||||
document.body.style.pointerEvents = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.documentElement.style.cursor = '';
|
||||
|
||||
let deltaX = 0;
|
||||
let deltaY = 0;
|
||||
let clientX = 0;
|
||||
|
|
@ -96,5 +108,24 @@ export const useDrag = (
|
|||
[onDragMove, onDragEnd],
|
||||
);
|
||||
|
||||
return { handleDragStart };
|
||||
const handleDragKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
const step = 0.02;
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
onDragKeyDown({ key: e.key, step });
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
[onDragKeyDown],
|
||||
);
|
||||
|
||||
return { handleDragStart, handleDragKeyDown };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { AppProps } from 'next/app';
|
||||
import Head from 'next/head';
|
||||
import { AppProps } from 'next/app';
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import Providers from '@/components/Providers';
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ interface NotebookState {
|
|||
getIsNotebookVisible: () => boolean;
|
||||
toggleNotebook: () => void;
|
||||
toggleNotebookPin: () => void;
|
||||
getNotebookWidth: () => string;
|
||||
setNotebookWidth: (width: string) => void;
|
||||
setNotebookVisible: (visible: boolean) => void;
|
||||
setNotebookPin: (pinned: boolean) => void;
|
||||
|
|
@ -29,6 +30,7 @@ export const useNotebookStore = create<NotebookState>((set, get) => ({
|
|||
notebookEditAnnotation: null,
|
||||
notebookAnnotationDrafts: {},
|
||||
getIsNotebookVisible: () => get().isNotebookVisible,
|
||||
getNotebookWidth: () => get().notebookWidth,
|
||||
setNotebookWidth: (width: string) => set({ notebookWidth: width }),
|
||||
toggleNotebook: () => set((state) => ({ isNotebookVisible: !state.isNotebookVisible })),
|
||||
toggleNotebookPin: () => set((state) => ({ isNotebookPinned: !state.isNotebookPinned })),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ interface SidebarState {
|
|||
isSideBarVisible: boolean;
|
||||
isSideBarPinned: boolean;
|
||||
getIsSideBarVisible: () => boolean;
|
||||
getSideBarWidth: () => string;
|
||||
setSideBarBookKey: (key: string) => void;
|
||||
setSideBarWidth: (width: string) => void;
|
||||
toggleSideBar: () => void;
|
||||
|
|
@ -20,6 +21,7 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
|
|||
isSideBarVisible: false,
|
||||
isSideBarPinned: false,
|
||||
getIsSideBarVisible: () => get().isSideBarVisible,
|
||||
getSideBarWidth: () => get().sideBarWidth,
|
||||
setSideBarBookKey: (key: string) => set({ sideBarBookKey: key }),
|
||||
setSideBarWidth: (width: string) => set({ sideBarWidth: width }),
|
||||
toggleSideBar: () => set((state) => ({ isSideBarVisible: !state.isSideBarVisible })),
|
||||
|
|
|
|||
|
|
@ -135,6 +135,16 @@ export const useThemeStore = create<ThemeState>((set, get) => {
|
|||
};
|
||||
});
|
||||
|
||||
export const loadDataTheme = () => {
|
||||
if (typeof localStorage === 'undefined' || typeof document === 'undefined') return;
|
||||
|
||||
const themeMode = localStorage.getItem('themeMode');
|
||||
const themeColor = localStorage.getItem('themeColor');
|
||||
if (themeMode && themeColor) {
|
||||
document.documentElement.setAttribute('data-theme', `${themeColor}-${themeMode}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const initSystemThemeListener = (appService: AppService) => {
|
||||
if (typeof window === 'undefined' || !appService) return;
|
||||
|
||||
|
|
|
|||
5
apps/readest-app/src/utils/a11y.ts
Normal file
5
apps/readest-app/src/utils/a11y.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const removeTabIndex = (document: Document) => {
|
||||
document.querySelectorAll('a').forEach((a) => {
|
||||
a.setAttribute('tabindex', '-1');
|
||||
});
|
||||
};
|
||||
75
pnpm-lock.yaml
generated
75
pnpm-lock.yaml
generated
|
|
@ -282,6 +282,9 @@ importers:
|
|||
eslint-config-next:
|
||||
specifier: 15.0.3
|
||||
version: 15.0.3(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
eslint-plugin-jsx-a11y:
|
||||
specifier: ^6.10.2
|
||||
version: 6.10.2(eslint@9.28.0(jiti@1.21.6))
|
||||
i18next-scanner:
|
||||
specifier: ^4.6.0
|
||||
version: 4.6.0(typescript@5.7.2)
|
||||
|
|
@ -11197,8 +11200,8 @@ snapshots:
|
|||
call-bind: 1.0.7
|
||||
define-properties: 1.2.1
|
||||
es-abstract: 1.23.5
|
||||
es-object-atoms: 1.0.0
|
||||
get-intrinsic: 1.2.4
|
||||
es-object-atoms: 1.1.1
|
||||
get-intrinsic: 1.3.0
|
||||
is-string: 1.0.7
|
||||
|
||||
array.prototype.findlast@1.2.5:
|
||||
|
|
@ -11248,7 +11251,7 @@ snapshots:
|
|||
define-properties: 1.2.1
|
||||
es-abstract: 1.23.5
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.2.4
|
||||
get-intrinsic: 1.3.0
|
||||
is-array-buffer: 3.0.4
|
||||
is-shared-array-buffer: 1.0.3
|
||||
|
||||
|
|
@ -11388,10 +11391,10 @@ snapshots:
|
|||
|
||||
call-bind@1.0.7:
|
||||
dependencies:
|
||||
es-define-property: 1.0.0
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
get-intrinsic: 1.2.4
|
||||
get-intrinsic: 1.3.0
|
||||
set-function-length: 1.2.2
|
||||
|
||||
call-bound@1.0.4:
|
||||
|
|
@ -11676,9 +11679,9 @@ snapshots:
|
|||
|
||||
define-data-property@1.1.4:
|
||||
dependencies:
|
||||
es-define-property: 1.0.0
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.1.0
|
||||
gopd: 1.2.0
|
||||
|
||||
define-properties@1.2.1:
|
||||
dependencies:
|
||||
|
|
@ -11795,19 +11798,19 @@ snapshots:
|
|||
data-view-buffer: 1.0.1
|
||||
data-view-byte-length: 1.0.1
|
||||
data-view-byte-offset: 1.0.0
|
||||
es-define-property: 1.0.0
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.0.0
|
||||
es-set-tostringtag: 2.0.3
|
||||
es-object-atoms: 1.1.1
|
||||
es-set-tostringtag: 2.1.0
|
||||
es-to-primitive: 1.3.0
|
||||
function.prototype.name: 1.1.6
|
||||
get-intrinsic: 1.2.4
|
||||
get-intrinsic: 1.3.0
|
||||
get-symbol-description: 1.0.2
|
||||
globalthis: 1.0.4
|
||||
gopd: 1.1.0
|
||||
gopd: 1.2.0
|
||||
has-property-descriptors: 1.0.2
|
||||
has-proto: 1.0.3
|
||||
has-symbols: 1.0.3
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.2
|
||||
internal-slot: 1.0.7
|
||||
is-array-buffer: 3.0.4
|
||||
|
|
@ -11837,7 +11840,7 @@ snapshots:
|
|||
|
||||
es-define-property@1.0.0:
|
||||
dependencies:
|
||||
get-intrinsic: 1.2.4
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
|
|
@ -11873,7 +11876,7 @@ snapshots:
|
|||
|
||||
es-set-tostringtag@2.0.3:
|
||||
dependencies:
|
||||
get-intrinsic: 1.2.4
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
|
||||
|
|
@ -12417,7 +12420,7 @@ snapshots:
|
|||
dependencies:
|
||||
call-bind: 1.0.7
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.2.4
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
get-tsconfig@4.8.1:
|
||||
dependencies:
|
||||
|
|
@ -12506,7 +12509,7 @@ snapshots:
|
|||
|
||||
gopd@1.1.0:
|
||||
dependencies:
|
||||
get-intrinsic: 1.2.4
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
|
|
@ -12538,7 +12541,7 @@ snapshots:
|
|||
|
||||
has-tostringtag@1.0.2:
|
||||
dependencies:
|
||||
has-symbols: 1.0.3
|
||||
has-symbols: 1.1.0
|
||||
|
||||
hasown@2.0.2:
|
||||
dependencies:
|
||||
|
|
@ -12669,7 +12672,7 @@ snapshots:
|
|||
is-array-buffer@3.0.4:
|
||||
dependencies:
|
||||
call-bind: 1.0.7
|
||||
get-intrinsic: 1.2.4
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
is-arrayish@0.3.2: {}
|
||||
|
||||
|
|
@ -12753,7 +12756,7 @@ snapshots:
|
|||
is-regex@1.2.0:
|
||||
dependencies:
|
||||
call-bind: 1.0.7
|
||||
gopd: 1.1.0
|
||||
gopd: 1.2.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
|
||||
|
|
@ -12773,7 +12776,7 @@ snapshots:
|
|||
|
||||
is-symbol@1.0.4:
|
||||
dependencies:
|
||||
has-symbols: 1.0.3
|
||||
has-symbols: 1.1.0
|
||||
|
||||
is-typed-array@1.1.13:
|
||||
dependencies:
|
||||
|
|
@ -12809,7 +12812,7 @@ snapshots:
|
|||
iterator.prototype@1.1.3:
|
||||
dependencies:
|
||||
define-properties: 1.2.1
|
||||
get-intrinsic: 1.2.4
|
||||
get-intrinsic: 1.3.0
|
||||
has-symbols: 1.0.3
|
||||
reflect.getprototypeof: 1.0.7
|
||||
set-function-name: 2.0.2
|
||||
|
|
@ -13199,7 +13202,7 @@ snapshots:
|
|||
dependencies:
|
||||
call-bind: 1.0.7
|
||||
define-properties: 1.2.1
|
||||
has-symbols: 1.0.3
|
||||
has-symbols: 1.1.0
|
||||
object-keys: 1.1.1
|
||||
|
||||
object.entries@1.1.8:
|
||||
|
|
@ -13213,7 +13216,7 @@ snapshots:
|
|||
call-bind: 1.0.7
|
||||
define-properties: 1.2.1
|
||||
es-abstract: 1.23.5
|
||||
es-object-atoms: 1.0.0
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
object.groupby@1.0.3:
|
||||
dependencies:
|
||||
|
|
@ -13611,8 +13614,8 @@ snapshots:
|
|||
define-properties: 1.2.1
|
||||
es-abstract: 1.23.5
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.2.4
|
||||
gopd: 1.1.0
|
||||
get-intrinsic: 1.3.0
|
||||
gopd: 1.2.0
|
||||
which-builtin-type: 1.2.0
|
||||
|
||||
regenerate-unicode-properties@10.2.0:
|
||||
|
|
@ -13750,7 +13753,7 @@ snapshots:
|
|||
safe-array-concat@1.1.2:
|
||||
dependencies:
|
||||
call-bind: 1.0.7
|
||||
get-intrinsic: 1.2.4
|
||||
get-intrinsic: 1.3.0
|
||||
has-symbols: 1.0.3
|
||||
isarray: 2.0.5
|
||||
|
||||
|
|
@ -13832,8 +13835,8 @@ snapshots:
|
|||
define-data-property: 1.1.4
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
get-intrinsic: 1.2.4
|
||||
gopd: 1.1.0
|
||||
get-intrinsic: 1.3.0
|
||||
gopd: 1.2.0
|
||||
has-property-descriptors: 1.0.2
|
||||
|
||||
set-function-name@2.0.2:
|
||||
|
|
@ -14051,7 +14054,7 @@ snapshots:
|
|||
call-bind: 1.0.7
|
||||
define-properties: 1.2.1
|
||||
es-abstract: 1.23.5
|
||||
es-object-atoms: 1.0.0
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
string.prototype.trimend@1.0.8:
|
||||
dependencies:
|
||||
|
|
@ -14063,7 +14066,7 @@ snapshots:
|
|||
dependencies:
|
||||
call-bind: 1.0.7
|
||||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.0.0
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
|
|
@ -14357,7 +14360,7 @@ snapshots:
|
|||
dependencies:
|
||||
call-bind: 1.0.7
|
||||
for-each: 0.3.3
|
||||
gopd: 1.1.0
|
||||
gopd: 1.2.0
|
||||
has-proto: 1.0.3
|
||||
is-typed-array: 1.1.13
|
||||
|
||||
|
|
@ -14366,7 +14369,7 @@ snapshots:
|
|||
available-typed-arrays: 1.0.7
|
||||
call-bind: 1.0.7
|
||||
for-each: 0.3.3
|
||||
gopd: 1.1.0
|
||||
gopd: 1.2.0
|
||||
has-proto: 1.0.3
|
||||
is-typed-array: 1.1.13
|
||||
reflect.getprototypeof: 1.0.7
|
||||
|
|
@ -14375,7 +14378,7 @@ snapshots:
|
|||
dependencies:
|
||||
call-bind: 1.0.7
|
||||
for-each: 0.3.3
|
||||
gopd: 1.1.0
|
||||
gopd: 1.2.0
|
||||
is-typed-array: 1.1.13
|
||||
possible-typed-array-names: 1.0.0
|
||||
reflect.getprototypeof: 1.0.7
|
||||
|
|
@ -14388,7 +14391,7 @@ snapshots:
|
|||
dependencies:
|
||||
call-bind: 1.0.7
|
||||
has-bigints: 1.0.2
|
||||
has-symbols: 1.0.3
|
||||
has-symbols: 1.1.0
|
||||
which-boxed-primitive: 1.0.2
|
||||
|
||||
undici-types@5.26.5: {}
|
||||
|
|
@ -14727,7 +14730,7 @@ snapshots:
|
|||
available-typed-arrays: 1.0.7
|
||||
call-bind: 1.0.7
|
||||
for-each: 0.3.3
|
||||
gopd: 1.1.0
|
||||
gopd: 1.2.0
|
||||
has-tostringtag: 1.0.2
|
||||
|
||||
which@2.0.2:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue