Compare commits

...

13 Commits

66 changed files with 1401 additions and 183 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.23",
"version": "0.9.25",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -247,5 +247,16 @@
"Border Color": "لون الحدود",
"Border Frame": "إطار الحدود",
"Show Header": "عرض الرأس",
"Show Footer": "عرض التذييل"
"Show Footer": "عرض التذييل",
"Small": "صغير",
"Large": "كبير",
"Auto": "تلقائي",
"Language": "اللغة",
"No annotations to export": "لا توجد تعليقات للتصدير",
"Author": "المؤلف",
"Exported from Readest": "تم التصدير من ريديست",
"Highlights & Annotations": "التمييزات والتعليقات",
"Note": "ملاحظة",
"Copied to clipboard": "تم النسخ إلى الحافظة",
"Export Annotations": "تصدير التعليقات"
}
@@ -247,5 +247,16 @@
"Border Color": "Randfarbe",
"Border Frame": "Rahmenrand",
"Show Header": "Kopfzeile anzeigen",
"Show Footer": "Fußzeile anzeigen"
"Show Footer": "Fußzeile anzeigen",
"Small": "Klein",
"Large": "Groß",
"Auto": "Automatisch",
"Language": "Sprache",
"No annotations to export": "Keine Anmerkungen zum Exportieren",
"Author": "Autor",
"Exported from Readest": "Exportiert von Readest",
"Highlights & Annotations": "Hervorhebungen & Anmerkungen",
"Note": "Notiz",
"Copied to clipboard": "In die Zwischenablage kopiert",
"Export Annotations": "Anmerkungen exportieren"
}
@@ -247,5 +247,16 @@
"Border Color": "Χρώμα περιγράμματος",
"Border Frame": "Πλαίσιο περιγράμματος",
"Show Header": "Εμφάνιση κεφαλίδας",
"Show Footer": "Εμφάνιση υποσέλιδου"
"Show Footer": "Εμφάνιση υποσέλιδου",
"Small": "Μικρό",
"Large": "Μεγάλο",
"Auto": "Αυτόματο",
"Language": "Γλώσσα",
"No annotations to export": "Δεν υπάρχουν σχολιασμοί για εξαγωγή",
"Author": "Συγγραφέας",
"Exported from Readest": "Εξήχθη από το Readest",
"Highlights & Annotations": "Επισημάνσεις & Σχολιασμοί",
"Note": "Σημείωση",
"Copied to clipboard": "Αντιγράφηκε στο πρόχειρο",
"Export Annotations": "Εξαγωγή σχολιασμών"
}
@@ -247,5 +247,16 @@
"Border Color": "Color del borde",
"Border Frame": "Marco de borde",
"Show Header": "Mostrar encabezado",
"Show Footer": "Mostrar pie de página"
"Show Footer": "Mostrar pie de página",
"Small": "Pequeño",
"Large": "Grande",
"Auto": "Automático",
"Language": "Idioma",
"No annotations to export": "No hay anotaciones para exportar",
"Author": "Autor",
"Exported from Readest": "Exportado desde Readest",
"Highlights & Annotations": "Resaltados y anotaciones",
"Note": "Nota",
"Copied to clipboard": "Copiado al portapapeles",
"Export Annotations": "Exportar anotaciones"
}
@@ -247,5 +247,16 @@
"Border Color": "Couleur de la bordure",
"Border Frame": "Cadre de bordure",
"Show Header": "Afficher l'en-tête",
"Show Footer": "Afficher le pied de page"
"Show Footer": "Afficher le pied de page",
"Small": "Petit",
"Large": "Grand",
"Auto": "Automatique",
"Language": "Langue",
"No annotations to export": "Aucune annotation à exporter",
"Author": "Auteur",
"Exported from Readest": "Exporté depuis Readest",
"Highlights & Annotations": "Surlignages et annotations",
"Note": "Note",
"Copied to clipboard": "Copié dans le presse-papiers",
"Export Annotations": "Exporter les annotations"
}
@@ -247,5 +247,16 @@
"Border Color": "बॉर्डर रंग",
"Border Frame": "बॉर्डर फ्रेम",
"Show Header": "हेडर दिखाएं",
"Show Footer": "फ़ुटर दिखाएं"
"Show Footer": "फ़ुटर दिखाएं",
"Small": "छोटा",
"Large": "बड़ा",
"Auto": "स्वचालित",
"Language": "भाषा",
"No annotations to export": "निर्यात करने के लिए कोई टिप्पणियाँ नहीं हैं",
"Author": "लेखक",
"Exported from Readest": "Readest से निर्यात किया गया",
"Highlights & Annotations": "हाइलाइट और टिप्पणियाँ",
"Note": "टिप्पणी",
"Copied to clipboard": "क्लिपबोर्ड पर कॉपी किया गया",
"Export Annotations": "टिप्पणियाँ निर्यात करें"
}
@@ -247,5 +247,16 @@
"Border Color": "Warna Garis",
"Border Frame": "Bingkai Garis",
"Show Header": "Tampilkan Header",
"Show Footer": "Tampilkan Footer"
"Show Footer": "Tampilkan Footer",
"Small": "Kecil",
"Large": "Besar",
"Auto": "Otomatis",
"Language": "Bahasa",
"No annotations to export": "Tidak ada anotasi yang diekspor",
"Author": "Penulis",
"Exported from Readest": "Diekspor dari Readest",
"Highlights & Annotations": "Sorotan & Anotasi",
"Note": "Catatan",
"Copied to clipboard": "Disalin ke papan klip",
"Export Annotations": "Ekspor Anotasi"
}
@@ -247,5 +247,16 @@
"Border Color": "Colore bordo",
"Border Frame": "Cornice bordo",
"Show Header": "Mostra intestazione",
"Show Footer": "Mostra piè di pagina"
"Show Footer": "Mostra piè di pagina",
"Small": "Piccolo",
"Large": "Grande",
"Auto": "Automatico",
"Language": "Lingua",
"No annotations to export": "Nessuna annotazione da esportare",
"Author": "Autore",
"Exported from Readest": "Esportato da Readest",
"Highlights & Annotations": "Evidenziazioni e annotazioni",
"Note": "Nota",
"Copied to clipboard": "Copiato negli appunti",
"Export Annotations": "Esporta annotazioni"
}
@@ -247,5 +247,16 @@
"Border Color": "ボーダーカラー",
"Border Frame": "ボーダーフレーム",
"Show Header": "ヘッダーを表示",
"Show Footer": "フッターを表示"
"Show Footer": "フッターを表示",
"Small": "小",
"Large": "大",
"Auto": "自動",
"Language": "言語",
"No annotations to export": "エクスポートする注釈はありません",
"Author": "著者",
"Exported from Readest": "Readestからエクスポート",
"Highlights & Annotations": "ハイライトと注釈",
"Note": "メモ",
"Copied to clipboard": "クリップボードにコピー",
"Export Annotations": "注釈をエクスポート"
}
@@ -247,5 +247,16 @@
"Border Color": "테두리 색상",
"Border Frame": "테두리 프레임",
"Show Header": "헤더 표시",
"Show Footer": "푸터 표시"
"Show Footer": "푸터 표시",
"Small": "작음",
"Large": "큼",
"Auto": "자동",
"Language": "언어",
"No annotations to export": "내보낼 주석이 없습니다",
"Author": "저자",
"Exported from Readest": "Readest에서 내보냄",
"Highlights & Annotations": "하이라이트 및 주석",
"Note": "노트",
"Copied to clipboard": "클립보드에 복사",
"Export Annotations": "주석 내보내기"
}
@@ -247,5 +247,16 @@
"Border Color": "Kolor ramki",
"Border Frame": "Ramka",
"Show Header": "Pokaż nagłówek",
"Show Footer": "Pokaż stopkę"
"Show Footer": "Pokaż stopkę",
"Small": "Mały",
"Large": "Duży",
"Auto": "Automatyczny",
"Language": "Język",
"No annotations to export": "Brak adnotacji do eksportu",
"Author": "Autor",
"Exported from Readest": "Eksportowane z Readest",
"Highlights & Annotations": "Zaznaczenia i adnotacje",
"Note": "Notatka",
"Copied to clipboard": "Skopiowano do schowka",
"Export Annotations": "Eksportuj adnotacje"
}
@@ -247,5 +247,16 @@
"Border Color": "Cor da Borda",
"Border Frame": "Moldura da Borda",
"Show Header": "Mostrar Cabeçalho",
"Show Footer": "Mostrar Rodapé"
"Show Footer": "Mostrar Rodapé",
"Small": "Pequeno",
"Large": "Grande",
"Auto": "Automático",
"Language": "Idioma",
"No annotations to export": "Nenhuma anotação para exportar",
"Author": "Autor",
"Exported from Readest": "Exportado do Readest",
"Highlights & Annotations": "Destaques e Anotações",
"Note": "Nota",
"Copied to clipboard": "Copiado para a área de transferência",
"Export Annotations": "Exportar Anotações"
}
@@ -247,5 +247,16 @@
"Border Color": "Цвет границы",
"Border Frame": "Граница рамки",
"Show Header": "Показать заголовок",
"Show Footer": "Показать нижний колонтитул"
"Show Footer": "Показать нижний колонтитул",
"Small": "Маленький",
"Large": "Большой",
"Auto": "Авто",
"Language": "Язык",
"No annotations to export": "Нет аннотаций для экспорта",
"Author": "Автор",
"Exported from Readest": "Экспортировано из Readest",
"Highlights & Annotations": "Выделения и аннотации",
"Note": "Заметка",
"Copied to clipboard": "Скопировано в буфер обмена",
"Export Annotations": "Экспортировать аннотации"
}
@@ -247,5 +247,16 @@
"Border Color": "Sınır Rengi",
"Border Frame": "Sınır Çerçevesi",
"Show Header": "Başlık Göster",
"Show Footer": "Altbilgi Göster"
"Show Footer": "Altbilgi Göster",
"Small": "Küçük",
"Large": "Büyük",
"Auto": "Otomatik",
"Language": "Dil",
"No annotations to export": "Dışa aktarılacak not yok",
"Author": "Yazar",
"Exported from Readest": "Readest'ten dışa aktarıldı",
"Highlights & Annotations": "Vurgular ve Notlar",
"Note": "Not",
"Copied to clipboard": "Panoya kopyalandı",
"Export Annotations": "Notları Dışa Aktar"
}
@@ -247,5 +247,16 @@
"Border Color": "Колір рамки",
"Border Frame": "Рамка рамки",
"Show Header": "Показати заголовок",
"Show Footer": "Показати нижню частину"
"Show Footer": "Показати нижню частину",
"Small": "Малий",
"Large": "Великий",
"Auto": "Автоматичний",
"Language": "Мова",
"No annotations to export": "Немає анотацій для експорту",
"Author": "Автор",
"Exported from Readest": "Експортовано з Readest",
"Highlights & Annotations": "Виділення та анотації",
"Note": "Примітка",
"Copied to clipboard": "Скопійовано в буфер обміну",
"Export Annotations": "Експортувати анотації"
}
@@ -247,5 +247,16 @@
"Border Color": "Màu viền",
"Border Frame": "Khung viền",
"Show Header": "Hiển thị tiêu đề",
"Show Footer": "Hiển thị chân trang"
"Show Footer": "Hiển thị chân trang",
"Small": "Nhỏ",
"Large": "Lớn",
"Auto": "Tự động",
"Language": "Ngôn ngữ",
"No annotations to export": "Không có chú thích để xuất",
"Author": "Tác giả",
"Exported from Readest": "Đã xuất từ Readest",
"Highlights & Annotations": "Điểm nổi bật & Chú thích",
"Note": "Ghi chú",
"Copied to clipboard": "Đã sao chép vào bảng ghi",
"Export Annotations": "Xuất chú thích"
}
@@ -247,5 +247,16 @@
"Border Color": "版框颜色",
"Border Frame": "页面版框",
"Show Header": "显示页眉",
"Show Footer": "显示页脚"
"Show Footer": "显示页脚",
"Small": "小",
"Large": "大",
"Auto": "自动",
"Language": "语言",
"No annotations to export": "没有要导出的笔记",
"Author": "作者",
"Exported from Readest": "从 Readest 导出",
"Highlights & Annotations": "划线和笔记",
"Note": "笔记",
"Copied to clipboard": "已复制到剪贴板",
"Export Annotations": "导出笔记"
}
@@ -247,5 +247,16 @@
"Border Color": "邊框顏色",
"Border Frame": "頁面邊框",
"Show Header": "顯示頁眉",
"Show Footer": "顯示頁腳"
"Show Footer": "顯示頁腳",
"Small": "小",
"Large": "大",
"Auto": "自動",
"Language": "語言",
"No annotations to export": "沒有要匯出的筆記",
"Author": "作者",
"Exported from Readest": "從 Readest 匯出",
"Highlights & Annotations": "高亮和筆記",
"Note": "筆記",
"Copied to clipboard": "已複製到剪貼板",
"Export Annotations": "匯出筆記"
}
+10
View File
@@ -1,5 +1,15 @@
{
"releases": {
"0.9.25": {
"date": "2025-03-24",
"notes": [
"Support exporting annotations in markdown format",
"Support importing TXT files",
"Support setting reader language in the UI",
"Add bottom action bar on mobile platforms",
"Various fixes on layout settings and styles"
]
},
"0.9.23": {
"date": "2025-03-19",
"notes": [
@@ -81,7 +81,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
/>
<FoliateViewer bookKey={bookKey} bookDoc={bookDoc} config={config} />
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
{viewSettings.vertical && (
{viewSettings.vertical && viewSettings.scrolled && (
<>
<div
className='bg-base-100 absolute left-0 top-0 h-full'
@@ -112,6 +112,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
<SectionInfo
section={sectionLabel}
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
isScrolled={viewSettings.scrolled}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
@@ -130,6 +131,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
section={section}
pageinfo={pageinfo}
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
isScrolled={viewSettings.scrolled}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
@@ -9,7 +9,7 @@ import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import { applyColorScheme, getStyles, mountAdditionalFonts } from '@/utils/style';
import { getStyles, mountAdditionalFonts } from '@/utils/style';
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
import { useUICSS } from '@/hooks/useUICSS';
import {
@@ -35,7 +35,7 @@ const FoliateViewer: React.FC<{
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
const { getViewSettings, setViewSettings } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { themeCode, isDarkMode, getIsDarkMode } = useThemeStore();
const { themeCode, isDarkMode } = useThemeStore();
const viewSettings = getViewSettings(bookKey)!;
const [toastMessage, setToastMessage] = useState('');
@@ -64,7 +64,6 @@ const FoliateViewer: React.FC<{
viewSettings.rtl = writingDir?.rtl || viewSettings.writingMode.includes('rl') || false;
setViewSettings(bookKey, { ...viewSettings });
applyColorScheme(detail.doc, getIsDarkMode());
mountAdditionalFonts(detail.doc);
if (!detail.doc.isEventListenersAdded) {
@@ -123,9 +122,6 @@ const FoliateViewer: React.FC<{
if (viewRef.current && viewRef.current.renderer) {
const viewSettings = getViewSettings(bookKey)!;
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
// Safari does not recognize color-scheme from the root document in iframe
const doc = viewRef.current.renderer.getContents()[0]!.doc;
applyColorScheme(doc, isDarkMode);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode, isDarkMode]);
@@ -3,14 +3,25 @@ import clsx from 'clsx';
import { RiArrowLeftWideLine, RiArrowRightWideLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { FaHeadphones } from 'react-icons/fa6';
import { IoIosList as TOCIcon } from 'react-icons/io';
import { PiNotePencil as NoteIcon } from 'react-icons/pi';
import { RxSlider as SliderIcon } from 'react-icons/rx';
import { RiFontFamily as FontIcon } from 'react-icons/ri';
import { MdOutlineHeadphones as TTSIcon } from 'react-icons/md';
import { TbBoxMargin } from 'react-icons/tb';
import { RxLineHeight } from 'react-icons/rx';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { eventDispatcher } from '@/utils/event';
import { saveViewSettings } from '../utils/viewSettingsHelper';
import { PageInfo } from '@/types/book';
import { getStyles } from '@/utils/style';
import Button from '@/components/Button';
import Slider from '@/components/Slider';
interface FooterBarProps {
bookKey: string;
@@ -28,17 +39,44 @@ const FooterBar: React.FC<FooterBarProps> = ({
isHoveredAnim,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { envConfig, appService } = useEnv();
const { hoveredBookKey, setHoveredBookKey, getView, getProgress, getViewSettings } =
useReaderStore();
const { isSideBarVisible } = useSidebarStore();
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
const [actionTab, setActionTab] = React.useState('');
const sliderHeight = useResponsiveSize(28);
const tocIconSize = useResponsiveSize(23);
const fontIconSize = useResponsiveSize(18);
const marginIconSize = useResponsiveSize(20);
const view = getView(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const handleProgressChange = (event: React.ChangeEvent) => {
const newProgress = parseInt((event.target as HTMLInputElement).value, 10);
view?.goToFraction(newProgress / 100.0);
const handleProgressChange = (value: number) => {
view?.goToFraction(value / 100.0);
};
const handleFontSizeChange = (value: number) => {
saveViewSettings(envConfig, bookKey, 'defaultFontSize', value);
view?.renderer.setStyles?.(getStyles(viewSettings!));
};
const handleMarginChange = (value: number) => {
const marginPx = Math.round((value / 100) * 88);
const gapPercent = Math.round((value / 100) * 10);
saveViewSettings(envConfig, bookKey, 'marginPx', marginPx);
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent);
view?.renderer.setAttribute('margin', `${marginPx}px`);
view?.renderer.setAttribute('gap', `${gapPercent}%`);
if (viewSettings?.scrolled) {
view?.renderer.setAttribute('flow', 'scrolled');
}
};
const handleLineHeightChange = (value: number) => {
saveViewSettings(envConfig, bookKey, 'lineHeight', value / 10);
view?.renderer.setStyles?.(getStyles(viewSettings!));
};
const handleGoPrev = () => {
@@ -67,6 +105,31 @@ const FooterBar: React.FC<FooterBarProps> = ({
}
};
const handleSetActionTab = (tab: string) => {
console.log('handleSetActionTab', tab);
setActionTab(actionTab === tab ? '' : tab);
if (tab === 'tts') {
setHoveredBookKey('');
handleSpeakText();
} else if (tab === 'toc') {
setHoveredBookKey('');
if (viewSettings) {
viewSettings.sideBarTab = 'toc';
}
setSideBarVisible(true);
} else if (tab === 'note') {
setHoveredBookKey('');
setSideBarVisible(true);
if (viewSettings) {
viewSettings.sideBarTab = 'annotations';
}
}
};
const getMarginProgressValue = (marginPx: number, gapPercent: number) => {
return (marginPx / 88 + gapPercent / 10) * 50;
};
const isVisible = hoveredBookKey === bookKey;
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
const progressValid = !!progressInfo;
@@ -78,59 +141,181 @@ const FooterBar: React.FC<FooterBarProps> = ({
<>
<div
className={clsx(
'absolute bottom-0 left-0 z-10 h-12 w-full',
viewSettings?.vertical && viewSettings?.scrolled ? 'bottom-3 !h-6' : '',
'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',
)}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
/>
<div
className={clsx(
'footer-bar absolute bottom-0 z-10 flex h-12 w-full items-center gap-x-4 px-4',
'shadow-xs bg-base-100 transition-opacity duration-300',
appService?.hasSafeAreaInset && 'pb-[env(safe-area-inset-bottom)]',
'footer-bar shadow-xs absolute bottom-0 z-50 flex w-full flex-col',
'sm:h-[52px] sm:justify-center',
'sm:bg-base-100 border-base-300/50 border sm:border-none',
'transition-[opacity,transform] duration-300',
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
viewSettings?.vertical && viewSettings?.scrolled ? 'mb-3 !h-6' : '',
isVisible ? `pointer-events-auto opacity-100` : `pointer-events-none opacity-0`,
// show scroll bar when vertical and scrolled in desktop
viewSettings?.vertical && viewSettings?.scrolled && 'sm:!bottom-3 sm:!h-7',
isVisible
? `pointer-events-auto translate-y-0 opacity-100`
: `pointer-events-none translate-y-full opacity-0 sm:translate-y-0`,
)}
dir={viewSettings?.rtl ? 'rtl' : 'ltr'}
onMouseLeave={() => setHoveredBookKey('')}
onMouseLeave={() => window.innerWidth >= 640 && setHoveredBookKey('')}
aria-hidden={!isVisible}
>
<div className='hidden sm:flex'>
{/* Mobile footer bar */}
<div
className={clsx(
'bg-base-200 absolute bottom-16 flex w-full items-center gap-x-2 px-4 transition-all sm:hidden',
actionTab === 'progress'
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
)}
style={{
bottom: appService?.hasSafeAreaInset
? 'calc(env(safe-area-inset-bottom) + 64px)'
: '64px',
}}
>
<Button
icon={viewSettings?.rtl ? <RiArrowRightWideLine /> : <RiArrowLeftWideLine />}
onClick={viewSettings?.rtl ? handleGoNext : handleGoPrev}
tooltip={viewSettings?.rtl ? _('Go Right') : _('Go Left')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
onClick={handleGoBack}
tooltip={_('Go Back')}
disabled={!view?.history.canGoBack}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoBackLine /> : <RiArrowGoForwardLine />}
onClick={handleGoForward}
tooltip={_('Go Forward')}
disabled={!view?.history.canGoForward}
/>
<Slider
heightPx={sliderHeight}
bubbleLabel={`${Math.round(progressFraction * 100)}%`}
initialValue={progressValid ? progressFraction * 100 : 0}
onChange={(e) => handleProgressChange(e)}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftWideLine /> : <RiArrowRightWideLine />}
onClick={viewSettings?.rtl ? handleGoPrev : handleGoNext}
tooltip={viewSettings?.rtl ? _('Go Left') : _('Go Right')}
/>
</div>
<Button
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
onClick={handleGoBack}
tooltip={_('Go Back')}
disabled={!view?.history.canGoBack}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoBackLine /> : <RiArrowGoForwardLine />}
onClick={handleGoForward}
tooltip={_('Go Forward')}
disabled={!view?.history.canGoForward}
/>
<span className='mx-2 text-center text-sm'>
{progressValid ? `${Math.round(progressFraction * 100)}%` : ''}
</span>
<input
type='range'
className='text-base-content mx-2 w-full'
min={0}
max={100}
value={progressValid ? progressFraction * 100 : 0}
onChange={(e) => handleProgressChange(e)}
/>
<Button icon={<FaHeadphones />} onClick={handleSpeakText} tooltip={_('Speak')} />
<div className='hidden sm:flex'>
<div
className={clsx(
'bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
actionTab === 'font'
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
)}
style={{
bottom: appService?.hasSafeAreaInset
? 'calc(env(safe-area-inset-bottom) + 64px)'
: '64px',
}}
>
<Slider
initialValue={viewSettings?.defaultFontSize ?? 16}
bubbleLabel={`${viewSettings?.defaultFontSize ?? 16}`}
minLabel='A'
maxLabel='A'
minClassName='text-xs'
maxClassName='text-base'
onChange={handleFontSizeChange}
min={8}
max={30}
/>
<div className='flex w-full items-center justify-between gap-x-6'>
<Slider
initialValue={getMarginProgressValue(
viewSettings?.marginPx ?? 44,
viewSettings?.gapPercent ?? 5,
)}
bubbleElement={<TbBoxMargin size={marginIconSize} />}
minLabel={_('Small')}
maxLabel={_('Large')}
step={10}
onChange={handleMarginChange}
/>
<Slider
initialValue={(viewSettings?.lineHeight ?? 1.6) * 10}
bubbleElement={<RxLineHeight size={marginIconSize} />}
minLabel={_('Small')}
maxLabel={_('Large')}
min={8}
max={24}
onChange={handleLineHeightChange}
/>
</div>
</div>
<div
className={clsx(
'bg-base-200 z-50 mt-auto flex w-full justify-between px-8 py-4 sm:hidden',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
>
<Button
icon={<TOCIcon size={tocIconSize} className='' />}
onClick={() => handleSetActionTab('toc')}
/>
<Button icon={<NoteIcon className='' />} onClick={() => handleSetActionTab('note')} />
<Button
icon={<SliderIcon className={clsx(actionTab === 'progress' && 'text-blue-500')} />}
onClick={() => handleSetActionTab('progress')}
/>
<Button
icon={
<FontIcon
size={fontIconSize}
className={clsx(actionTab === 'font' && 'text-blue-500')}
/>
}
onClick={() => handleSetActionTab('font')}
/>
<Button icon={<TTSIcon className='' />} onClick={() => handleSetActionTab('tts')} />
</div>
{/* Desktop footer bar */}
<div className='hidden w-full items-center gap-x-4 px-4 sm:flex'>
<Button
icon={viewSettings?.rtl ? <RiArrowRightWideLine /> : <RiArrowLeftWideLine />}
onClick={viewSettings?.rtl ? handleGoNext : handleGoPrev}
tooltip={viewSettings?.rtl ? _('Go Right') : _('Go Left')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
onClick={handleGoBack}
tooltip={_('Go Back')}
disabled={!view?.history.canGoBack}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoBackLine /> : <RiArrowGoForwardLine />}
onClick={handleGoForward}
tooltip={_('Go Forward')}
disabled={!view?.history.canGoForward}
/>
<span className='mx-2 text-center text-sm'>
{progressValid ? `${Math.round(progressFraction * 100)}%` : ''}
</span>
<input
type='range'
className='text-base-content mx-2 w-full'
min={0}
max={100}
value={progressValid ? progressFraction * 100 : 0}
onChange={(e) =>
handleProgressChange(parseInt((e.target as HTMLInputElement).value, 10))
}
/>
<Button icon={<FaHeadphones />} onClick={handleSpeakText} tooltip={_('Speak')} />
<Button
icon={viewSettings?.rtl ? <RiArrowLeftWideLine /> : <RiArrowRightWideLine />}
onClick={viewSettings?.rtl ? handleGoPrev : handleGoNext}
@@ -26,9 +26,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const [popupPosition, setPopupPosition] = useState<Position | null>();
const [showPopup, setShowPopup] = useState(false);
const { getView, getProgress, getViewSettings } = useReaderStore();
const { getView, getViewSettings } = useReaderStore();
const view = getView(bookKey);
const progress = getProgress(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
const footnoteHandler = new FootnoteHandler();
@@ -36,6 +35,11 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const [responsiveWidth, setResponsiveWidth] = useState(popupWidth);
const [responsiveHeight, setResponsiveHeight] = useState(popupHeight);
const getResponsivePopupSize = (size: number, isVertical: boolean) => {
const maxSize = isVertical ? window.innerWidth / 2 : window.innerHeight / 2;
return Math.min(size, maxSize - popupPadding - 12);
};
useEffect(() => {
const handleBeforeRender = (e: Event) => {
const detail = (e as CustomEvent).detail;
@@ -75,9 +79,9 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
view.addEventListener('relocate', () => {
const { renderer } = view as FoliateView;
if (viewSettings.vertical) {
setResponsiveWidth(Math.min(renderer.viewSize, window.innerWidth / 2));
setResponsiveWidth(getResponsivePopupSize(renderer.viewSize, true));
} else {
setResponsiveHeight(Math.min(renderer.viewSize, window.innerHeight / 2));
setResponsiveHeight(getResponsivePopupSize(renderer.viewSize, false));
}
setShowPopup(true);
});
@@ -92,17 +96,12 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
useEffect(() => {
handleDismissPopup();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress]);
useEffect(() => {
if (viewSettings.vertical) {
setResponsiveWidth(popupHeight);
setResponsiveHeight(Math.max(360, window.innerHeight / 4));
setResponsiveHeight(Math.max(popupWidth, window.innerHeight / 4));
} else {
setResponsiveWidth(Math.max(360, window.innerWidth / 4));
setResponsiveWidth(Math.max(popupWidth, window.innerWidth / 4));
setResponsiveHeight(popupHeight);
}
}, [viewSettings]);
@@ -173,9 +172,9 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
document.body.appendChild(elem);
const popupSize = elem.getBoundingClientRect();
if (viewSettings.vertical) {
setResponsiveWidth(Math.min(popupSize.width, window.innerWidth / 2));
setResponsiveWidth(getResponsivePopupSize(popupSize.width, true));
} else {
setResponsiveHeight(Math.min(popupSize.height, window.innerHeight / 2));
setResponsiveHeight(getResponsivePopupSize(popupSize.height, false));
}
document.body.removeChild(elem);
@@ -192,8 +191,10 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
});
useEffect(() => {
window.addEventListener('resize', handleDismissPopup);
eventDispatcher.on('footnote-popup', handleFootnotePopupEvent);
return () => {
window.removeEventListener('resize', handleDismissPopup);
eventDispatcher.off('footnote-popup', handleFootnotePopupEvent);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -86,8 +86,10 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
onMouseEnter={() => setHoveredBookKey(bookKey)}
onMouseLeave={() => setHoveredBookKey('')}
>
<div className='sidebar-bookmark-toggler bg-base-100 z-20 flex h-full items-center space-x-4'>
<SidebarToggler bookKey={bookKey} />
<div className='sidebar-bookmark-toggler bg-base-100 z-20 flex h-full items-center gap-x-4'>
<div className='hidden sm:flex'>
<SidebarToggler bookKey={bookKey} />
</div>
<BookmarkToggler bookKey={bookKey} />
</div>
@@ -1,5 +1,6 @@
import clsx from 'clsx';
import React from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { PageInfo } from '@/types/book';
@@ -8,6 +9,7 @@ interface PageInfoProps {
section?: PageInfo;
pageinfo?: PageInfo;
showDoubleBorder: boolean;
isScrolled: boolean;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
@@ -18,33 +20,32 @@ const PageInfoView: React.FC<PageInfoProps> = ({
section,
pageinfo,
showDoubleBorder,
isScrolled,
isVertical,
horizontalGap,
verticalMargin,
}) => {
const _ = useTranslation();
const pageInfo =
bookFormat === 'PDF'
? section
? isVertical
? `${section.current + 1} · ${section.total}`
: `${section.current + 1} / ${section.total}`
: ''
: pageinfo
? _(
isVertical ? '{{currentPage}} · {{totalPage}}' : 'Loc. {{currentPage}} / {{totalPage}}',
{
currentPage: (pageinfo.next ?? pageinfo.current) + 1,
totalPage: pageinfo.total,
},
)
: '';
const { appService } = useEnv();
const pageInfo = ['PDF', 'CBZ'].includes(bookFormat)
? section
? isVertical
? `${section.current + 1} · ${section.total}`
: `${section.current + 1} / ${section.total}`
: ''
: pageinfo
? _(isVertical ? '{{currentPage}} · {{totalPage}}' : 'Loc. {{currentPage}} / {{totalPage}}', {
currentPage: (pageinfo.next ?? pageinfo.current) + 1,
totalPage: pageinfo.total,
})
: '';
return (
<div
className={clsx(
'pageinfo absolute bottom-0 flex items-center justify-end',
isVertical ? 'writing-vertical-rl' : 'bg-base-100 h-12 w-full',
isVertical ? 'writing-vertical-rl' : 'h-12 w-full',
isScrolled && 'bg-base-100',
)}
style={
isVertical
@@ -54,7 +55,10 @@ const PageInfoView: React.FC<PageInfoProps> = ({
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
height: `calc(100% - ${verticalMargin * 2}px)`,
}
: { insetInlineEnd: `${horizontalGap}%` }
: {
insetInlineEnd: `${horizontalGap}%`,
paddingBottom: appService?.hasSafeAreaInset ? 'env(safe-area-inset-bottom)' : 0,
}
}
>
<h2 className='text-neutral-content text-right font-sans text-xs font-extralight'>
@@ -4,6 +4,7 @@ import React from 'react';
interface SectionInfoProps {
section?: string;
showDoubleBorder: boolean;
isScrolled: boolean;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
@@ -12,6 +13,7 @@ interface SectionInfoProps {
const SectionInfo: React.FC<SectionInfoProps> = ({
section,
showDoubleBorder,
isScrolled,
isVertical,
horizontalGap,
verticalMargin,
@@ -20,7 +22,8 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
<div
className={clsx(
'sectioninfo absolute flex items-center overflow-hidden',
isVertical ? 'writing-vertical-rl max-h-[85%]' : 'bg-base-100 top-0 h-[44px]',
isVertical ? 'writing-vertical-rl max-h-[85%]' : 'top-0 h-[44px]',
isScrolled && 'bg-base-100',
)}
style={
isVertical
@@ -12,7 +12,7 @@ import { FaHeadphones } from 'react-icons/fa6';
import * as CFI from 'foliate-js/epubcfi.js';
import { Overlayer } from 'foliate-js/overlayer.js';
import { useEnv } from '@/context/EnvContext';
import { BookNote, HighlightColor, HighlightStyle } from '@/types/book';
import { BookNote, BooknoteGroup, HighlightColor, HighlightStyle } from '@/types/book';
import { getOSPlatform, uniqueId } from '@/utils/misc';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
@@ -24,6 +24,7 @@ import { useFoliateEvents } from '../../hooks/useFoliateEvents';
import { useNotesSync } from '../../hooks/useNotesSync';
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { findTocItemBS } from '@/utils/toc';
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
@@ -78,7 +79,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const transPopupHeight = Math.min(360, maxHeight);
const annotPopupWidth = useResponsiveSize(300);
const annotPopupHeight = useResponsiveSize(44);
const androidSelectionHandlerHeight = 8;
const androidSelectionHandlerHeight = 0;
const onLoad = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -240,8 +241,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
};
eventDispatcher.onSync('iframe-single-click', handleSingleClick);
eventDispatcher.on('export-annotations', handleExportMarkdown);
return () => {
eventDispatcher.offSync('iframe-single-click', handleSingleClick);
eventDispatcher.off('export-annotations', handleExportMarkdown);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -438,6 +441,90 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range });
};
const handleExportMarkdown = (event: CustomEvent) => {
const { bookKey: exportBookKey } = event.detail;
if (bookKey !== exportBookKey) return;
const { bookDoc, book } = bookData;
if (!bookDoc || !book || !bookDoc.toc) return;
const { booknotes: allNotes = [] } = config;
const booknotes = allNotes.filter((note) => !note.deletedAt);
if (booknotes.length === 0) {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('No annotations to export'),
className: 'whitespace-nowrap',
timeout: 2000,
});
return;
}
const booknoteGroups: { [href: string]: BooknoteGroup } = {};
for (const booknote of booknotes) {
const tocItem = findTocItemBS(bookDoc.toc ?? [], booknote.cfi);
const href = tocItem?.href || '';
const label = tocItem?.label || '';
const id = tocItem?.id || 0;
if (!booknoteGroups[href]) {
booknoteGroups[href] = { id, href, label, booknotes: [] };
}
booknoteGroups[href].booknotes.push(booknote);
}
Object.values(booknoteGroups).forEach((group) => {
group.booknotes.sort((a, b) => {
return CFI.compare(a.cfi, b.cfi);
});
});
const sortedGroups = Object.values(booknoteGroups).sort((a, b) => {
return a.id - b.id;
});
const lines: string[] = [];
lines.push(`# ${book.title}`);
lines.push(`**${_('Author')}**: ${book.author || ''}`);
lines.push('');
lines.push(`**${_('Exported from Readest')}**: ${new Date().toISOString().slice(0, 10)}`);
lines.push('');
lines.push('---');
lines.push('');
lines.push(`## ${_('Highlights & Annotations')}`);
lines.push('');
for (const group of sortedGroups) {
const chapterTitle = group.label || _('Untitled');
lines.push(`### ${chapterTitle}`);
for (const note of group.booknotes) {
lines.push(`> "${note.text}"`);
if (note.note) {
lines.push(`**${_('Note')}**:: ${note.note}`);
}
lines.push('');
}
lines.push('---');
lines.push('');
}
const markdownContent = lines.join('\n');
navigator.clipboard?.writeText(markdownContent);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Copied to clipboard'),
className: 'whitespace-nowrap',
timeout: 2000,
});
if (appService?.isMobile) return;
const blob = new Blob([markdownContent], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${book.title.replace(/\s+/g, '_')}.md`;
a.click();
URL.revokeObjectURL(url);
};
const selectionAnnotated = selection?.annotated;
const buttons = [
{ tooltipText: _('Copy'), Icon: FiCopy, onClick: handleCopy },
@@ -165,13 +165,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
) : (
<div>
<p className='text-base'>{translation || 'No translation available.'}</p>
<div className='pt-4 text-sm opacity-60'>
Translated by{' '}
<a href='https://www.deepl.com/' target='_blank' rel='noopener noreferrer'>
DeepL
</a>
.
</div>
<div className='pt-4 text-sm opacity-60'>Translated by DeepL.</div>
</div>
)}
</div>
@@ -114,11 +114,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
<main className='flex-grow overflow-y-auto px-2 font-sans'></main>
<footer className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
<div className='flex items-center px-4 py-2 text-sm opacity-60'>
From <a id='link'>Wikipedia</a>, released under the{' '}
<a href='https://en.wikipedia.org/wiki/Wikipedia:Text_of_the_Creative_Commons_Attribution-ShareAlike_4.0_International_License'>
CC BY-SA License
</a>
.
Source: Wikipedia (CC BY-SA)
</div>
</footer>
</div>
@@ -169,8 +169,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
<main className='flex-grow overflow-y-auto p-4 font-sans' />
<footer className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
<div className='flex items-center px-4 py-2 text-sm opacity-60'>
From <a id='link'>Wiktionary</a>, released under the{' '}
<a href='https://creativecommons.org/licenses/by-sa/4.0/'>CC BY-SA License</a>.
Source: Wiktionary (CC BY-SA)
</div>
</footer>
</div>
@@ -0,0 +1,49 @@
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;
}
const DropDown: React.FC<DropDownProps> = ({ selected, options, onSelect }) => {
const iconSize16 = useResponsiveSize(16);
const defaultIconSize = useDefaultIconSize();
return (
<div className='dropdown dropdown-bottom'>
<button
tabIndex={0}
className='btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case'
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',
)}
>
{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;
@@ -1,8 +1,6 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import NumberInput from './NumberInput';
import FontDropdown from './FontDropDown';
import {
ANDROID_FONTS,
IOS_FONTS,
@@ -21,6 +19,8 @@ import { getOSPlatform } from '@/utils/misc';
import { getSysFontsList } from '@/utils/font';
import { isTauriAppPlatform } from '@/services/environment';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import NumberInput from './NumberInput';
import FontDropdown from './FontDropDown';
interface FontFaceProps {
className?: string;
@@ -164,6 +164,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
viewSettings.gapPercent,
Math.ceil(4800 / window.innerWidth),
);
setGapPercent(viewSettings.gapPercent);
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'doubleBorder', doubleBorder);
@@ -176,11 +177,35 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, [borderColor]);
useEffect(() => {
if (showHeader && !viewSettings.vertical) {
viewSettings.marginPx = Math.max(viewSettings.marginPx, 44);
setMarginPx(viewSettings.marginPx);
setViewSettings(bookKey, viewSettings);
} else if (showHeader && viewSettings.vertical) {
viewSettings.gapPercent = Math.max(
viewSettings.gapPercent,
Math.ceil(4800 / window.innerWidth),
);
setGapPercent(viewSettings.gapPercent);
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'showHeader', showHeader);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showHeader]);
useEffect(() => {
if (showFooter && !viewSettings.vertical) {
viewSettings.marginPx = Math.max(viewSettings.marginPx, 44);
setMarginPx(viewSettings.marginPx);
setViewSettings(bookKey, viewSettings);
} else if (showFooter && viewSettings.vertical) {
viewSettings.gapPercent = Math.max(
viewSettings.gapPercent,
Math.ceil(4800 / window.innerWidth),
);
setGapPercent(viewSettings.gapPercent);
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'showFooter', showFooter);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showFooter]);
@@ -301,21 +326,23 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
max={3.0}
step={0.1}
/>
<NumberInput
label={_('Word Spacing')}
value={wordSpacing}
onChange={setWordSpacing}
min={-4}
max={8}
step={0.5}
/>
{langCode !== 'zh' && (
<NumberInput
label={_('Word Spacing')}
value={wordSpacing}
onChange={setWordSpacing}
min={-4}
max={8}
step={0.5}
/>
)}
<NumberInput
label={_('Letter Spacing')}
value={letterSpacing}
onChange={setLetterSpacing}
min={-2}
max={4}
step={0.1}
step={0.5}
/>
<NumberInput
label={_('Text Indent')}
@@ -382,7 +409,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
label={_('Vertical Margins (px)')}
value={marginPx}
onChange={setMarginPx}
min={0}
min={!viewSettings.vertical && (showFooter || showHeader) ? 44 : 0}
max={88}
step={4}
/>
@@ -1,13 +1,16 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import i18n from 'i18next';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStyles } from '@/utils/style';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import { TRANSLATED_LANGS } from '@/services/constants';
import cssbeautify from 'cssbeautify';
import cssValidate from '@/utils/css';
import DropDown from './DropDown';
const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
@@ -69,6 +72,31 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
e.nativeEvent.stopImmediatePropagation();
};
const getCurrentUILangOption = () => {
const uiLanguage = viewSettings.uiLanguage;
return {
option: uiLanguage,
label:
uiLanguage === ''
? _('Auto')
: TRANSLATED_LANGS[uiLanguage as keyof typeof TRANSLATED_LANGS],
};
};
const getUILangOptions = () => {
const langs = TRANSLATED_LANGS as Record<string, string>;
const options = Object.entries(langs).map(([option, label]) => ({ option, label }));
options.sort((a, b) => a.label.localeCompare(b.label));
options.unshift({ option: '', label: _('Auto') });
return options;
};
const handleSelectUILang = (option: string) => {
viewSettings.uiLanguage = option;
saveViewSettings(envConfig, bookKey, 'uiLanguage', option);
i18n.changeLanguage(option ? option : navigator.language);
};
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'animated', animated);
if (animated) {
@@ -91,6 +119,22 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
return (
<div className='my-4 w-full space-y-6'>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Language')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Language')}</span>
<DropDown
selected={getCurrentUILangOption()}
options={getUILangOptions()}
onSelect={handleSelectUILang}
/>
</div>
</div>
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Animation')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { FiMinus, FiPlus } from 'react-icons/fi';
interface NumberInputProps {
@@ -26,6 +26,10 @@ const NumberInput: React.FC<NumberInputProps> = ({
const [localValue, setLocalValue] = useState(value);
const numberStep = step || 1;
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
@@ -72,6 +72,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
onClose={handleClose}
className='modal-open'
boxClassName='sm:min-w-[520px]'
snapHeight={window.innerWidth < 640 ? 0.7 : undefined}
header={
<div className='flex w-full items-center justify-between'>
<button
@@ -5,8 +5,10 @@ import Image from 'next/image';
import MenuItem from '@/components/MenuItem';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { useLibraryStore } from '@/store/libraryStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { isWebAppPlatform } from '@/services/environment';
import { eventDispatcher } from '@/utils/event';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import useBooksManager from '../../hooks/useBooksManager';
@@ -19,6 +21,8 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
const _ = useTranslation();
const { getVisibleLibrary } = useLibraryStore();
const { openParallelView } = useBooksManager();
const { sideBarBookKey } = useSidebarStore();
const handleParallelView = (id: string) => {
openParallelView(id);
setIsDropdownOpen?.(false);
@@ -35,6 +39,10 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
window.open(DOWNLOAD_READEST_URL, '_blank');
setIsDropdownOpen?.(false);
};
const handleExportAnnotations = () => {
eventDispatcher.dispatch('export-annotations', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
};
const isWebApp = isWebAppPlatform();
@@ -49,6 +57,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
<MenuItem label={_('Parallel Read')} noIcon>
<ul className='max-h-60 overflow-y-auto'>
{getVisibleLibrary()
.filter((book) => book.format !== 'PDF' && book.format !== 'CBZ')
.slice(0, 20)
.map((book) => (
<MenuItem
@@ -72,6 +81,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
))}
</ul>
</MenuItem>
<MenuItem label={_('Export Annotations')} noIcon onClick={handleExportAnnotations} />
<MenuItem label={_('Reload Page')} noIcon shortcut='Shift+R' onClick={handleReloadPage} />
<hr className='border-base-200 my-1' />
{isWebApp && <MenuItem label={_('Download Readest')} noIcon onClick={downloadReadest} />}
@@ -4,16 +4,9 @@ import * as CFI from 'foliate-js/epubcfi.js';
import { useBookDataStore } from '@/store/bookDataStore';
import { findTocItemBS } from '@/utils/toc';
import { TOCItem } from '@/libs/document';
import { BookNote, BookNoteType } from '@/types/book';
import { BooknoteGroup, BookNoteType } from '@/types/book';
import BooknoteItem from './BooknoteItem';
interface BooknoteGroup {
id: number;
href: string;
label: string;
booknotes: BookNote[];
}
const BooknoteView: React.FC<{
type: BookNoteType;
bookKey: string;
@@ -38,7 +38,10 @@ const SidebarHeader: React.FC<{
>
<MdArrowBackIosNew size={iconSize22} />
</button>
<button className='btn btn-ghost h-8 min-h-8 w-8 p-0' onClick={onGoToLibrary}>
<button
className='btn btn-ghost hidden h-8 min-h-8 w-8 p-0 sm:flex'
onClick={onGoToLibrary}
>
<GiBookshelf className='fill-base-content' />
</button>
</div>
@@ -68,7 +68,7 @@ const TOCItemView: React.FC<{
aria-selected={isActive ? 'true' : 'false'}
data-href={item.href ? getContentMd5(item.href) : undefined}
className={`flex w-full cursor-pointer items-center rounded-md py-2 ${
isActive ? 'bg-base-300/85 hover:bg-base-300' : 'hover:bg-base-300/85'
isActive ? 'bg-base-300/85 hover:bg-base-300' : 'sm:hover:bg-base-300/85'
}`}
>
{item.subitems && (
@@ -1,6 +1,8 @@
import clsx from 'clsx';
import React from 'react';
import { MdToc, MdEditNote, MdBookmarkBorder } from 'react-icons/md';
import { MdBookmarkBorder as BookmarkIcon } from 'react-icons/md';
import { IoIosList as TOCIcon } from 'react-icons/io';
import { PiNotePencil as NoteIcon } from 'react-icons/pi';
import { useTranslation } from '@/hooks/useTranslation';
@@ -34,13 +36,13 @@ const TabNavigation: React.FC<{
tab === 'toc' ? _('TOC') : tab === 'annotations' ? _('Annotate') : _('Bookmark')
}
>
<div className={clsx('')} onClick={() => onTabChange(tab)}>
<div className={clsx('flex h-6 items-center')} onClick={() => onTabChange(tab)}>
{tab === 'toc' ? (
<MdToc className='mx-auto' />
<TOCIcon className='mx-auto' />
) : tab === 'annotations' ? (
<MdEditNote className='mx-auto' />
<NoteIcon className='mx-auto' />
) : (
<MdBookmarkBorder className='mx-auto' />
<BookmarkIcon className='mx-auto' />
)}
</div>
</div>
@@ -137,7 +137,11 @@ export const useTouchEvent = (
if (hoveredBookKey && touchEnd) {
const deltaY = touchEnd.screenY - touchStart.screenY;
const deltaX = touchEnd.screenX - touchStart.screenX;
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) {
if (!viewSettings!.scrolled && !viewSettings!.vertical) {
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) {
setHoveredBookKey(null);
}
} else {
setHoveredBookKey(null);
}
}
@@ -161,8 +165,10 @@ export const useTouchEvent = (
Math.abs(deltaY) > Math.abs(deltaX) &&
Math.abs(deltaX) < windowWidth * 0.3
) {
// swipe up to toggle the header bar and the footer bar
setHoveredBookKey(hoveredBookKey ? null : bookKey);
// swipe up to toggle the header bar and the footer bar, only for horizontal page mode
if (!viewSettings!.scrolled && !viewSettings!.vertical) {
setHoveredBookKey(hoveredBookKey ? null : bookKey);
}
} else {
if (hoveredBookKey) {
setHoveredBookKey(null);
@@ -187,15 +193,10 @@ export const useTouchEvent = (
};
useEffect(() => {
// swipe touch is not compatible with scrolled mode, so only enable it in page mode
if (!viewSettings!.scrolled) {
window.addEventListener('message', handleTouch);
return () => {
window.removeEventListener('message', handleTouch);
};
} else {
return () => {};
}
window.addEventListener('message', handleTouch);
return () => {
window.removeEventListener('message', handleTouch);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hoveredBookKey, viewRef]);
};
+3 -3
View File
@@ -169,9 +169,9 @@ const ProfilePage = () => {
>
<IoArrowBack className='text-base-content' />
</button>
<div className='w-full max-w-4xl px-6 py-10'>
<div className='w-full max-w-4xl px-4 py-10'>
<div className='bg-base-200 overflow-hidden rounded-lg p-2 shadow-md sm:p-6'>
<div className='p-6'>
<div className='p-2 sm:p-6'>
<div className='mb-8 flex flex-col items-center gap-x-6 gap-y-4 md:flex-row md:items-start'>
<div className='flex-shrink-0'>
{avatarUrl ? (
@@ -202,7 +202,7 @@ const ProfilePage = () => {
</div>
</div>
<div className='bg-base-100 mb-8 rounded-lg p-6'>
<div className='bg-base-100 mb-8 rounded-lg px-4 py-6'>
<h3 className='text-base-content mb-2 text-lg font-semibold'>{_('Features')}</h3>
<div className='mt-6'>
<ul className='text-base-content/60 grid grid-cols-1 gap-2 md:grid-cols-2'>
+46 -5
View File
@@ -7,11 +7,13 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
const VELOCITY_THRESHOLD = 0.5;
const SNAP_THRESHOLD = 0.2;
interface DialogProps {
id?: string;
isOpen: boolean;
children: ReactNode;
snapHeight?: number;
header?: ReactNode;
title?: string;
className?: string;
@@ -25,6 +27,7 @@ const Dialog: React.FC<DialogProps> = ({
id,
isOpen,
children,
snapHeight,
header,
title,
className,
@@ -34,6 +37,7 @@ const Dialog: React.FC<DialogProps> = ({
onClose,
}) => {
const { appService } = useEnv();
const [isFullHeightInMobile, setIsFullHeightInMobile] = React.useState(!snapHeight);
const iconSize22 = useResponsiveSize(22);
const isMobile = window.innerWidth < 640;
@@ -61,9 +65,12 @@ const Dialog: React.FC<DialogProps> = ({
const newTop = Math.max(0.0, Math.min(1, heightFraction));
if (modal && overlay) {
modal.style.transition = '';
modal.style.height = '100%';
modal.style.transform = `translateY(${newTop * 100}%)`;
overlay.style.opacity = `${1 - heightFraction}`;
setIsFullHeightInMobile(data.clientY < 44);
modal.style.transition = `padding-top 0.3s ease-out`;
}
};
@@ -72,11 +79,14 @@ const Dialog: React.FC<DialogProps> = ({
const overlay = document.querySelector('.overlay') as HTMLElement;
if (!modal || !overlay) return;
const snapUpper = snapHeight ? 1 - snapHeight - SNAP_THRESHOLD : 0.5;
const snapLower = snapHeight ? 1 - snapHeight + SNAP_THRESHOLD : 0.5;
if (
data.velocity > VELOCITY_THRESHOLD ||
(data.velocity >= 0 && data.clientY >= window.innerHeight * 0.5)
(data.velocity >= 0 && data.clientY >= window.innerHeight * snapLower)
) {
const transitionDuration = 0.15 / Math.max(data.velocity, 0.5);
modal.style.height = '100%';
modal.style.transition = `transform ${transitionDuration}s ease-out`;
modal.style.transform = 'translateY(100%)';
overlay.style.transition = `opacity ${transitionDuration}s ease-out`;
@@ -88,7 +98,22 @@ const Dialog: React.FC<DialogProps> = ({
if (appService?.hasHaptics) {
impactFeedback('medium');
}
} else if (
snapHeight &&
data.clientY > window.innerHeight * snapUpper &&
data.clientY < window.innerHeight * snapLower
) {
modal.style.transition = `transform 0.3s ease-out`;
modal.style.transform = `translateY(${(1 - snapHeight) * window.innerHeight}px)`;
setTimeout(() => {
modal.style.height = `${snapHeight * 100}%`;
}, 100);
if (appService?.hasHaptics) {
impactFeedback('medium');
}
} else {
setIsFullHeightInMobile(true);
modal.style.height = '100%';
modal.style.transition = `transform 0.3s ease-out`;
modal.style.transform = `translateY(0%)`;
overlay.style.opacity = '0';
@@ -104,9 +129,15 @@ const Dialog: React.FC<DialogProps> = ({
<dialog
id={id ?? 'dialog'}
open={isOpen}
className={clsx('modal sm:min-w-90 z-50 h-full w-full !bg-transparent sm:w-full', className)}
className={clsx(
'modal sm:min-w-90 z-50 h-full w-full !items-start !bg-transparent sm:w-full sm:!items-center',
className,
)}
>
<div className={clsx('overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20', bgClassName)} />
<div
className={clsx('overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20', bgClassName)}
onClick={onClose}
/>
<div
className={clsx(
'modal-box settings-content z-20 flex flex-col rounded-none rounded-tl-2xl rounded-tr-2xl p-0 sm:rounded-2xl',
@@ -114,9 +145,19 @@ const Dialog: React.FC<DialogProps> = ({
window.innerWidth < window.innerHeight
? 'sm:h-[50%] sm:w-3/4'
: 'sm:h-[65%] sm:w-1/2 sm:max-w-[600px]',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)] sm:pt-0',
appService?.hasSafeAreaInset &&
isFullHeightInMobile &&
'pt-[env(safe-area-inset-top)] sm:pt-0',
boxClassName,
)}
style={
snapHeight
? {
height: `${snapHeight * 100}%`,
transform: `translateY(${(1 - snapHeight) * window.innerHeight}px)`,
}
: {}
}
>
{window.innerWidth < 640 && (
<div
@@ -0,0 +1,93 @@
import React, { useEffect, useState } from 'react';
interface SliderProps {
min?: number;
max?: number;
step?: number;
initialValue?: number;
heightPx?: number;
minLabel?: string;
maxLabel?: string;
bubbleElement?: React.ReactNode;
bubbleLabel?: string;
className?: string;
minClassName?: string;
maxClassName?: string;
bubbleClassName?: string;
onChange?: (value: number) => void;
}
const Slider: React.FC<SliderProps> = ({
min = 0,
max = 100,
step = 1,
initialValue = 50,
heightPx = 40,
minLabel = '',
maxLabel = '',
bubbleElement,
bubbleLabel = '',
className = '',
minClassName = '',
maxClassName = '',
bubbleClassName = '',
onChange,
}) => {
const [value, setValue] = useState(initialValue);
const handleChange = (e: React.ChangeEvent) => {
const newValue = parseInt((e.target as HTMLInputElement).value, 10);
setValue(newValue);
if (onChange) {
onChange(newValue);
}
};
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className={`slider bg-base-200 mx-auto w-full max-w-md rounded-xl ${className}`}>
<div className='relative' style={{ height: `${heightPx}px` }}>
<div className='bg-base-300/40 absolute h-full w-full rounded-full'></div>
<div
className='bg-base-300 absolute h-full rounded-full'
style={{ width: percentage > 0 ? `calc(${percentage}% + ${heightPx / 2}px)` : '0px' }}
></div>
<div className='absolute inset-0 flex items-center justify-between px-4 text-sm'>
<span className={`ml-2 ${minClassName}`}>{minLabel}</span>
<span className={`mr-2 ${maxClassName}`}>{maxLabel}</span>
</div>
<div
className='pointer-events-none absolute top-0 z-10'
style={{
left: `max(${heightPx / 2}px, calc(${percentage}%))`,
transform: 'translateX(calc(-50%))',
height: '100%',
}}
>
<div
className={`bg-base-200 flex h-full items-center justify-center rounded-full text-sm shadow-md ${bubbleClassName}`}
style={{ width: `${heightPx}px` }}
>
{bubbleElement || bubbleLabel}
</div>
</div>
<input
type='range'
min={min}
max={max}
step={step}
value={value}
className='absolute inset-0 h-full w-full cursor-pointer opacity-0'
onChange={handleChange}
/>
</div>
</div>
);
};
export default Slider;
+1 -2
View File
@@ -2,7 +2,6 @@ import { useEffect } from 'react';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { applyCustomTheme } from '@/styles/themes';
import { applyColorScheme } from '@/utils/style';
export const useTheme = () => {
const { settings } = useSettingsStore();
@@ -17,6 +16,6 @@ export const useTheme = () => {
useEffect(() => {
const colorScheme = isDarkMode ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', `${themeColor}-${colorScheme}`);
applyColorScheme(document, isDarkMode);
document.documentElement.style.setProperty('color-scheme', colorScheme);
}, [themeColor, isDarkMode]);
};
+27 -2
View File
@@ -99,6 +99,31 @@ export class DocumentLoader {
}
private async makeZipLoader() {
const getComment = async (): Promise<string | null> => {
const EOCD_SIGNATURE = [0x50, 0x4b, 0x05, 0x06];
const maxEOCDSearch = 1024 * 64;
const sliceSize = Math.min(maxEOCDSearch, this.file.size);
const tail = await this.file.slice(this.file.size - sliceSize, this.file.size).arrayBuffer();
const bytes = new Uint8Array(tail);
for (let i = bytes.length - 22; i >= 0; i--) {
if (
bytes[i] === EOCD_SIGNATURE[0] &&
bytes[i + 1] === EOCD_SIGNATURE[1] &&
bytes[i + 2] === EOCD_SIGNATURE[2] &&
bytes[i + 3] === EOCD_SIGNATURE[3]
) {
const commentLength = bytes[i + 20]! + (bytes[i + 21]! << 8);
const commentStart = i + 22;
const commentBytes = bytes.slice(commentStart, commentStart + commentLength);
return new TextDecoder().decode(commentBytes);
}
}
return null;
};
const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } = await import(
'@zip.js/zip.js'
);
@@ -120,7 +145,7 @@ export class DocumentLoader {
);
const getSize = (name: string) => map.get(name)?.uncompressedSize ?? 0;
return { entries, loadText, loadBlob, getSize, sha1: undefined };
return { entries, loadText, loadBlob, getSize, getComment, sha1: undefined };
}
private isCBZ(): boolean {
@@ -155,7 +180,7 @@ export class DocumentLoader {
if (this.isCBZ()) {
const { makeComicBook } = await import('foliate-js/comic-book.js');
book = makeComicBook(loader, this.file);
book = await makeComicBook(loader, this.file);
format = 'CBZ';
} else if (this.isFBZ()) {
const entry = entries.find((entry) => entry.filename.endsWith(`.${EXTS.FB2}`));
+10 -2
View File
@@ -36,6 +36,7 @@ import { getOSPlatform, isCJKEnv, isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { downloadFile, uploadFile, deleteFile, createProgressHandler } from '@/libs/storage';
import { ProgressHandler } from '@/utils/transfer';
import { TxtToEpubConverter } from '@/utils/txt';
import { BOOK_FILE_NOT_FOUND_ERROR } from './errors';
export abstract class BaseAppService implements AppService {
@@ -125,7 +126,7 @@ export abstract class BaseAppService implements AppService {
}
async importBook(
file: string | File,
file: string | File, // file path/url or file object
books: Book[],
saveBook: boolean = true,
saveCover: boolean = true,
@@ -145,6 +146,10 @@ export abstract class BaseAppService implements AppService {
filename = file.name;
fileobj = file;
}
if (filename.endsWith('.txt')) {
const txt2epub = new TxtToEpubConverter();
({ file: fileobj } = await txt2epub.convert({ file: fileobj }));
}
({ book: loadedBook, format } = await new DocumentLoader(fileobj).open());
if (!loadedBook.metadata.title) {
loadedBook.metadata.title = getBaseFilename(filename);
@@ -177,7 +182,7 @@ export abstract class BaseAppService implements AppService {
await this.fs.createDir(getDir(book), 'Books');
}
if (saveBook && (!(await this.fs.exists(getFilename(book), 'Books')) || overwrite)) {
if (typeof file === 'string' && !isValidURL(file)) {
if (typeof file === 'string' && !isValidURL(file) && !filename.endsWith('.txt')) {
await this.fs.copyFile(file, getFilename(book), 'Books');
} else {
await this.fs.writeFile(getFilename(book), 'Books', await fileobj.arrayBuffer());
@@ -193,6 +198,9 @@ export abstract class BaseAppService implements AppService {
if (!existingBook) {
await this.saveBookConfig(book, INIT_BOOK_CONFIG);
books.splice(0, 0, book);
} else {
existingBook.title = book.title;
existingBook.author = book.author;
}
if (typeof file === 'string' && isValidURL(file)) {
+35 -2
View File
@@ -15,7 +15,17 @@ import { getDefaultMaxBlockSize, getDefaultMaxInlineSize } from '@/utils/config'
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
export const CLOUD_BOOKS_SUBDIR = 'Readest/Books';
export const SUPPORTED_FILE_EXTS = ['epub', 'mobi', 'azw', 'azw3', 'fb2', 'zip', 'cbz', 'pdf'];
export const SUPPORTED_FILE_EXTS = [
'epub',
'mobi',
'azw',
'azw3',
'fb2',
'zip',
'cbz',
'pdf',
'txt',
];
export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).join(', ');
export const BOOK_UNGROUPED_NAME = '';
export const BOOK_UNGROUPED_ID = '';
@@ -89,7 +99,7 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
invert: false,
theme: 'light',
overrideFont: false,
overrideLayout: false,
overrideLayout: true,
userStylesheet: '',
};
@@ -105,6 +115,7 @@ export const DEFAULT_CJK_VIEW_SETTINGS: Partial<ViewSettings> = {
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
sideBarTab: 'toc',
uiLanguage: '',
};
export const DEFAULT_TTS_CONFIG: TTSConfig = {
@@ -475,3 +486,25 @@ export const MIGHT_BE_RTL_LANGS = [
'yi',
'',
];
export const TRANSLATED_LANGS = {
en: 'English',
fr: 'Français',
de: 'Deutsch',
it: 'Italiano',
ja: '日本語',
ko: '한국어',
es: 'Español',
pt: 'Português',
ru: 'Русский',
ar: 'العربية',
el: 'Ελληνικά',
uk: 'Українська',
pl: 'Polski',
tr: 'Türkçe',
hi: 'हिन्दी',
id: 'Bahasa Indonesia',
vi: 'Tiếng Việt',
'zh-CN': '简体中文',
'zh-TW': '正體中文',
};
+8
View File
@@ -53,6 +53,13 @@ export interface BookNote {
deletedAt?: number | null;
}
export interface BooknoteGroup {
id: number;
href: string;
label: string;
booknotes: BookNote[];
}
export type WritingMode = 'auto' | 'horizontal-tb' | 'horizontal-rl' | 'vertical-rl';
export interface BookLayout {
@@ -102,6 +109,7 @@ export interface BookFont {
export interface ViewConfig {
sideBarTab: string;
uiLanguage: string;
}
export interface TTSConfig {
+22 -11
View File
@@ -137,10 +137,11 @@ const getLayoutStyles = (
bg: string,
fg: string,
primary: string,
isDarkMode: boolean,
) => `
@namespace epub "http://www.idpf.org/2007/ops";
html {
color-scheme: light dark;
color-scheme: ${isDarkMode ? 'dark' : 'light'};
color: ${fg};
}
a:any-link {
@@ -158,6 +159,8 @@ const getLayoutStyles = (
html {
--theme-bg-color: ${bg};
--theme-fg-color: ${fg};
--theme-primary-color: ${primary};
--default-text-align: ${justify ? 'justify' : 'start'};
hanging-punctuation: allow-end last;
orphans: 2;
@@ -197,12 +200,12 @@ const getLayoutStyles = (
background-color: transparent !important;
}
p, li, blockquote, dd {
margin: ${vertical ? `0 ${paragraphMargin}em` : `${paragraphMargin}em 0`};
margin: ${vertical ? `0 ${paragraphMargin}em` : `${paragraphMargin}em 0`} ${overrideLayout ? '!important' : ''};
line-height: ${lineSpacing} ${overrideLayout ? '!important' : ''};
word-spacing: ${wordSpacing}px ${overrideLayout ? '!important' : ''};
letter-spacing: ${letterSpacing}px ${overrideLayout ? '!important' : ''};
text-indent: ${textIndent}em ${overrideLayout ? '!important' : ''};
text-align: inherit;
text-align: ${overrideLayout ? 'var(--default-text-align) !important' : 'inherit'};
-webkit-hyphens: ${hyphenate ? 'auto' : 'manual'};
hyphens: ${hyphenate ? 'auto' : 'manual'};
-webkit-hyphenate-limit-before: 3;
@@ -211,6 +214,9 @@ const getLayoutStyles = (
hanging-punctuation: allow-end last;
widows: 2;
}
li, p:has(> :is(img, video, font, h1, h2, h3, h4, h5, table)) {
text-indent: 0 !important;
}
/* prevent the above from overriding the align attribute */
[align="left"] { text-align: left; }
[align="right"] { text-align: right; }
@@ -248,15 +254,22 @@ export const getFootnoteStyles = () => `
display: block !important;
}
body {
padding: 1em !important;
}
a:any-link {
text-decoration: none;
}
ol {
margin: 0;
padding: 0;
}
p, li, blockquote, dd {
padding: 1em !important;
margin: 0 !important;
text-indent: 0 !important;
margin: unset !important;
text-indent: unset !important;
}
`;
@@ -265,6 +278,7 @@ export interface ThemeCode {
fg: string;
primary: string;
palette: Palette;
isDarkMode: boolean;
}
export const getThemeCode = () => {
@@ -300,6 +314,7 @@ export const getThemeCode = () => {
fg: defaultPalette['base-content'],
primary: defaultPalette.primary,
palette: defaultPalette,
isDarkMode,
} as ThemeCode;
};
@@ -322,6 +337,7 @@ export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) =>
themeCode.bg,
themeCode.fg,
themeCode.primary,
themeCode.isDarkMode,
);
// scale the font size on-the-fly so that we can sync the same font size on different devices
const isMobile = ['ios', 'android'].includes(getOSPlatform());
@@ -361,8 +377,3 @@ export const mountAdditionalFonts = (document: Document) => {
style.textContent = getAdditionalFontFaces();
document.head.appendChild(style);
};
export const applyColorScheme = (document: Document, isDarkMode: boolean) => {
const colorScheme = isDarkMode ? 'dark' : 'light';
document.documentElement.style.setProperty('color-scheme', colorScheme);
};
+374
View File
@@ -0,0 +1,374 @@
import { partialMD5 } from './md5';
interface Metadata {
bookTitle: string;
author: string;
language: string;
identifier: string;
}
interface Chapter {
title: string;
content: string;
}
interface Txt2EpubOptions {
file: File;
author?: string;
language?: string;
}
interface ExtractChapterOptions {
linesBetweenSegments: number;
}
interface ConversionResult {
file: File;
bookTitle: string;
chapterCount: number;
language: string;
}
const zipWriteOptions = {
lastAccessDate: new Date(0),
lastModDate: new Date(0),
};
export class TxtToEpubConverter {
public async convert(options: Txt2EpubOptions): Promise<ConversionResult> {
const { file: txtFile, author: providedAuthor, language: providedLanguage } = options;
const fileContent = await this.readFileAsArrayBuffer(txtFile);
const detectedEncoding = this.detectEncoding(fileContent) || 'utf-8';
const decoder = new TextDecoder(detectedEncoding);
const txtContent = decoder.decode(fileContent).trim();
const bookTitle = this.extractBookTitle(txtFile.name);
const fileName = `${bookTitle}.epub`;
const fileHeader = txtContent.slice(0, 1024);
const authorMatch =
fileHeader.match(/[【\[]?作者[】\]]?[:\s]\s*(.+)\r?\n/) ||
fileHeader.match(/[【\[]?\s*(.+)\s+著\s*[】\]]?\r?\n/);
const author = authorMatch ? authorMatch[1]!.trim() : providedAuthor || '';
const language = providedLanguage || this.detectLanguage(fileHeader);
const identifier = await partialMD5(txtFile);
const metadata = { bookTitle, author, language, identifier };
let chapters: Chapter[] = [];
for (let i = 4; i >= 3; i--) {
chapters = this.extractChapters(txtContent, metadata, {
linesBetweenSegments: i,
});
if (chapters.length === 0) {
throw new Error('No chapters detected.');
} else if (chapters.length > 1) {
break;
}
}
const blob = await this.createEpub(chapters, metadata);
return {
file: new File([blob], fileName),
bookTitle,
chapterCount: chapters.length,
language,
};
}
private extractChapters(
txtContent: string,
metadata: Metadata,
option: ExtractChapterOptions,
): Chapter[] {
const { language } = metadata;
const { linesBetweenSegments } = option;
const segmentRegex = new RegExp(`(?:\\r?\\n){${linesBetweenSegments},}|-{4,}\r?\n`);
let chapterRegex: RegExp;
if (language === 'zh') {
chapterRegex =
/(?:^|\n|\s|《[^》]+》)(第?[一二三四五六七八九十百千万0-9]+[章卷节回讲篇](?:[:、  \(\)0-9]+[^\n-]*)?(?!\S)|(?:^|\n|\s|《[^》]+》)[一二三四五六七八九十百千万]+(?:[:、  ][^\n-]+)(?!\S)|(?:楔子|前言|引言|序言|序章|总论|概论)(?:[:  ][^\n-]*)?(?!\S))/g;
} else {
chapterRegex =
/(?:^|\n|\s)(Chapter [0-9]+(?:[: ][^\n]*)?(?!\S)|Part [0-9]+(?:[: ][^\n]*)?(?!\S)|Prologue(?:[: ][^\n]*)?(?!\S)|Introduction(?:[: ][^\n]*)?(?!\S))/g;
}
const formatSegment = (segment: string): string => {
return segment
.replace(/-{4,}|_{4,}/g, '\n')
.split(/\n+/)
.map((line) => line.trim())
.filter((line) => line)
.join('</p><p>');
};
const chapters: Chapter[] = [];
const segments = txtContent.split(segmentRegex);
for (const segment of segments) {
const trimmedSegment = segment.replace(/<!--.*?-->/g, '').trim();
if (!trimmedSegment) continue;
const segmentChapters = [];
const matches = trimmedSegment.split(chapterRegex);
for (let j = 1; j < matches.length; j += 2) {
const title = matches[j]?.trim() || '';
const content = matches[j + 1]?.trim() || '';
let isVolume = false;
if (language === 'zh') {
isVolume = /第[一二三四五六七八九十百千万0-9]+卷/.test(title);
} else {
isVolume = /\b(Part|Volume|Book)\b/i.test(title);
}
const headTitle = isVolume ? `<h1>${title}</h1>` : `<h2>${title}</h2>`;
const formattedSegment = formatSegment(content);
segmentChapters.push({
title,
content: `${headTitle}<p>${formattedSegment}</p>`,
});
}
if (matches[0] && matches[0].trim()) {
const initialContent = matches[0].trim();
const firstLine = initialContent.split('\n')[0]!.trim();
const segmentTitle =
(firstLine.length > 16 ? initialContent.split(/[\n\s\p{P}]/u)[0]!.trim() : firstLine) ||
initialContent.slice(0, 16);
const formattedSegment = formatSegment(initialContent);
segmentChapters.unshift({
title: segmentTitle,
content: `<h3></h3><p>${formattedSegment}</p>`,
});
}
chapters.push(...segmentChapters);
}
return chapters;
}
private async createEpub(chapters: Chapter[], metadata: Metadata): Promise<Blob> {
const { BlobWriter, TextReader, ZipWriter } = await import('@zip.js/zip.js');
const { bookTitle, author, language, identifier } = metadata;
const zipWriter = new ZipWriter(new BlobWriter('application/epub+zip'), {
extendedTimestamp: false,
});
await zipWriter.add('mimetype', new TextReader('application/epub+zip'), zipWriteOptions);
// Add META-INF/container.xml
const containerXml = `<?xml version="1.0" encoding="UTF-8"?>
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
<rootfiles>
<rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`.trim();
await zipWriter.add('META-INF/container.xml', new TextReader(containerXml), zipWriteOptions);
// Create navigation points for TOC
const navPoints = chapters
.map((chapter, index) => {
const id = `chapter${index + 1}`;
const playOrder = index + 1;
return `
<navPoint id="navPoint-${id}" playOrder="${playOrder}">
<navLabel>
<text>${chapter.title}</text>
</navLabel>
<content src="./OEBPS/${id}.xhtml" />
</navPoint>
`.trim();
})
.join('\n');
// Add NCX file (table of contents)
const tocNcx = `<?xml version="1.0" encoding="UTF-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head>
<meta name="dtb:uid" content="book-id" />
<meta name="dtb:depth" content="1" />
<meta name="dtb:totalPageCount" content="0" />
<meta name="dtb:maxPageNumber" content="0" />
</head>
<docTitle>
<text>${bookTitle}</text>
</docTitle>
<docAuthor>
<text>${author}</text>
</docAuthor>
<navMap>
${navPoints}
</navMap>
</ncx>`.trim();
await zipWriter.add('toc.ncx', new TextReader(tocNcx), zipWriteOptions);
// Create manifest and spine items
const manifest = chapters
.map(
(_, index) => `
<item id="chap${index + 1}" href="OEBPS/chapter${index + 1}.xhtml" media-type="application/xhtml+xml"/>
`,
)
.join('\n')
.trim();
const spine = chapters
.map(
(_, index) => `
<itemref idref="chap${index + 1}"/>`,
)
.join('\n')
.trim();
// Add CSS stylesheet
const css = `
body { line-height: 1.6; font-size: 1em; font-family: 'Arial', sans-serif; text-align: justify; }
p { text-indent: 2em; margin: 0; }
`;
await zipWriter.add('style.css', new TextReader(css), zipWriteOptions);
// Add chapter files
for (let i = 0; i < chapters.length; i++) {
const chapter = chapters[i]!;
const chapterContent = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="zh">
<head>
<title>${chapter.title}</title>
<link rel="stylesheet" type="text/css" href="../style.css"/>
</head>
<body>${chapter.content}</body>
</html>`.trim();
await zipWriter.add(
`OEBPS/chapter${i + 1}.xhtml`,
new TextReader(chapterContent),
zipWriteOptions,
);
}
const tocManifest = `<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>`;
// Add content.opf file
const contentOpf = `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="book-id" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>${bookTitle}</dc:title>
<dc:language>${language}</dc:language>
<dc:creator>${author}</dc:creator>
<dc:identifier id="book-id">${identifier}</dc:identifier>
</metadata>
<manifest>
${manifest}
${tocManifest}
</manifest>
<spine toc="ncx">
${spine}
</spine>
</package>`.trim();
await zipWriter.add('content.opf', new TextReader(contentOpf), zipWriteOptions);
return await zipWriter.close();
}
private detectEncoding(buffer: ArrayBuffer): string | undefined {
try {
new TextDecoder('utf-8', { fatal: true }).decode(buffer);
return 'utf-8';
} catch {
// If UTF-8 decoding fails, try to detect other encodings
}
const headerBytes = new Uint8Array(buffer.slice(0, 4));
if (headerBytes[0] === 0xff && headerBytes[1] === 0xfe) {
return 'utf-16le';
}
if (headerBytes[0] === 0xfe && headerBytes[1] === 0xff) {
return 'utf-16be';
}
if (headerBytes[0] === 0xef && headerBytes[1] === 0xbb && headerBytes[2] === 0xbf) {
return 'utf-8';
}
// Analyze a sample of the content to guess between common East Asian encodings
// If the content has a high ratio of bytes in the 0x80-0xFF range, it's likely GBK/GB18030
const sample = new Uint8Array(buffer.slice(0, Math.min(1024, buffer.byteLength)));
let highByteCount = 0;
for (let i = 0; i < sample.length; i++) {
if (sample[i]! >= 0x80) {
highByteCount++;
}
}
const highByteRatio = highByteCount / sample.length;
if (highByteRatio > 0.3) {
return 'gbk';
}
if (highByteRatio > 0.1) {
let sjisPattern = false;
for (let i = 0; i < sample.length - 1; i++) {
const b1 = sample[i]!;
const b2 = sample[i + 1]!;
if (
((b1 >= 0x81 && b1 <= 0x9f) || (b1 >= 0xe0 && b1 <= 0xfc)) &&
((b2 >= 0x40 && b2 <= 0x7e) || (b2 >= 0x80 && b2 <= 0xfc))
) {
sjisPattern = true;
break;
}
}
if (sjisPattern) {
return 'shift-jis';
}
return 'gb18030';
}
return 'utf-8';
}
private detectLanguage(fileHeader: string): string {
const sample = fileHeader;
let chineseCount = 0;
for (let i = 0; i < sample.length; i++) {
const code = sample.charCodeAt(i);
if (
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0x3400 && code <= 0x4dbf) ||
(code >= 0x20000 && code <= 0x2a6df)
) {
chineseCount++;
}
}
if (chineseCount / sample.length > 0.05) {
return 'zh';
}
return 'en';
}
private readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as ArrayBuffer);
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
private extractBookTitle(filename: string): string {
const match = filename.match(/《([^》]+)》/);
return match ? match[1]! : filename.split('.')[0]!;
}
}
@@ -0,0 +1,12 @@
<p>b>Readest</b> is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.</p>
<p>Whether you're a casual reader, a student, or a researcher, Readest is packed with thoughtful features to enhance your reading experience. From flexible reading modes to powerful annotation tools and cloud sync, its designed to make reading smarter, smoother, and more enjoyable.</p>
<ul>
<li><b>Support for Multiple eBook Formats:</b> Compatible with EPUB, MOBI, AZW3, TXT, PDF, and more.</li>
<li><b>Paging/Scrolling Reading Modes:</b> Freely switch between modes to suit different reading preferences.</li>
<li><b>Efficient Annotation & Marking:</b> Supports highlights, bookmarks, and notes—easily organize key information.</li>
<li><b>Full-Text Search & Translation:</b> Includes full-text search, built-in DeepL translation tool, and Wikipedia lookup.</li>
<li><b>Dual-Screen Parallel Reading:</b> Read two books at the same time—ideal for academic research and text comparison.</li>
<li><b>Personalized Layout Settings:</b> Customize fonts, themes, and colors for a tailored reading experience.</li>
<li><b>Built-in Sync Server:</b> Each user gets 500 MB of cloud storage; unlimited devices supported. Log in to sync eBooks, reading progress, highlights, and notes across devices.</li>
<li><b>TTS Voice Reading:</b> Unlimited text-to-speech with Edge TTS; multiple voice options available.</li>
</ul>
Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 677 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

@@ -0,0 +1 @@
Modern, feature-rich ebook reader
@@ -0,0 +1 @@
Readest
@@ -0,0 +1 @@
https://cdn.readest.com/videos/Readest_annotation_notebook-1080p.mp4