Add font and layout settings UI

This commit is contained in:
chrox
2024-11-01 20:49:53 +01:00
parent dc5e9b8ab9
commit bac1c6648b
16 changed files with 592 additions and 19 deletions
+23 -6
View File
@@ -81,10 +81,6 @@ foliate-view {
padding: 10px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
border-radius: 14px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
font-weight: 300;
@apply text-gray-700;
}
.dropdown-content::before,
@@ -111,7 +107,7 @@ foliate-view {
.dropdown-left::before,
.dropdown-left::after {
left: 20px;
left: 16px;
transform: translateX(0);
}
@@ -123,7 +119,7 @@ foliate-view {
.dropdown-right::before,
.dropdown-right::after {
right: 20px;
right: 16px;
transform: translateX(0);
}
@@ -131,3 +127,24 @@ foliate-view {
.dropdown-content.no-triangle::after {
display: none;
}
.modal, .dropdown-content {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
font-weight: 400;
@apply text-gray-700;
}
.config-item {
@apply flex h-14 items-center justify-between p-4;
@apply hover:bg-gray-50;
}
.config-item-top {
@apply rounded-t-2xl;
}
.config-item-bottom {
@apply rounded-b-2xl;
}
@@ -58,19 +58,19 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
/>
<div className='absolute right-4 flex items-center space-x-4 text-gray-500'>
<span className='mx-2 h-5 w-[1px] bg-gray-300'></span>
<span className='dropdown dropdown-end h-5 cursor-pointer text-gray-500'>
<div className='dropdown dropdown-bottom flex h-5 cursor-pointer justify-center text-gray-500'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip='Add books'>
<PiPlus tabIndex={0} className='h-5 w-5' role='button' />
<PiPlus tabIndex={-1} className='h-5 w-5' />
</div>
<ul
tabIndex={0}
className='dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow'
tabIndex={-1}
className='dropdown-content dropdown-center menu bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow'
>
<li>
<button onClick={onImportBooks}>From Local File</button>
</li>
</ul>
</span>
</div>
<button onClick={onToggleSelectMode} aria-label='Select Multiple Books' className='h-6'>
<div className='lg:tooltip lg:tooltip-bottom cursor-pointer' data-tip='Select books'>
<PiSelectionAllDuotone
@@ -7,6 +7,7 @@ import PageInfo from './PageInfo';
import FooterBar from './FooterBar';
import SectionInfo from './SectionInfo';
import getGridTemplate from '@/utils/grid';
import SettingsDialog from './settings/SettingsDialog';
interface BookGridProps {
bookKeys: string[];
@@ -16,6 +17,7 @@ interface BookGridProps {
const BookGrid: React.FC<BookGridProps> = ({ bookKeys, bookStates, onCloseBook }) => {
const { isSideBarPinned, isSideBarVisible, sideBarWidth } = useReaderStore();
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useReaderStore();
const gridWidth = isSideBarPinned && isSideBarVisible ? `calc(100% - ${sideBarWidth})` : '100%';
const gridTemplate = getGridTemplate(bookKeys.length, window.innerWidth / window.innerHeight);
@@ -42,6 +44,7 @@ const BookGrid: React.FC<BookGridProps> = ({ bookKeys, bookStates, onCloseBook }
bookTitle={book.title}
isHoveredAnim={bookStates.length > 2}
onCloseBook={onCloseBook}
onSetSettingsDialogOpen={setFontLayoutSettingsDialogOpen}
/>
<FoliateViewer bookKey={bookKey} bookDoc={bookState.bookDoc!} bookConfig={config} />
{config.viewSettings!.scrolled ? null : (
@@ -56,6 +59,7 @@ const BookGrid: React.FC<BookGridProps> = ({ bookKeys, bookStates, onCloseBook }
</>
)}
<FooterBar bookKey={bookKey} progress={progress} isHoveredAnim={false} />
{isFontLayoutSettingsDialogOpen && <SettingsDialog bookKey={bookKey} config={config} />}
</div>
);
})}
@@ -13,6 +13,7 @@ interface HeaderBarProps {
bookTitle: string;
isHoveredAnim: boolean;
onCloseBook: (bookKey: string) => void;
onSetSettingsDialogOpen: (open: boolean) => void;
}
const HeaderBar: React.FC<HeaderBarProps> = ({
@@ -20,6 +21,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
bookTitle,
isHoveredAnim,
onCloseBook,
onSetSettingsDialogOpen,
}) => {
const headerRef = useRef<HTMLDivElement>(null);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
@@ -61,14 +63,14 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
<h2 className='line-clamp-1 max-w-[80%] text-center text-xs font-semibold'>{bookTitle}</h2>
</div>
<div className='ml-auto flex h-full items-center space-x-2'>
<div className='flex h-full items-center space-x-2'>
<Dropdown
className='dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeVerticalBold size={16} />}
onToggle={setIsDropdownOpen}
>
<ViewMenu bookKey={bookKey} />
<ViewMenu bookKey={bookKey} onSetSettingsDialogOpen={onSetSettingsDialogOpen} />
</Dropdown>
<WindowButtons
@@ -9,9 +9,14 @@ import { useReaderStore } from '@/store/readerStore';
interface ViewMenuProps {
bookKey: string;
toggleDropdown?: () => void;
onSetSettingsDialogOpen: (open: boolean) => void;
}
const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey }) => {
const ViewMenu: React.FC<ViewMenuProps> = ({
bookKey,
toggleDropdown,
onSetSettingsDialogOpen,
}) => {
const { books, setConfig, getFoliateView } = useReaderStore();
const bookState = books[bookKey]!;
const config = bookState.config!;
@@ -29,7 +34,8 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey }) => {
const toggleInvertedColors = () => setInvertedColors(!isInvertedColors);
const openFontLayoutMenu = () => {
// open font layout menu
toggleDropdown?.();
onSetSettingsDialogOpen(true);
};
useEffect(() => {
@@ -0,0 +1,36 @@
import React, { useState } from 'react';
const ColorPanel: React.FC = () => {
const [selectedTheme, setSelectedTheme] = useState('Default');
const themes = [
'Default',
'Gray',
'Sepia',
'Grass',
'Cherry',
'Sky',
'Nord',
'Solarized',
'Gruvbox',
];
return (
<div>
<h2 className='text-lg font-bold'>Color Settings</h2>
{/* Theme Selection */}
<div className='mt-4 grid grid-cols-3 gap-2'>
{themes.map((theme) => (
<button
key={theme}
className={`btn ${selectedTheme === theme ? 'btn-active' : ''}`}
onClick={() => setSelectedTheme(theme)}
>
{theme}
</button>
))}
</div>
</div>
);
};
export default ColorPanel;
@@ -0,0 +1,39 @@
import React from 'react';
import { useReaderStore } from '@/store/readerStore';
import { MdCheck } from 'react-icons/md';
interface DialogMenuProps {
toggleDropdown?: () => void;
}
const DialogMenu: React.FC<DialogMenuProps> = ({ toggleDropdown }) => {
const { isFontLayoutSettingsGlobal, setFontLayoutSettingsGlobal } = useReaderStore();
const handleToggleGlobal = () => {
setFontLayoutSettingsGlobal(!isFontLayoutSettingsGlobal);
toggleDropdown?.();
};
return (
<div
tabIndex={0}
className='dropdown-content dropdown-right no-triangle z-20 mt-1 w-44 border bg-white shadow-2xl'
>
<button
className='flex w-full items-center justify-between rounded-md p-2 hover:bg-gray-100'
onClick={handleToggleGlobal}
>
<div className='flex items-center'>
<span style={{ minWidth: '20px' }}>
{isFontLayoutSettingsGlobal && <MdCheck size={20} className='text-base-content' />}
</span>
<div className='tooltip' data-tip='Uncheck for current book settings'>
<span className='ml-2'>Global Settings</span>
</div>
</div>
</button>
</div>
);
};
export default DialogMenu;
@@ -0,0 +1,48 @@
import React from 'react';
import { FiChevronDown } from 'react-icons/fi';
import { MdCheck } from 'react-icons/md';
interface DropdownProps {
family?: string;
selected: string;
options: string[];
onSelect: (option: string) => void;
onGetFontFamily: (option: string, family: string) => string;
}
const FontDropdown: React.FC<DropdownProps> = ({
family,
selected,
options,
onSelect,
onGetFontFamily,
}) => {
return (
<div className='dropdown dropdown-end'>
<button
tabIndex={0}
className='btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case'
>
<span style={{ fontFamily: onGetFontFamily(selected, family ?? '') }}>{selected}</span>
<FiChevronDown className='h-4 w-4' />
</button>
<ul
tabIndex={0}
className='dropdown-content dropdown-right menu bg-base-100 rounded-box z-[1] mt-4 w-44 shadow'
>
{options.map((option) => (
<li key={option} onClick={() => onSelect(option)}>
<div className='flex items-center px-0'>
<span style={{ minWidth: '20px' }}>
{selected === option && <MdCheck size={20} className='text-base-content' />}
</span>
<span style={{ fontFamily: onGetFontFamily(option, family ?? '') }}>{option}</span>
</div>
</li>
))}
</ul>
</div>
);
};
export default FontDropdown;
@@ -0,0 +1,155 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import NumberInput from './NumberInput';
import FontDropdown from './FontDropDown';
interface FontFaceProps {
className?: string;
family: string;
label: string;
options: string[];
selected: string;
onSelect: (option: string) => void;
}
const fontFamilyOptions = ['Serif', 'Sans-serif'];
const serifFonts = [
'Literata',
'Vollkorn',
'Aleo',
'Crimson Text',
'Merriweather',
'Georgia',
'Times New Roman',
];
const sansSerifFonts = ['Roboto', 'Open Sans', 'Noto Sans', 'Poppins', 'Helvetica', 'Arial'];
const monospaceFonts = ['Fira Code', 'Lucida Console', 'Consolas', 'Courier New'];
const FontPanel: React.FC = () => {
const [defaultFontSize, setDefaultFontSize] = useState(18);
const [minFontSize, setMinFontSize] = useState(1);
const [overridePublisherFont, setOverridePublisherFont] = useState(true);
const [defaultFont, setDefaultFont] = useState('Serif');
const [serifFont, setSerifFont] = useState('Georgia');
const [sansSerifFont, setSansSerifFont] = useState('Roboto');
const [monospaceFont, setMonospaceFont] = useState('Consolas');
const handleFontFamilyFont = (option: string) => {
switch (option) {
case 'Serif':
return `'${serifFont}', serif`;
case 'Sans-serif':
return `'${sansSerifFont}', sans-serif`;
case 'Monospace':
return `'${monospaceFont}', monospace`;
default:
return '';
}
};
const handleFontFaceFont = (option: string, family: string) => {
return `'${option}', ${family}`;
};
const FontFace = ({ className, family, label, options, selected, onSelect }: FontFaceProps) => (
<div className={clsx('config-item', className)}>
<span className='text-gray-700'>{label}</span>
<FontDropdown
family={family}
options={options}
selected={selected}
onSelect={onSelect}
onGetFontFamily={handleFontFaceFont}
/>
</div>
);
return (
<div className='my-4 w-full space-y-6'>
<div className='cell-font-size w-full'>
<h2 className='mb-2 font-medium'>Font Size</h2>
<div className='card bg-base-100 border shadow'>
<div className='divide-y'>
<NumberInput
className='config-item-top'
label='Default Font Size'
value={defaultFontSize}
onChange={setDefaultFontSize}
min={1}
max={120}
/>
<NumberInput
className='config-item-bottom'
label='Minimum Font Size'
value={minFontSize}
onChange={setMinFontSize}
min={1}
max={120}
/>
</div>
</div>
</div>
<div className='cell-font-family w-full'>
<h2 className='mb-2 font-medium'>Font Family</h2>
<div className='card bg-base-100 border shadow'>
<div className='divide-y'>
<div className='config-item config-item-top'>
<span className='text-gray-700'>Default Font</span>
<FontDropdown
options={fontFamilyOptions}
selected={defaultFont}
onSelect={setDefaultFont}
onGetFontFamily={handleFontFamilyFont}
/>
</div>
<div className='config-item config-item-bottom'>
<span className='text-gray-700'>Override Publisher Font</span>
<input
type='checkbox'
className='toggle'
checked={overridePublisherFont}
onChange={() => setOverridePublisherFont(!overridePublisherFont)}
/>
</div>
</div>
</div>
</div>
<div className='cell-font-family w-full'>
<h2 className='mb-2 font-medium'>Font Face</h2>
<div className='card bg-base-100 border shadow'>
<div className='divide-y'>
<FontFace
className='config-item-top'
family='serif'
label='Serif Font'
options={serifFonts}
selected={serifFont}
onSelect={setSerifFont}
/>
<FontFace
family='sans-serif'
label='Sans-Serif Font'
options={sansSerifFonts}
selected={sansSerifFont}
onSelect={setSansSerifFont}
/>
<FontFace
className='config-item-bottom'
family='monospace'
label='Monospace Font'
options={monospaceFonts}
selected={monospaceFont}
onSelect={setMonospaceFont}
/>
</div>
</div>
</div>
</div>
);
};
export default FontPanel;
@@ -0,0 +1,92 @@
import React, { useState } from 'react';
import NumberInput from './NumberInput';
const LayoutPanel: React.FC = () => {
const [lineHeight, setLineHeight] = useState(1.5);
const [fullJustification, setFullJustification] = useState(true);
const [hyphenation, setHyphenation] = useState(true);
const [margins, setMargins] = useState(0.05);
const [maxNumberOfColumns, setMaxNumberOfColumns] = useState(2);
const [maxInlineSize, setMaxInlineSize] = useState(720);
const [maxBlockSize, setMaxBlockSize] = useState(1440);
return (
<div className='my-4 w-full space-y-6'>
<div className='cell-font-size w-full'>
<h2 className='mb-2 font-medium'>Paragraph</h2>
<div className='card bg-base-100 border shadow'>
<div className='divide-y'>
<NumberInput
className='config-item-top'
label='Line Height'
value={lineHeight}
onChange={setLineHeight}
min={1}
max={120}
/>
<div className='config-item config-item-bottom'>
<span className='text-gray-700'>Full Justification</span>
<input
type='checkbox'
className='toggle'
checked={fullJustification}
onChange={() => setFullJustification(!fullJustification)}
/>
</div>
<div className='config-item config-item-bottom'>
<span className='text-gray-700'>Hyphenation</span>
<input
type='checkbox'
className='toggle'
checked={hyphenation}
onChange={() => setHyphenation(!hyphenation)}
/>
</div>
</div>
</div>
</div>
<div className='cell-font-family w-full'>
<h2 className='mb-2 font-medium'>Page</h2>
<div className='card bg-base-100 border shadow'>
<div className='divide-y'>
<NumberInput
className='config-item-top'
label='Margins'
value={margins}
onChange={setMargins}
min={0}
max={0.3}
step={0.01}
/>
<NumberInput
label='Maximum Number of Columns'
value={maxNumberOfColumns}
onChange={setMaxNumberOfColumns}
min={1}
max={2}
/>
<NumberInput
label='Maximum Inline Size'
value={maxInlineSize}
onChange={setMaxInlineSize}
min={500}
max={9999}
step={100}
/>
<NumberInput
label='Maximum Block Size'
value={maxBlockSize}
onChange={setMaxBlockSize}
min={500}
max={9999}
step={100}
/>
</div>
</div>
</div>
</div>
);
};
export default LayoutPanel;
@@ -0,0 +1,90 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import { FiMinus, FiPlus } from 'react-icons/fi';
interface NumberInputProps {
className?: string;
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (value: number) => void;
}
const NumberInput: React.FC<NumberInputProps> = ({
className,
label,
value,
onChange,
min,
max,
step,
}) => {
const [localValue, setLocalValue] = useState(value);
const numberStep = step || 1;
const handleInput = (e: React.FormEvent<HTMLInputElement>) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value === '' ? 0 : parseInt(e.target.value);
if (!isNaN(newValue)) {
setLocalValue(newValue);
onChange(Math.max(min, Math.min(max, newValue)));
}
};
const increment = () => {
const newValue = Math.min(max, localValue + numberStep);
setLocalValue(newValue);
onChange(newValue);
};
const decrement = () => {
const newValue = Math.max(min, localValue - numberStep);
setLocalValue(newValue);
onChange(newValue);
};
const handleOnBlur = () => {
const newValue = Math.max(min, Math.min(max, localValue));
setLocalValue(newValue);
onChange(newValue);
};
return (
<div className={clsx('config-item', className)}>
<span className='text-gray-700'>{label}</span>
<div className='flex items-center gap-2'>
<input
type='text'
value={localValue}
onChange={handleChange}
onInput={handleInput}
onKeyDown={handleInput}
onKeyUp={handleInput}
onBlur={handleOnBlur}
className='input input-ghost w-20 max-w-xs rounded px-3 py-1 text-right'
onFocus={(e) => e.target.select()}
/>
<button
onClick={decrement}
className={`btn btn-circle btn-sm ${value === min ? 'btn-disabled !bg-opacity-5' : ''}`}
>
<FiMinus className='h-4 w-4' />
</button>
<button
onClick={increment}
className={`btn btn-circle btn-sm ${value === max ? 'btn-disabled !bg-opacity-5' : ''}`}
>
<FiPlus className='h-4 w-4' />
</button>
</div>
</div>
);
};
export default NumberInput;
@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { BookConfig } from '@/types/book';
import { useReaderStore } from '@/store/readerStore';
import { RiFontSize } from 'react-icons/ri';
import { RiDashboardLine } from 'react-icons/ri';
import { VscSymbolColor } from 'react-icons/vsc';
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
import FontPanel from './FontPanel';
import LayoutPanel from './LayoutPanel';
import ColorPanel from './ColorPanel';
import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
import DialogMenu from './DialogMenu';
const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({}) => {
const [activePanel, setActivePanel] = useState('Font');
const { setFontLayoutSettingsDialogOpen } = useReaderStore();
return (
<dialog className='modal modal-open min-w-90 w-full'>
<div className='modal-box flex h-[60%] w-1/2 min-w-96 max-w-full flex-col p-0'>
<div className='dialog-header bg-base-100 sticky top-0 z-10 flex items-center justify-center px-4 pt-2'>
<div className='dialog-tabs flex h-10 max-w-[80%] flex-grow items-center justify-around'>
<button
className={`btn btn-ghost h-8 min-h-8 px-6 ${activePanel === 'Font' ? 'btn-active' : ''}`}
onClick={() => setActivePanel('Font')}
>
<RiFontSize size={20} className='mr-0' />
Font
</button>
<button
className={`btn btn-ghost h-8 min-h-8 px-6 ${activePanel === 'Layout' ? 'btn-active' : ''}`}
onClick={() => setActivePanel('Layout')}
>
<RiDashboardLine size={20} className='mr-0' />
Layout
</button>
<button
className={`btn btn-ghost h-8 min-h-8 px-6 ${activePanel === 'Color' ? 'btn-active' : ''}`}
onClick={() => setActivePanel('Color')}
>
<VscSymbolColor size={20} className='mr-0' />
Color
</button>
</div>
<div className='flex h-full items-center justify-end'>
<Dropdown
className='dropdown-bottom dropdown-end absolute right-12'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeVerticalBold size={16} />}
>
<DialogMenu />
</Dropdown>
<WindowButtons
className='window-buttons absolute right-4 !ml-2 flex h-full items-center'
showMinimize={false}
showMaximize={false}
onClose={() => setFontLayoutSettingsDialogOpen(false)}
/>
</div>
</div>
<div className='mt-2 flex-grow overflow-y-auto px-16'>
{activePanel === 'Font' && <FontPanel />}
{activePanel === 'Layout' && <LayoutPanel />}
{activePanel === 'Color' && <ColorPanel />}
</div>
</div>
</dialog>
);
};
export default SettingsDialog;
+1 -1
View File
@@ -37,7 +37,7 @@ const Dropdown: React.FC<DropdownProps> = ({
};
const handleClickOutside = (event: MouseEvent | Event) => {
if (event instanceof KeyboardEvent) {
if (event instanceof MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
closeDropdown();
}
@@ -3,7 +3,7 @@ import React, { useEffect, useRef } from 'react';
interface WindowButtonsProps {
className?: string;
headerRef: React.RefObject<HTMLDivElement>;
headerRef?: React.RefObject<HTMLDivElement>;
showMinimize?: boolean;
showMaximize?: boolean;
showClose?: boolean;
@@ -41,7 +41,7 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
};
useEffect(() => {
const headerElement = headerRef.current;
const headerElement = headerRef?.current;
headerElement?.addEventListener('mousedown', handleMouseDown);
return () => {
+1 -1
View File
@@ -31,7 +31,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
export const DEFAULT_BOOK_STYLE: BookStyle = {
zoomLevel: 100,
lineHeight: 1.5,
lineHeight: 1.6,
justify: true,
hyphenate: true,
invert: false,
+10
View File
@@ -39,6 +39,11 @@ interface ReaderStore {
setSideBarVisibility: (visible: boolean) => void;
setSideBarPin: (pinned: boolean) => void;
isFontLayoutSettingsDialogOpen: boolean;
isFontLayoutSettingsGlobal: boolean;
setFontLayoutSettingsDialogOpen: (open: boolean) => void;
setFontLayoutSettingsGlobal: (global: boolean) => void;
setLibrary: (books: Book[]) => void;
setSettings: (settings: SystemSettings) => void;
setProgress: (
@@ -101,6 +106,11 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
setSideBarVisibility: (visible: boolean) => set({ isSideBarVisible: visible }),
setSideBarPin: (pinned: boolean) => set({ isSideBarPinned: pinned }),
isFontLayoutSettingsDialogOpen: false,
isFontLayoutSettingsGlobal: true,
setFontLayoutSettingsDialogOpen: (open: boolean) => set({ isFontLayoutSettingsDialogOpen: open }),
setFontLayoutSettingsGlobal: (global: boolean) => set({ isFontLayoutSettingsGlobal: global }),
setLibrary: (books: Book[]) => set({ library: books }),
setSettings: (settings: SystemSettings) => set({ settings }),
setConfig: (key: string, config: BookConfig) => {