mirror of
https://github.com/readest/readest.git
synced 2026-10-21 05:23:27 +00:00
fix(reader): keep scrolled PDFs smooth on iOS: virtualize the strip, hide PDF chrome by layout (#6626)
* fix(reader): keep scrolled PDFs smooth on iOS for books with thousands of pages Bumps foliate-js for the scroll-strip virtualization and the pageColors re-render guard (readest/foliate-js#121). A 2752-page PDF scrolled at a few frames a second on iOS: the strip held one placeholder per page, and every page load re-rendered every loaded page because the reader reassigns pageColors on each load. Tests cover the mounted window, the computed page positions matching the old flex strip, goTo to a far page, the layout helpers, and pageColors skipping an unchanged assignment. The Webtoon seam test finds each boundary from the page before it, which is mounted, instead of holding every page up front. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(foliate-js): pin the merged scroll-strip virtualization (readest/foliate-js#121) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(reader): hide a PDF's header and footer by layout, not by scroll position #6605 showed the overlaid header and footer of a fixed-layout book only while a page's top or bottom edge sat at the viewport's. In scroll mode they then flashed on and off at every page boundary, which is hard to predict, and on iOS every flip of the blended header ended momentum scrolling dead at the next page top (a visibility change on a mix-blend layer over the scroller resets it; the bare renderer without the chrome coasted fine). They now hide whenever the page runs past the viewport vertically: always in vertical scroll flow, and when a zoomed page or a horizontal flow pans vertically. A page that fits still shows them. The state changes only with a zoom, page turn, flow change or resize, never mid-scroll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a05dcae6d9
commit
adf3b95216
13 changed files with 280 additions and 188 deletions
|
|
@ -1,111 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getPageEdges } from '@/app/reader/utils/pageEdges';
|
||||
import type { Renderer } from '@/types/view';
|
||||
|
||||
// A fixed-layout page (PDF, CBZ) fills the screen in fit-page mode, so its
|
||||
// header and footer sit on the page's own margins. Zoomed in, or in scrolled
|
||||
// mode, the viewport's top and bottom land in the middle of a page and the
|
||||
// title / progress would cover its content (#6596). They show only while the
|
||||
// page's own top / bottom edge is within 20px of the viewport's.
|
||||
const rect = (top: number, bottom: number) => ({ top, bottom }) as DOMRect;
|
||||
|
||||
const paginated = ({
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight = 800,
|
||||
horizontal = false,
|
||||
}: {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight?: number;
|
||||
horizontal?: boolean;
|
||||
}): Renderer =>
|
||||
({
|
||||
scrolled: horizontal,
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
getAttribute: (name: string) =>
|
||||
name === 'scroll-direction' ? (horizontal ? 'horizontal' : 'vertical') : null,
|
||||
getBoundingClientRect: () => rect(100, 100 + clientHeight),
|
||||
shadowRoot: null,
|
||||
}) as unknown as Renderer;
|
||||
|
||||
// Vertical scroll flow: pages of `height` stacked with a 4px gap, scrolled by
|
||||
// `scrollTop`, in a viewport whose top sits at y=100.
|
||||
const scrolled = (scrollTop: number, height = 1200, count = 50): Renderer => {
|
||||
const pages = Array.from({ length: count }, (_, i) => {
|
||||
const top = 100 + 4 + i * (height + 4) - scrollTop;
|
||||
return { getBoundingClientRect: () => rect(top, top + height) };
|
||||
});
|
||||
return {
|
||||
scrolled: true,
|
||||
scrollTop,
|
||||
scrollHeight: count * (height + 4) + 4,
|
||||
clientHeight: 800,
|
||||
getAttribute: () => 'vertical',
|
||||
getBoundingClientRect: () => rect(100, 900),
|
||||
shadowRoot: { querySelectorAll: () => pages },
|
||||
} as unknown as Renderer;
|
||||
};
|
||||
|
||||
describe('getPageEdges', () => {
|
||||
it('shows both in fit-page mode, where the page fits the viewport', () => {
|
||||
expect(getPageEdges(paginated({ scrollTop: 0, scrollHeight: 800 }))).toEqual({
|
||||
top: true,
|
||||
bottom: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows only the header at the top of a zoomed page', () => {
|
||||
expect(getPageEdges(paginated({ scrollTop: 0, scrollHeight: 2000 }))).toEqual({
|
||||
top: true,
|
||||
bottom: false,
|
||||
});
|
||||
expect(getPageEdges(paginated({ scrollTop: 20, scrollHeight: 2000 }))).toEqual({
|
||||
top: true,
|
||||
bottom: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides both in the middle of a zoomed page', () => {
|
||||
expect(getPageEdges(paginated({ scrollTop: 600, scrollHeight: 2000 }))).toEqual({
|
||||
top: false,
|
||||
bottom: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows only the footer at the bottom of a zoomed page', () => {
|
||||
expect(getPageEdges(paginated({ scrollTop: 1185, scrollHeight: 2000 }))).toEqual({
|
||||
top: false,
|
||||
bottom: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the vertical pan of a horizontal scroll flow like a paginated page', () => {
|
||||
expect(
|
||||
getPageEdges(paginated({ scrollTop: 300, scrollHeight: 1600, horizontal: true })),
|
||||
).toEqual({ top: false, bottom: false });
|
||||
});
|
||||
|
||||
it('shows the header only where a page starts in vertical scroll flow', () => {
|
||||
// Page 0 starts 4px below the viewport top.
|
||||
expect(getPageEdges(scrolled(0))).toEqual({ top: true, bottom: false });
|
||||
expect(getPageEdges(scrolled(500))).toEqual({ top: false, bottom: false });
|
||||
// Page 3 starts 10px above the viewport top.
|
||||
expect(getPageEdges(scrolled(3 * 1204 + 14))).toEqual({ top: true, bottom: false });
|
||||
// Page 3 starts 10px below it, just past page 2's end.
|
||||
expect(getPageEdges(scrolled(3 * 1204 - 6))).toEqual({ top: true, bottom: false });
|
||||
});
|
||||
|
||||
it('shows the footer only where a page ends in vertical scroll flow', () => {
|
||||
// Page 2 ends 10px above the viewport bottom.
|
||||
expect(getPageEdges(scrolled(3 * 1204 - 800 + 10))).toEqual({ top: false, bottom: true });
|
||||
// Page 2 ends 30px below it.
|
||||
expect(getPageEdges(scrolled(3 * 1204 - 800 - 30))).toEqual({
|
||||
top: false,
|
||||
bottom: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isPageOverflowing } from '@/app/reader/utils/pageOverflow';
|
||||
import type { Renderer } from '@/types/view';
|
||||
|
||||
// A fixed-layout page's (PDF, CBZ) header and footer overlay the page, so they
|
||||
// would cover its content wherever the page does not fit the viewport (#6596).
|
||||
// They hide by layout, never by scroll position: showing them at every page
|
||||
// edge made them flash on and off while scrolling, and on iOS each flip ended
|
||||
// momentum scrolling dead.
|
||||
const renderer = ({
|
||||
scrolled = false,
|
||||
horizontal = false,
|
||||
scrollHeight = 800,
|
||||
}: {
|
||||
scrolled?: boolean;
|
||||
horizontal?: boolean;
|
||||
scrollHeight?: number;
|
||||
}): Renderer =>
|
||||
({
|
||||
scrolled,
|
||||
scrollHeight,
|
||||
clientHeight: 800,
|
||||
getAttribute: (name: string) =>
|
||||
name === 'scroll-direction' ? (horizontal ? 'horizontal' : 'vertical') : null,
|
||||
}) as unknown as Renderer;
|
||||
|
||||
describe('isPageOverflowing', () => {
|
||||
it('is false for a page that fits the viewport', () => {
|
||||
expect(isPageOverflowing(renderer({}))).toBe(false);
|
||||
});
|
||||
|
||||
it('is true for a zoomed page that pans vertically', () => {
|
||||
expect(isPageOverflowing(renderer({ scrollHeight: 2000 }))).toBe(true);
|
||||
});
|
||||
|
||||
it('is always true in vertical scroll flow, where the pages run on under the chrome', () => {
|
||||
expect(isPageOverflowing(renderer({ scrolled: true, scrollHeight: 800 }))).toBe(true);
|
||||
expect(isPageOverflowing(renderer({ scrolled: true, scrollHeight: 90000 }))).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a horizontal scroll flow by its vertical overflow', () => {
|
||||
expect(isPageOverflowing(renderer({ scrolled: true, horizontal: true }))).toBe(false);
|
||||
expect(
|
||||
isPageOverflowing(renderer({ scrolled: true, horizontal: true, scrollHeight: 1600 })),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import 'foliate-js/fixed-layout.js';
|
||||
|
||||
// The reader assigns `pageColors` again on every page load. Each assignment
|
||||
// re-rendered every loaded page and rebuilt its overlayer, so scrolling a PDF
|
||||
// re-rendered the whole loaded window once per page it brought in. Colors that
|
||||
// did not change must not re-render anything.
|
||||
|
||||
const PAGE_HTML = `<!doctype html><html><head><style>
|
||||
html, body { margin: 0; height: 1000px; }
|
||||
</style></head><body></body></html>`;
|
||||
|
||||
type Renderer = HTMLElement & {
|
||||
open(book: unknown): void;
|
||||
destroy(): void;
|
||||
pageColors: unknown;
|
||||
};
|
||||
|
||||
const waitFor = async (condition: () => boolean, timeout = 4000): Promise<void> => {
|
||||
const start = performance.now();
|
||||
while (!condition()) {
|
||||
if (performance.now() - start > timeout) throw new Error('waitFor timed out');
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
}
|
||||
};
|
||||
|
||||
let renderer: Renderer | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
renderer?.destroy();
|
||||
renderer?.remove();
|
||||
renderer = null;
|
||||
});
|
||||
|
||||
describe('fixed-layout page colors', () => {
|
||||
it('re-renders the loaded pages only when the colors change', async () => {
|
||||
const onZoom = vi.fn();
|
||||
const loaded = new Set<number>();
|
||||
renderer = document.createElement('foliate-fxl') as Renderer;
|
||||
renderer.style.width = '300px';
|
||||
renderer.style.height = '600px';
|
||||
renderer.setAttribute('flow', 'scrolled');
|
||||
renderer.addEventListener('load', (e) => loaded.add((e as CustomEvent).detail.index));
|
||||
document.body.append(renderer);
|
||||
renderer.open({
|
||||
dir: 'ltr',
|
||||
rendition: { viewport: { width: 600, height: 1000 }, spread: 'none' },
|
||||
sections: Array.from({ length: 3 }, () => ({
|
||||
load: async () => ({ src: 'srcdoc', data: PAGE_HTML, onZoom }),
|
||||
linear: 'yes',
|
||||
})),
|
||||
});
|
||||
await waitFor(() => loaded.size === 3);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
renderer.pageColors = { background: '#000', foreground: '#fff', keepImages: true };
|
||||
await waitFor(() => onZoom.mock.calls.length > 0);
|
||||
onZoom.mockClear();
|
||||
|
||||
renderer.pageColors = { background: '#000', foreground: '#fff', keepImages: true };
|
||||
expect(onZoom).not.toHaveBeenCalled();
|
||||
|
||||
renderer.pageColors = { background: '#fff', foreground: '#000', keepImages: true };
|
||||
expect(onZoom).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { captureScrollModeAnchor, restoreScrollModeAnchor } from 'foliate-js/fixed-layout.js';
|
||||
import {
|
||||
captureScrollModeAnchor,
|
||||
findScrollPageRange,
|
||||
layoutScrollPages,
|
||||
restoreScrollModeAnchor,
|
||||
} from 'foliate-js/fixed-layout.js';
|
||||
|
||||
// The helpers are 1-D interval math along the scroll axis: vertical mode feeds
|
||||
// offsetTop/offsetHeight, horizontal mode (readest#4995) feeds
|
||||
|
|
@ -69,3 +74,28 @@ describe('fixed-layout scroll mode anchor preservation', () => {
|
|||
expect(anchor).toEqual({ index: 1, fraction: 0.25, scrollPos: 368 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixed-layout scroll mode page layout', () => {
|
||||
it('lays pages out like a flex column with a gap around each page', () => {
|
||||
expect(layoutScrollPages({ sizes: [100, 200, 100], gap: 4, overlap: 0 })).toEqual({
|
||||
starts: [4, 112, 320],
|
||||
sizes: [100, 200, 100],
|
||||
total: 424,
|
||||
});
|
||||
});
|
||||
|
||||
it('pulls each page after the first onto the previous one by the overlap', () => {
|
||||
const { starts, total } = layoutScrollPages({ sizes: [100, 100], gap: 0, overlap: 1 });
|
||||
expect(starts).toEqual([0, 99]);
|
||||
expect(total).toBe(199);
|
||||
});
|
||||
|
||||
it('finds the pages overlapping a span of the strip', () => {
|
||||
const layout = layoutScrollPages({ sizes: [100, 100, 100, 100], gap: 4, overlap: 0 });
|
||||
// pages sit at [4,104) [112,212) [220,320) [328,428)
|
||||
expect(findScrollPageRange(layout, 150, 330)).toEqual([1, 4]);
|
||||
expect(findScrollPageRange(layout, 106, 110)).toEqual([1, 1]);
|
||||
expect(findScrollPageRange(layout, -500, 0)).toEqual([0, 0]);
|
||||
expect(findScrollPageRange(layout, 1000, 2000)).toEqual([4, 4]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import 'foliate-js/fixed-layout.js';
|
||||
|
||||
// Scroll mode used to put one placeholder per page in the DOM. On WebKit every
|
||||
// frame and every page load then cost time proportional to the page count, and
|
||||
// a 2752-page PDF scrolled at a few frames per second on iOS. Only the pages
|
||||
// around the viewport may be in the DOM; the rest of the strip is arithmetic.
|
||||
|
||||
const PAGE_HTML = `<!doctype html><html><head><style>
|
||||
html, body { margin: 0; height: 1000px; }
|
||||
</style></head><body></body></html>`;
|
||||
|
||||
const PAGES = 2000;
|
||||
// 600x1000 pages fitted to a 300px wide host: 500px tall, 4px gap each side.
|
||||
const PAGE_HEIGHT = 500;
|
||||
const STEP = PAGE_HEIGHT + 8;
|
||||
|
||||
type Renderer = HTMLElement & {
|
||||
open(book: unknown): void;
|
||||
goTo(target: { index: number }): Promise<void>;
|
||||
destroy(): void;
|
||||
readonly index: number;
|
||||
};
|
||||
|
||||
const makeBook = () => ({
|
||||
dir: 'ltr',
|
||||
rendition: { viewport: { width: 600, height: 1000 }, spread: 'none' },
|
||||
sections: Array.from({ length: PAGES }, () => ({
|
||||
load: async () => ({ src: 'srcdoc', data: PAGE_HTML }),
|
||||
linear: 'yes',
|
||||
})),
|
||||
});
|
||||
|
||||
const waitFor = async (condition: () => boolean, timeout = 4000): Promise<void> => {
|
||||
const start = performance.now();
|
||||
while (!condition()) {
|
||||
if (performance.now() - start > timeout) throw new Error('waitFor timed out');
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
}
|
||||
};
|
||||
|
||||
const nextFrames = async (count = 2) => {
|
||||
for (let i = 0; i < count; i++) await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
};
|
||||
|
||||
let renderer: Renderer | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
renderer?.destroy();
|
||||
renderer?.remove();
|
||||
renderer = null;
|
||||
});
|
||||
|
||||
const mount = () => {
|
||||
renderer = document.createElement('foliate-fxl') as Renderer;
|
||||
renderer.style.width = '300px';
|
||||
renderer.style.height = '600px';
|
||||
renderer.setAttribute('flow', 'scrolled');
|
||||
document.body.append(renderer);
|
||||
renderer.open(makeBook());
|
||||
return renderer;
|
||||
};
|
||||
|
||||
const pageEls = () =>
|
||||
Array.from(renderer!.shadowRoot!.querySelectorAll<HTMLElement>('.scroll-page'));
|
||||
|
||||
const pageTop = (el: HTMLElement) =>
|
||||
el.getBoundingClientRect().top - renderer!.getBoundingClientRect().top + renderer!.scrollTop;
|
||||
|
||||
describe('fixed-layout scroll mode virtualization', () => {
|
||||
it('mounts only the pages around the viewport', () => {
|
||||
const r = mount();
|
||||
expect(pageEls().length).toBeLessThan(30);
|
||||
expect(r.scrollHeight).toBeCloseTo(PAGES * STEP, 0);
|
||||
});
|
||||
|
||||
it('lays every page where the flex strip put it', async () => {
|
||||
const r = mount();
|
||||
await nextFrames();
|
||||
r.scrollTop = 1234 * STEP + 100;
|
||||
await waitFor(() => pageEls().some((el) => el.dataset['index'] === '1234'));
|
||||
const els = pageEls();
|
||||
const indices = els.map((el) => Number(el.dataset['index']));
|
||||
expect(indices).toEqual([...indices].sort((a, b) => a - b));
|
||||
expect(els.length).toBeLessThan(30);
|
||||
for (const el of els)
|
||||
expect(pageTop(el)).toBeCloseTo(4 + Number(el.dataset['index']) * STEP, 0);
|
||||
});
|
||||
|
||||
it('goes to a far page and loads it', async () => {
|
||||
const r = mount();
|
||||
await r.goTo({ index: 1500 });
|
||||
expect(r.scrollTop).toBeCloseTo(4 + 1500 * STEP, 0);
|
||||
expect(r.index).toBe(1500);
|
||||
await waitFor(() => !!r.shadowRoot!.querySelector('.scroll-page[data-index="1500"] iframe'));
|
||||
expect(pageEls().length).toBeLessThan(30);
|
||||
});
|
||||
});
|
||||
|
|
@ -96,10 +96,14 @@ describe('fixed-layout Webtoon Mode seams (readest#6484)', () => {
|
|||
'211',
|
||||
])('leaves no line between vertically scrolled pages at scale-factor %s', async (scaleFactor) => {
|
||||
const loaded = await openStrip(scaleFactor, false);
|
||||
const pages = renderer!.shadowRoot!.querySelectorAll<HTMLElement>('.scroll-page');
|
||||
const hostRect = renderer!.getBoundingClientRect();
|
||||
for (const boundary of [1, 2]) {
|
||||
const top = pages[boundary]!.getBoundingClientRect().top;
|
||||
// Only the pages near the viewport are mounted, so find the boundary
|
||||
// from the page before it, which is on screen.
|
||||
const before = renderer!.shadowRoot!.querySelector(
|
||||
`.scroll-page[data-index="${boundary - 1}"]`,
|
||||
);
|
||||
const top = before!.getBoundingClientRect().bottom;
|
||||
renderer!.scrollTop += top - hostRect.top - hostRect.height / 2;
|
||||
await waitFor(() => loaded.has(boundary - 1) && loaded.has(boundary));
|
||||
await nextFrames();
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { expectsColumnSpread } from '@/utils/config';
|
|||
import { getPageAreaInsets, getReadingScreenInsets, getSpreadColumnGap } from '@/utils/insets';
|
||||
import { tauriSetWindowTitle } from '@/utils/window';
|
||||
import { useContentInsets } from '../hooks/useContentInsets';
|
||||
import { usePageEdges } from '../hooks/usePageEdges';
|
||||
import { usePageOverflow } from '../hooks/usePageOverflow';
|
||||
import { type BottomCornerRadii, getCellCornerRadii, NO_CORNERS } from '../utils/footerBand';
|
||||
import SearchResultsNav from './sidebar/SearchResultsNav';
|
||||
import BooknotesNav from './sidebar/BooknotesNav';
|
||||
|
|
@ -158,9 +158,9 @@ const BookCellInner: React.FC<BookCellProps> = ({
|
|||
// though saveViewSettings mutates viewSettings in place (#4898).
|
||||
const { viewInsets, contentInsets } = useContentInsets(viewSettings, gridInsets);
|
||||
// A fixed-layout page fills the screen in fit-page mode; zoomed in or
|
||||
// scrolled, its header and footer show only at its own top / bottom edge,
|
||||
// not over its content (#6596).
|
||||
const pageEdges = usePageEdges(
|
||||
// scrolled, its header and footer would cover its content, so they hide
|
||||
// (#6596).
|
||||
const pageOverflowing = usePageOverflow(
|
||||
getView(bookKey)?.renderer,
|
||||
!!bookData?.isFixedLayout && !viewSettings?.vertical,
|
||||
);
|
||||
|
|
@ -269,7 +269,7 @@ const BookCellInner: React.FC<BookCellProps> = ({
|
|||
contentInsets={contentInsets}
|
||||
gridInsets={gridInsets}
|
||||
columnGap={columnGap}
|
||||
hidden={!pageEdges.top}
|
||||
hidden={pageOverflowing}
|
||||
/>
|
||||
)}
|
||||
<HintInfo
|
||||
|
|
@ -305,7 +305,7 @@ const BookCellInner: React.FC<BookCellProps> = ({
|
|||
gridInsets={gridInsets}
|
||||
columnGap={columnGap}
|
||||
cornerRadii={cornerRadii}
|
||||
hidden={!pageEdges.bottom}
|
||||
hidden={pageOverflowing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ interface ProgressBarProps {
|
|||
columnGap?: number;
|
||||
// Rounded screen corners this footer's ends run into.
|
||||
cornerRadii?: BottomCornerRadii;
|
||||
// A fixed-layout page whose bottom edge is off screen (usePageEdges).
|
||||
// A fixed-layout page that runs past the viewport (usePageOverflow).
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ interface SectionInfoProps {
|
|||
gridInsets: Insets;
|
||||
// The spread's Column Gap (px) in effect, 0 when none (getSpreadColumnGap).
|
||||
columnGap?: number;
|
||||
// A fixed-layout page whose top edge is off screen (usePageEdges).
|
||||
// A fixed-layout page that runs past the viewport (usePageOverflow).
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,24 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import type { Renderer } from '@/types/view';
|
||||
import { getPageEdges } from '../utils/pageEdges';
|
||||
import { isPageOverflowing } from '../utils/pageOverflow';
|
||||
|
||||
const ALL_EDGES = { top: true, bottom: true };
|
||||
|
||||
// Tracks whether a fixed-layout page's top / bottom edge is at the viewport's
|
||||
// (see getPageEdges), so the header / footer show only there (#6596).
|
||||
export const usePageEdges = (renderer: Renderer | undefined, enabled: boolean) => {
|
||||
const [edges, setEdges] = useState(ALL_EDGES);
|
||||
// Tracks whether a fixed-layout page runs past the viewport (see
|
||||
// isPageOverflowing), so its header and footer hide only then (#6596).
|
||||
export const usePageOverflow = (renderer: Renderer | undefined, enabled: boolean) => {
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renderer || !enabled) return;
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const next = getPageEdges(renderer);
|
||||
setEdges((prev) => (prev.top === next.top && prev.bottom === next.bottom ? prev : next));
|
||||
setOverflowing(isPageOverflowing(renderer));
|
||||
};
|
||||
const schedule = () => {
|
||||
if (!frame) frame = requestAnimationFrame(update);
|
||||
};
|
||||
// Scrolling pans the page; a zoom, page turn or resize lays it out again,
|
||||
// A zoom, page turn, flow change or resize lays the page out again,
|
||||
// restyling the page boxes in the renderer's shadow root.
|
||||
renderer.addEventListener('scroll', schedule, { passive: true });
|
||||
const mutationObserver = new MutationObserver(schedule);
|
||||
if (renderer.shadowRoot) {
|
||||
mutationObserver.observe(renderer.shadowRoot, {
|
||||
|
|
@ -37,11 +33,10 @@ export const usePageEdges = (renderer: Renderer | undefined, enabled: boolean) =
|
|||
schedule();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
renderer.removeEventListener('scroll', schedule);
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [renderer, enabled]);
|
||||
|
||||
return enabled ? edges : ALL_EDGES;
|
||||
return enabled && overflowing;
|
||||
};
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import type { Renderer } from '@/types/view';
|
||||
|
||||
// How far (px) a fixed-layout page's top / bottom edge may sit from the
|
||||
// viewport's before its header / footer is hidden (#6596).
|
||||
const PAGE_EDGE_TOLERANCE = 20;
|
||||
|
||||
interface Span {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
// Whether a page runs through the line `edge` by more than the tolerance on
|
||||
// both sides, i.e. its own edge is nowhere near the viewport's. Spans are in
|
||||
// order along the axis, so only the first one ending past the band can.
|
||||
const crossesEdge = (count: number, spanAt: (i: number) => Span, edge: number) => {
|
||||
let lo = 0;
|
||||
let hi = count;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (spanAt(mid).end > edge + PAGE_EDGE_TOLERANCE) hi = mid;
|
||||
else lo = mid + 1;
|
||||
}
|
||||
return lo < count && spanAt(lo).start < edge - PAGE_EDGE_TOLERANCE;
|
||||
};
|
||||
|
||||
// Whether the page's top / bottom edge sits at the viewport's, so the header /
|
||||
// footer lands on the page's own margin rather than over its content. In
|
||||
// fit-page mode the page fits the viewport and both always show.
|
||||
export const getPageEdges = (renderer: Renderer) => {
|
||||
const viewTop = renderer.getBoundingClientRect().top;
|
||||
const viewBottom = viewTop + renderer.clientHeight;
|
||||
let count = 1;
|
||||
let spanAt: (i: number) => Span;
|
||||
if (renderer.scrolled && renderer.getAttribute('scroll-direction') !== 'horizontal') {
|
||||
// Vertical scroll flow stacks the pages, each with its own edges.
|
||||
const pages = renderer.shadowRoot?.querySelectorAll('.scroll-page') ?? [];
|
||||
count = pages.length;
|
||||
spanAt = (i) => {
|
||||
const rect = pages[i]!.getBoundingClientRect();
|
||||
return { start: rect.top, end: rect.bottom };
|
||||
};
|
||||
} else {
|
||||
// A paginated spread, or the row of a horizontal scroll flow, is one
|
||||
// block that pans vertically within the host once zoomed past its height.
|
||||
const start = viewTop - renderer.scrollTop;
|
||||
spanAt = () => ({ start, end: start + renderer.scrollHeight });
|
||||
}
|
||||
return {
|
||||
top: !crossesEdge(count, spanAt, viewTop),
|
||||
bottom: !crossesEdge(count, spanAt, viewBottom),
|
||||
};
|
||||
};
|
||||
12
apps/readest-app/src/app/reader/utils/pageOverflow.ts
Normal file
12
apps/readest-app/src/app/reader/utils/pageOverflow.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { Renderer } from '@/types/view';
|
||||
|
||||
// Whether a fixed-layout page (PDF, CBZ) runs past the viewport vertically, so
|
||||
// its header and footer, which overlay the page, would cover its content
|
||||
// (#6596): always in vertical scroll flow, where the pages run on under them,
|
||||
// and when a zoomed page or horizontal flow pans vertically. Decided by layout,
|
||||
// never by scroll position: showing the chrome at each page's edges made it
|
||||
// flash on and off while scrolling, and on iOS every flip ended momentum
|
||||
// scrolling dead.
|
||||
export const isPageOverflowing = (renderer: Renderer) =>
|
||||
(renderer.scrolled && renderer.getAttribute('scroll-direction') !== 'horizontal') ||
|
||||
renderer.scrollHeight > renderer.clientHeight + 1;
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit bc7326725e7b154922a473b211a18ae9c4c38636
|
||||
Subproject commit 97e44bfebf106361f8056226a70e7c45faa7e18a
|
||||
Loading…
Add table
Add a link
Reference in a new issue