Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/app/src/components/reader/FoliateViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2783,7 +2783,13 @@ export const FoliateViewer = forwardRef<FoliateViewerHandle, FoliateViewerProps>
}

applyReflowLayoutSettings(view, viewSettings);
}, [viewSettings.viewMode, viewSettings.paginatedLayout, isFixedLayout, appTheme]);
}, [
viewSettings.viewMode,
viewSettings.paginatedLayout,
viewSettings.fixedLayoutZoom,
isFixedLayout,
appTheme,
]);

const handleViewerShellClick = useCallback(
(event: {
Expand Down Expand Up @@ -3068,6 +3074,7 @@ function applyRendererSettings(
// EPUBs do not look "shrunk inside a spread". Double-page mode still uses
// fit-page to keep both pages fully visible inside the viewport.
renderer.setAttribute("zoom", isSinglePage ? "fit-width" : "fit-page");
renderer.setAttribute("zoom-factor", String(settings.fixedLayoutZoom ?? 1));
if (view.book?.rendition) {
view.book.rendition.spread = spreadMode;
}
Expand Down
64 changes: 62 additions & 2 deletions packages/app/src/components/reader/ReaderToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { useAnnotationStore } from "@/stores/annotation-store";
import { useAppStore } from "@/stores/app-store";
import { useNotebookStore } from "@/stores/notebook-store";
import { useReaderStore } from "@/stores/reader-store";
import { generateId } from "@readany/core/utils";
import type { ChapterTranslationState } from "@readany/core/hooks";
import { generateId } from "@readany/core/utils";
import {
ArrowLeft,
Bookmark,
Expand All @@ -16,9 +16,12 @@ import {
MessageSquare,
NotebookPen,
Pin,
RotateCcw,
Search,
Settings,
Undo,
ZoomIn,
ZoomOut,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
Expand Down Expand Up @@ -46,6 +49,11 @@ interface ReaderToolbarProps {
isChatOpen?: boolean;
isTTSActive?: boolean;
isFixedLayout?: boolean;
fixedLayoutZoom?: number;
fixedLayoutZoomMin?: number;
fixedLayoutZoomMax?: number;
fixedLayoutZoomStep?: number;
onFixedLayoutZoomChange?: (zoom: number) => void;
isPinned?: boolean;
onTogglePinned?: () => void;
onMouseEnter?: () => void;
Expand Down Expand Up @@ -73,7 +81,12 @@ export function ReaderToolbar({
onChapterTranslationReset,
isChatOpen,
isTTSActive,
isFixedLayout: _isFixedLayout = false,
isFixedLayout = false,
fixedLayoutZoom = 1,
fixedLayoutZoomMin = 0.5,
fixedLayoutZoomMax = 3,
fixedLayoutZoomStep = 0.1,
onFixedLayoutZoomChange,
isPinned = false,
onTogglePinned,
onMouseEnter,
Expand All @@ -95,6 +108,10 @@ export function ReaderToolbar({
const bookId = tab?.bookId || "";
const existingBookmark = bookmarks.find((b) => b.bookId === bookId && b.cfi === currentCfi);
const isBookmarked = !!existingBookmark;
const fixedLayoutZoomPercent = Math.round(fixedLayoutZoom * 100);
const canZoomOut = fixedLayoutZoom > fixedLayoutZoomMin + 0.001;
const canZoomIn = fixedLayoutZoom < fixedLayoutZoomMax - 0.001;
const canResetZoom = Math.abs(fixedLayoutZoom - 1) > 0.001;

const handleToggleBookmark = () => {
if (!currentCfi || !bookId) return;
Expand Down Expand Up @@ -211,6 +228,49 @@ export function ReaderToolbar({
onReset={onChapterTranslationReset}
/>
<SyncButton iconSize={14} className="h-7 w-7" />
{isFixedLayout && (
<div
className="mx-0.5 flex h-7 items-center gap-0.5 rounded-sm border border-border/50 bg-muted/40 px-0.5"
aria-label={t("reader.pdfZoom")}
>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => onFixedLayoutZoomChange?.(fixedLayoutZoom - fixedLayoutZoomStep)}
disabled={!onFixedLayoutZoomChange || !canZoomOut}
title={t("reader.zoomOut")}
aria-label={t("reader.zoomOut")}
>
<ZoomOut className="h-3.5 w-3.5" />
</Button>
<span className="w-10 text-center text-[11px] tabular-nums text-muted-foreground">
{fixedLayoutZoomPercent}%
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => onFixedLayoutZoomChange?.(fixedLayoutZoom + fixedLayoutZoomStep)}
disabled={!onFixedLayoutZoomChange || !canZoomIn}
title={t("reader.zoomIn")}
aria-label={t("reader.zoomIn")}
>
<ZoomIn className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => onFixedLayoutZoomChange?.(1)}
disabled={!onFixedLayoutZoomChange || !canResetZoom}
title={t("reader.resetZoom")}
aria-label={t("reader.resetZoom")}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
</div>
)}
<Button
variant="ghost"
size="icon"
Expand Down
21 changes: 21 additions & 0 deletions packages/app/src/components/reader/ReaderView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,22 @@ const MAX_TRACKED_PAGE_DELTA = 20;
const MAX_TRACKED_FRACTION_DELTA = 0.08;
const INITIAL_PROGRESS_RESTORE_GUARD_MS = 1800;
const PROGRAMMATIC_NAV_GUARD_MS = 1200;
const FIXED_LAYOUT_ZOOM_MIN = 0.5;
const FIXED_LAYOUT_ZOOM_MAX = 3;
const FIXED_LAYOUT_ZOOM_STEP = 0.1;
const BOOK_IMPORT_FILTERS = [
{
name: "Books",
extensions: ["epub", "pdf", "mobi", "azw", "azw3", "cbz", "fb2", "fbz", "txt", "umd"],
},
];

function normalizeFixedLayoutZoom(value: number): number {
if (!Number.isFinite(value)) return 1;
const rounded = Math.round(value * 10) / 10;
return Math.min(FIXED_LAYOUT_ZOOM_MAX, Math.max(FIXED_LAYOUT_ZOOM_MIN, rounded));
}

function countReadableCharacters(doc: Document): number {
const rawText = doc.body?.textContent ?? "";
const normalizedText = rawText.replace(/\s+/g, "");
Expand Down Expand Up @@ -869,6 +878,13 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) {
viewSettings.viewMode === "scroll",
);
const isDoublePage = (viewSettings.paginatedLayout ?? "double") === "double";
const fixedLayoutZoom = normalizeFixedLayoutZoom(viewSettings.fixedLayoutZoom ?? 1);
const handleFixedLayoutZoomChange = useCallback(
(zoom: number) => {
updateReadSettings({ fixedLayoutZoom: normalizeFixedLayoutZoom(zoom) });
},
[updateReadSettings],
);
const toolbarVisible = controlsVisible || isToolbarPinned;
const readingHeaderTitle = (readerTab?.chapterTitle || book?.meta.title || "").trim();
const contentTopPadding = isToolbarPinned ? 78 : 56;
Expand Down Expand Up @@ -2905,6 +2921,11 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) {
isChatOpen={showChat}
isTTSActive={showTTS || ttsPlayState !== "stopped"}
isFixedLayout={isFixedLayout}
fixedLayoutZoom={fixedLayoutZoom}
fixedLayoutZoomMin={FIXED_LAYOUT_ZOOM_MIN}
fixedLayoutZoomMax={FIXED_LAYOUT_ZOOM_MAX}
fixedLayoutZoomStep={FIXED_LAYOUT_ZOOM_STEP}
onFixedLayoutZoomChange={handleFixedLayoutZoomChange}
isPinned={isToolbarPinned}
onTogglePinned={() => setIsToolbarPinned((prev) => !prev)}
onMouseEnter={handleMouseEnter}
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/i18n/locales/en/reader.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@
"goBackToPreviousLocation": "Go back to previous location",
"pinToolbar": "Pin toolbar",
"unpinToolbar": "Unpin toolbar",
"pdfZoom": "PDF zoom",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"resetZoom": "Reset zoom",
"addNote": "Add note",
"bookNotFound": "Book not found",
"bookmarks": "Bookmarks",
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/i18n/locales/zh/reader.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@
"goBackToPreviousLocation": "返回上一位置",
"pinToolbar": "固定工具栏",
"unpinToolbar": "取消固定工具栏",
"pdfZoom": "PDF 缩放",
"zoomIn": "放大",
"zoomOut": "缩小",
"resetZoom": "重置缩放",
"addNote": "添加笔记",
"bookNotFound": "书籍未找到",
"bookmarks": "书签",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/stores/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const defaultReadSettings: ReadSettings = {
fontTheme: "system",
viewMode: "paginated",
paginatedLayout: "double",
fixedLayoutZoom: 1,
pageMargin: 40,
paragraphSpacing: 16,
showTopTitleProgress: true,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types/book.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export interface ViewSettings {
customFontCssUrls?: string[]; // remote font stylesheet URLs to inject into renderer docs
viewMode: ViewMode;
paginatedLayout: PaginatedLayout;
fixedLayoutZoom?: number; // relative zoom multiplier for PDF/CBZ fixed layouts
pageMargin: number; // px
paragraphSpacing: number;
}
Expand Down
22 changes: 16 additions & 6 deletions packages/foliate-js/fixed-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const getViewport = (doc, viewport) => {
};

export class FixedLayout extends HTMLElement {
static observedAttributes = ["zoom", "spread"];
static observedAttributes = ["zoom", "zoom-factor", "spread"];
#root = this.attachShadow({ mode: "closed" });
#observer = new ResizeObserver(() => this.#render());
#spreads;
Expand All @@ -42,6 +42,7 @@ export class FixedLayout extends HTMLElement {
#center;
#side;
#zoom;
#zoomFactor = 1;
constructor() {
super();

Expand All @@ -51,8 +52,8 @@ export class FixedLayout extends HTMLElement {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
justify-content: safe center;
align-items: safe center;
overflow: auto;
}`);

Expand All @@ -65,6 +66,12 @@ export class FixedLayout extends HTMLElement {
value !== "fit-width" && value !== "fit-page" ? Number.parseFloat(value) : value;
this.#render();
break;
case "zoom-factor": {
const parsed = Number.parseFloat(value);
this.#zoomFactor = Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
this.#render();
break;
}
case "spread":
this.spread = value;
void this.#applySpreadChange(value);
Expand Down Expand Up @@ -174,8 +181,8 @@ export class FixedLayout extends HTMLElement {
const blankWidth = left.width ?? right.width ?? 0;
const blankHeight = left.height ?? right.height ?? 0;

const scale =
typeof this.#zoom === "number" && !isNaN(this.#zoom)
const fitScale =
typeof this.#zoom === "number" && !Number.isNaN(this.#zoom)
? this.#zoom
: (this.#zoom === "fit-width"
? portrait || this.#center
Expand All @@ -190,6 +197,10 @@ export class FixedLayout extends HTMLElement {
width / ((left.width ?? blankWidth) + (right.width ?? blankWidth)),
height / Math.max(left.height ?? blankHeight, right.height ?? blankHeight),
)) || 1;
const scale =
typeof this.#zoom === "number" && !Number.isNaN(this.#zoom)
? fitScale
: fitScale * this.#zoomFactor;

const transform = (frame) => {
const { element, iframe, width, height, blank, onZoom } = frame;
Expand Down Expand Up @@ -263,7 +274,6 @@ export class FixedLayout extends HTMLElement {
this.defaultViewport = rendition?.viewport;

const rtl = book.dir === "rtl";
const ltr = !rtl;
this.rtl = rtl;

this.#spreads = this.#buildSpreads(book);
Expand Down