feat(pdf): support setting zoom level, zoom mode and spread mode for PDF (#1969)

This should close #1717, close #1276, close #669.
This commit is contained in:
Huang Xin
2025-09-06 00:20:15 +08:00
committed by GitHub
parent 5724d2eeec
commit 77c4303938
31 changed files with 172 additions and 31 deletions
@@ -537,5 +537,6 @@
"Pause": "إيقاف مؤقت",
"Play": "تشغيل",
"Next Sentence": "الجملة التالية",
"Next Paragraph": "الفقرة التالية"
"Next Paragraph": "الفقرة التالية",
"Separate Cover Page": "صفحة غلاف منفصلة"
}
@@ -521,5 +521,6 @@
"Pause": "বিরতি",
"Play": "চালান",
"Next Sentence": "পরবর্তী বাক্য",
"Next Paragraph": "পরবর্তী অনুচ্ছেদ"
"Next Paragraph": "পরবর্তী অনুচ্ছেদ",
"Separate Cover Page": "আলাদা কভার পৃষ্ঠা"
}
@@ -517,5 +517,6 @@
"Pause": "སྐར་མ་འཇོག",
"Play": "གཏོང་བ།",
"Next Sentence": "རྗེས་ཀྱི་ཚིག་གྲུབ།",
"Next Paragraph": "རྗེས་ཀྱི་དོན་ཚན།"
"Next Paragraph": "རྗེས་ཀྱི་དོན་ཚན།",
"Separate Cover Page": "སྟོང་ངོས་གི་ཤོག་དཀར་པ།"
}
@@ -521,5 +521,6 @@
"Pause": "Pause",
"Play": "Wiedergabe",
"Next Sentence": "Nächster Satz",
"Next Paragraph": "Nächster Absatz"
"Next Paragraph": "Nächster Absatz",
"Separate Cover Page": "Titelseite separat anzeigen"
}
@@ -521,5 +521,6 @@
"Pause": "Παύση",
"Play": "Αναπαραγωγή",
"Next Sentence": "Επόμενη πρόταση",
"Next Paragraph": "Επόμενη παράγραφος"
"Next Paragraph": "Επόμενη παράγραφος",
"Separate Cover Page": "Ξεχωριστή σελίδα εξωφύλλου"
}
@@ -525,5 +525,6 @@
"Pause": "Pausar",
"Play": "Reproducir",
"Next Sentence": "Siguiente oración",
"Next Paragraph": "Siguiente párrafo"
"Next Paragraph": "Siguiente párrafo",
"Separate Cover Page": "Portada separada"
}
@@ -525,5 +525,6 @@
"Pause": "Pause",
"Play": "Lecture",
"Next Sentence": "Phrase suivante",
"Next Paragraph": "Paragraphe suivant"
"Next Paragraph": "Paragraphe suivant",
"Separate Cover Page": "Page de couverture séparée"
}
@@ -521,5 +521,6 @@
"Pause": "रोकें",
"Play": "चलाएं",
"Next Sentence": "अगला वाक्य",
"Next Paragraph": "अगला पैराग्राफ"
"Next Paragraph": "अगला पैराग्राफ",
"Separate Cover Page": "अलग कवर पृष्ठ"
}
@@ -517,5 +517,6 @@
"Pause": "Jeda",
"Play": "Putar",
"Next Sentence": "Kalimat Berikutnya",
"Next Paragraph": "Paragraf Berikutnya"
"Next Paragraph": "Paragraf Berikutnya",
"Separate Cover Page": "Halaman Sampul Terpisah"
}
@@ -525,5 +525,6 @@
"Pause": "Pausa",
"Play": "Riproduci",
"Next Sentence": "Frase successiva",
"Next Paragraph": "Paragrafo successivo"
"Next Paragraph": "Paragrafo successivo",
"Separate Cover Page": "Pagina di copertina separata"
}
@@ -517,5 +517,6 @@
"Pause": "一時停止",
"Play": "再生",
"Next Sentence": "次の文",
"Next Paragraph": "次の段落"
"Next Paragraph": "次の段落",
"Separate Cover Page": "別の表紙ページ"
}
@@ -517,5 +517,6 @@
"Pause": "일시정지",
"Play": "재생",
"Next Sentence": "다음 문장",
"Next Paragraph": "다음 단락"
"Next Paragraph": "다음 단락",
"Separate Cover Page": "별도의 표지 페이지"
}
@@ -521,5 +521,6 @@
"Pause": "Pauzeren",
"Play": "Afspelen",
"Next Sentence": "Volgende zin",
"Next Paragraph": "Volgende alinea"
"Next Paragraph": "Volgende alinea",
"Separate Cover Page": "Afzonderlijke omslagpagina"
}
@@ -529,5 +529,6 @@
"Pause": "Pauza",
"Play": "Odtwórz",
"Next Sentence": "Następne zdanie",
"Next Paragraph": "Następny akapit"
"Next Paragraph": "Następny akapit",
"Separate Cover Page": "Osobna strona okładki"
}
@@ -525,5 +525,6 @@
"Pause": "Pausar",
"Play": "Reproduzir",
"Next Sentence": "Próxima frase",
"Next Paragraph": "Próximo parágrafo"
"Next Paragraph": "Próximo parágrafo",
"Separate Cover Page": "Páginas de Capa Separadas"
}
@@ -529,5 +529,6 @@
"Pause": "Пауза",
"Play": "Воспроизвести",
"Next Sentence": "Следующее предложение",
"Next Paragraph": "Следующий абзац"
"Next Paragraph": "Следующий абзац",
"Separate Cover Page": "Отдельная страница обложки"
}
@@ -521,5 +521,6 @@
"Pause": "විරාමය",
"Play": "වාදනය කරන්න",
"Next Sentence": "මීළඟ වාක්‍යය",
"Next Paragraph": "මීළඟ ඡේදය"
"Next Paragraph": "මීළඟ ඡේදය",
"Separate Cover Page": "වෙන් වූ ආවරණ පිටුව"
}
@@ -521,5 +521,6 @@
"Pause": "இடைநிறுத்தம்",
"Play": "இயக்கு",
"Next Sentence": "அடுத்த வாக்கியம்",
"Next Paragraph": "அடுத்த பத்தி"
"Next Paragraph": "அடுத்த பத்தி",
"Separate Cover Page": "தனித்த அட்டை பக்கம்"
}
@@ -517,5 +517,6 @@
"Pause": "หยุดชั่วคราว",
"Play": "เล่น",
"Next Sentence": "ประโยคถัดไป",
"Next Paragraph": "ย่อหน้าถัดไป"
"Next Paragraph": "ย่อหน้าถัดไป",
"Separate Cover Page": "หน้าปกแยกต่างหาก"
}
@@ -521,5 +521,6 @@
"Pause": "Duraklat",
"Play": "Oynat",
"Next Sentence": "Sonraki Cümle",
"Next Paragraph": "Sonraki Paragraf"
"Next Paragraph": "Sonraki Paragraf",
"Separate Cover Page": "Ayrı Kapak Sayfası"
}
@@ -529,5 +529,6 @@
"Pause": "Пауза",
"Play": "Відтворити",
"Next Sentence": "Наступне речення",
"Next Paragraph": "Наступний абзац"
"Next Paragraph": "Наступний абзац",
"Separate Cover Page": "Окрема сторінка обкладинки"
}
@@ -517,5 +517,6 @@
"Pause": "Tạm dừng",
"Play": "Phát",
"Next Sentence": "Câu tiếp theo",
"Next Paragraph": "Đoạn tiếp theo"
"Next Paragraph": "Đoạn tiếp theo",
"Separate Cover Page": "Trang Bìa Riêng"
}
@@ -517,5 +517,6 @@
"Pause": "暂停",
"Play": "播放",
"Next Sentence": "下一句",
"Next Paragraph": "下一段"
"Next Paragraph": "下一段",
"Separate Cover Page": "单独封面页"
}
@@ -517,5 +517,6 @@
"Pause": "暫停",
"Play": "播放",
"Next Sentence": "下一句",
"Next Paragraph": "下一段"
"Next Paragraph": "下一段",
"Separate Cover Page": "單獨封面頁"
}
@@ -256,6 +256,12 @@ const FoliateViewer: React.FC<{
}
}
if (bookDoc.rendition?.layout === 'pre-paginated' && bookDoc.sections) {
bookDoc.rendition.spread = viewSettings.spreadMode;
const coverSide = bookDoc.dir === 'rtl' ? 'right' : 'left';
bookDoc.sections[0]!.pageSpread = viewSettings.keepCoverSpread ? '' : coverSide;
}
await view.open(bookDoc);
// make sure we can listen renderer events after opening book
viewRef.current = view;
@@ -291,9 +297,14 @@ const FoliateViewer: React.FC<{
} else {
view.renderer.removeAttribute('animated');
}
view.renderer.setAttribute('max-column-count', maxColumnCount);
view.renderer.setAttribute('max-inline-size', `${maxInlineSize}px`);
view.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
if (bookDoc?.rendition?.layout === 'pre-paginated') {
view.renderer.setAttribute('zoom', viewSettings.zoomMode);
view.renderer.setAttribute('spread', viewSettings.spreadMode);
} else {
view.renderer.setAttribute('max-column-count', maxColumnCount);
view.renderer.setAttribute('max-inline-size', `${maxInlineSize}px`);
view.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
}
applyMarginAndGap();
const lastLocation = config.location;
@@ -6,6 +6,9 @@ import { BiMoon, BiSun } from 'react-icons/bi';
import { TbSunMoon } from 'react-icons/tb';
import { MdZoomOut, MdZoomIn, MdCheck } from 'react-icons/md';
import { MdSync, MdSyncProblem } from 'react-icons/md';
import { IoMdExpand } from 'react-icons/io';
import { TbArrowAutofitWidth } from 'react-icons/tb';
import { TbColumns1, TbColumns2 } from 'react-icons/tb';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
import { useEnv } from '@/context/EnvContext';
@@ -37,14 +40,18 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
const router = useRouter();
const { user } = useAuth();
const { envConfig, appService } = useEnv();
const { getConfig } = useBookDataStore();
const { getConfig, getBookData } = useBookDataStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const config = getConfig(bookKey)!;
const bookData = getBookData(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
const { themeMode, isDarkMode, setThemeMode } = useThemeStore();
const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled);
const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!);
const [zoomMode, setZoomMode] = useState(viewSettings!.zoomMode!);
const [spreadMode, setSpreadMode] = useState(viewSettings!.spreadMode!);
const [keepCoverSpread, setKeepCoverSpread] = useState(viewSettings!.keepCoverSpread!);
const [invertImgColorInDark, setInvertImgColorInDark] = useState(
viewSettings!.invertImgColorInDark,
);
@@ -93,6 +100,9 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'zoomLevel', zoomLevel, true, true);
if (bookData.bookDoc?.rendition?.layout === 'pre-paginated') {
getView(bookKey)?.renderer.setAttribute('scale-factor', zoomLevel);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zoomLevel]);
@@ -102,6 +112,36 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invertImgColorInDark]);
useEffect(() => {
if (zoomMode === viewSettings.zoomMode) return;
viewSettings.zoomMode = zoomMode;
getView(bookKey)?.renderer.setAttribute('zoom', zoomMode);
setViewSettings(bookKey, viewSettings);
saveViewSettings(envConfig, bookKey, 'zoomMode', zoomMode, true, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zoomMode]);
useEffect(() => {
if (spreadMode === viewSettings.spreadMode) return;
viewSettings.spreadMode = spreadMode;
getView(bookKey)?.renderer.setAttribute('spread', spreadMode);
setViewSettings(bookKey, viewSettings);
saveViewSettings(envConfig, bookKey, 'spreadMode', spreadMode, true, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spreadMode]);
useEffect(() => {
if (keepCoverSpread === viewSettings.keepCoverSpread) return;
if (!bookData?.bookDoc?.sections?.length) return;
viewSettings.keepCoverSpread = keepCoverSpread;
const coverSide = bookData.bookDoc.dir === 'rtl' ? 'right' : 'left';
bookData.bookDoc.sections[0]!.pageSpread = keepCoverSpread ? '' : coverSide;
getView(bookKey)?.renderer.setAttribute('spread', spreadMode);
setViewSettings(bookKey, viewSettings);
saveViewSettings(envConfig, bookKey, 'keepCoverSpread', keepCoverSpread, true, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keepCoverSpread]);
const lastSyncTime = Math.max(config?.lastSyncedAtConfig || 0, config?.lastSyncedAtNotes || 0);
return (
@@ -145,6 +185,56 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
</button>
</div>
{bookData.bookDoc?.rendition?.layout === 'pre-paginated' && (
<>
<div className={clsx('my-2 flex items-center justify-between rounded-md')}>
<button
onClick={setSpreadMode.bind(null, 'none')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
spreadMode === 'none' && 'bg-base-300/75',
)}
>
<TbColumns1 />
</button>
<button
onClick={setSpreadMode.bind(null, 'auto')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
spreadMode === 'auto' && 'bg-base-300/75',
)}
>
<TbColumns2 />
</button>
<div className='bg-base-300 mx-2 h-6 w-[1px]' />
<button
onClick={setZoomMode.bind(null, 'fit-page')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
zoomMode === 'fit-page' && 'bg-base-300/75',
)}
>
<IoMdExpand />
</button>
<button
onClick={setZoomMode.bind(null, 'fit-width')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
zoomMode === 'fit-width' && 'bg-base-300/75',
)}
>
<TbArrowAutofitWidth />
</button>
</div>
<MenuItem
label={_('Separate Cover Page')}
Icon={keepCoverSpread ? MdCheck : undefined}
onClick={() => setKeepCoverSpread(!keepCoverSpread)}
disabled={spreadMode === 'none'}
/>
</>
)}
<hr className='border-base-300 my-1' />
<MenuItem label={_('Font & Layout')} shortcut='Shift+F' onClick={openFontLayoutMenu} />
@@ -154,6 +244,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
shortcut='Shift+J'
Icon={isScrolledMode ? MdCheck : undefined}
onClick={toggleScrolledMode}
disabled={bookData.bookDoc?.rendition?.layout === 'pre-paginated'}
/>
<hr className='border-base-300 my-1' />
+6 -2
View File
@@ -57,7 +57,10 @@ const MenuItem: React.FC<MenuItemProps> = ({
{!noIcon && (
<span style={{ minWidth: `${iconSize}px` }}>
{typeof Icon === 'function' ? (
<Icon className='text-base-content' size={iconSize} />
<Icon
className={disabled ? 'text-gray-400' : 'text-base-content'}
size={iconSize}
/>
) : (
Icon
)}
@@ -73,8 +76,9 @@ const MenuItem: React.FC<MenuItemProps> = ({
{shortcut && (
<kbd
className={clsx(
'border-base-300/40 bg-base-300/75 text-neutral-content hidden rounded-md border shadow-sm sm:flex',
'border-base-300/40 bg-base-300/75 hidden rounded-md border shadow-sm sm:flex',
'shrink-0 px-1.5 py-0.5 text-xs font-medium',
disabled ? 'text-gray-400' : 'text-neutral-content',
)}
>
{shortcut}
+2
View File
@@ -57,6 +57,7 @@ export interface SectionItem {
size: number;
linear: string;
location?: Location;
pageSpread?: 'left' | 'right' | 'center' | '';
createDocument: () => Promise<Document>;
}
@@ -87,6 +88,7 @@ export interface BookDoc {
metadata: BookMetadata;
rendition?: {
layout?: 'pre-paginated' | 'reflowable';
spread?: 'auto' | 'none';
viewport?: { width: number; height: number };
};
dir: string;
@@ -151,6 +151,10 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
codeLanguage: 'auto-detect',
userStylesheet: '',
userUIStylesheet: '',
zoomMode: 'fit-page',
spreadMode: 'auto',
keepCoverSpread: true,
};
export const DEFAULT_MOBILE_VIEW_SETTINGS: Partial<ViewSettings> = {
+5
View File
@@ -126,6 +126,11 @@ export interface BookStyle {
codeLanguage: string;
userStylesheet: string;
userUIStylesheet: string;
// fixed-layout specific
zoomMode: 'fit-page' | 'fit-width' | 'original-size' | 'custom';
spreadMode: 'auto' | 'none';
keepCoverSpread: boolean;
}
export interface BookFont {