{
Footer: ListPadding,
ScrollSeekPlaceholder,
}}
+ increaseViewportBy={{ top: overscan, bottom: overscan }}
context={{
viewportInfo: {
width: viewportInfo.width - 40,
diff --git a/packages/frontend/core/src/modules/pdf/cache/bitmap-cache.ts b/packages/frontend/core/src/modules/pdf/cache/bitmap-cache.ts
new file mode 100644
index 000000000..ac64aa55f
--- /dev/null
+++ b/packages/frontend/core/src/modules/pdf/cache/bitmap-cache.ts
@@ -0,0 +1,165 @@
+type CacheKey = string;
+
+type CacheEntry = {
+ key: CacheKey;
+ blobId: string;
+ pageNum: number;
+ width: number;
+ height: number;
+ scale: number;
+ blob: Blob;
+};
+
+type CacheParams = {
+ blobId: string;
+ pageNum: number;
+ width: number;
+ height: number;
+ scale: number;
+};
+
+class BitmapLRU {
+ private readonly map = new Map
();
+
+ constructor(private readonly maxEntries: number) {}
+
+ has(key: CacheKey) {
+ return this.map.has(key);
+ }
+
+ get(key: CacheKey): CacheEntry | null {
+ const entry = this.map.get(key);
+ if (!entry) return null;
+ this.map.delete(key);
+ this.map.set(key, entry);
+ return entry;
+ }
+
+ findReusable(
+ params: CacheParams,
+ upscaleThreshold: number
+ ): CacheEntry | null {
+ let best: CacheEntry | null = null;
+
+ for (const entry of this.map.values()) {
+ if (
+ entry.blobId !== params.blobId ||
+ entry.pageNum !== params.pageNum ||
+ entry.scale !== params.scale
+ ) {
+ continue;
+ }
+
+ const ratio = Math.max(
+ params.width / entry.width,
+ params.height / entry.height
+ );
+
+ if (ratio > upscaleThreshold) continue;
+
+ if (!best || entry.width * entry.height > best.width * best.height) {
+ best = entry;
+ }
+ }
+
+ if (!best) return null;
+
+ this.map.delete(best.key);
+ this.map.set(best.key, best);
+ return best;
+ }
+
+ set(entry: CacheEntry) {
+ if (this.map.has(entry.key)) {
+ this.map.delete(entry.key);
+ }
+
+ this.map.set(entry.key, entry);
+
+ while (this.map.size > this.maxEntries) {
+ const oldest = this.map.keys().next().value as CacheKey | undefined;
+ if (oldest === undefined) break;
+ this.map.delete(oldest);
+ }
+ }
+}
+
+const MAX_ENTRIES = 64;
+const UPSCALE_THRESHOLD = 1.3;
+const QUALITY = 0.72;
+
+const cache = new BitmapLRU(MAX_ENTRIES);
+
+const normalize = (value: number) => Math.round(value);
+const toKey = ({
+ blobId,
+ pageNum,
+ width,
+ height,
+ scale,
+}: CacheParams): CacheKey =>
+ `${blobId}:${pageNum}:${normalize(width)}:${normalize(height)}:${scale}`;
+
+export async function getReusableBitmap(
+ params: CacheParams
+): Promise {
+ const exact = cache.get(toKey(params));
+ if (exact) {
+ try {
+ return await createImageBitmap(exact.blob);
+ } catch {
+ return null;
+ }
+ }
+
+ const reusable = cache.findReusable(params, UPSCALE_THRESHOLD);
+ if (!reusable) return null;
+
+ try {
+ return await createImageBitmap(reusable.blob);
+ } catch {
+ return null;
+ }
+}
+
+export async function cacheBitmap(params: CacheParams, bitmap: ImageBitmap) {
+ const key = toKey(params);
+ if (cache.has(key)) return;
+
+ try {
+ const blob = await bitmapToWebp(bitmap);
+ if (!blob) return;
+ cache.set({ key, ...params, blob });
+ } catch (e) {
+ console.error('Failed to convert bitmap', e);
+ }
+}
+
+async function bitmapToWebp(bitmap: ImageBitmap): Promise {
+ const width = bitmap.width;
+ const height = bitmap.height;
+
+ if (typeof OffscreenCanvas !== 'undefined') {
+ const canvas = new OffscreenCanvas(width, height);
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return null;
+ ctx.drawImage(bitmap, 0, 0, width, height);
+ return canvas.convertToBlob({
+ type: 'image/webp',
+ quality: QUALITY,
+ });
+ }
+
+ if (typeof document === 'undefined') return null;
+
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return null;
+ ctx.drawImage(bitmap, 0, 0, width, height);
+
+ return new Promise(resolve => {
+ canvas.toBlob(blob => resolve(blob), 'image/webp', QUALITY);
+ });
+}
diff --git a/packages/frontend/core/src/modules/pdf/views/page-renderer.tsx b/packages/frontend/core/src/modules/pdf/views/page-renderer.tsx
index df221a7b4..61acde1e7 100644
--- a/packages/frontend/core/src/modules/pdf/views/page-renderer.tsx
+++ b/packages/frontend/core/src/modules/pdf/views/page-renderer.tsx
@@ -4,6 +4,7 @@ import { useLiveData } from '@toeverything/infra';
import { debounce } from 'lodash-es';
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
+import { cacheBitmap, getReusableBitmap } from '../cache/bitmap-cache';
import type { PDF } from '../entities/pdf';
import type { PDFPage } from '../entities/pdf-page';
import type { PageSize } from '../renderer/types';
@@ -28,6 +29,89 @@ interface PDFPageProps {
isThumbnail?: boolean;
}
+function usePDFPage({
+ pdf,
+ pageNum,
+ width,
+ height,
+ scale,
+ visibility,
+}: {
+ pdf: PDF;
+ pageNum: number;
+ width: number;
+ height: number;
+ scale: number;
+ visibility: boolean;
+}) {
+ const [page, setPage] = useState(null);
+ const [cachedBitmap, setCachedBitmap] = useState(null);
+ const img = useLiveData(useMemo(() => (page ? page.bitmap$ : null), [page]));
+ const error = useLiveData(page?.error$ ?? null);
+
+ // Consolidated effect to handle loading strategy (Cache vs Render)
+ useEffect(() => {
+ if (!visibility || !width || !height) {
+ setPage(null);
+ return;
+ }
+
+ let active = true;
+ let releasePage: (() => void) | undefined;
+
+ const load = async () => {
+ try {
+ // 1. Try cache
+ const compressed = await getReusableBitmap({
+ blobId: pdf.id,
+ pageNum,
+ width,
+ height,
+ scale,
+ });
+
+ if (!active) return;
+
+ if (compressed) {
+ setCachedBitmap(compressed);
+ setPage(null);
+ } else {
+ // 2. Load Page
+ setCachedBitmap(null); // Clear stale cache
+
+ const key = `${width}:${height}:${scale}`;
+ const { page: newPage, release } = pdf.page(pageNum, key);
+ releasePage = release;
+
+ setPage(newPage);
+ newPage.render({ width, height, scale });
+ }
+ } catch (e) {
+ console.error('Failed to load PDF page', e);
+ }
+ };
+
+ load().catch(console.error);
+
+ return () => {
+ active = false;
+ releasePage?.();
+ setPage(null);
+ };
+ }, [visibility, pdf, pageNum, width, height, scale]);
+
+ // Cache new bitmap when generated
+ useEffect(() => {
+ if (!img || !page) return;
+
+ cacheBitmap({ blobId: pdf.id, pageNum, width, height, scale }, img).catch(
+ e => console.error('Failed to cache bitmap', e)
+ );
+ }, [img, page, pdf.id, pageNum, width, height, scale]);
+
+ return { displayImg: cachedBitmap ?? img, error };
+}
+
export const PDFPageRenderer = ({
pdf,
pageNum,
@@ -43,62 +127,32 @@ export const PDFPageRenderer = ({
const t = useI18n();
const pageViewRef = useRef(null);
const canvasRef = useRef(null);
- const [page, setPage] = useState(null);
- const img = useLiveData(useMemo(() => (page ? page.bitmap$ : null), [page]));
- const error = useLiveData(page?.error$ ?? null);
const size = useMemo(
() => resize(viewportInfo, actualSize, maxSize, isThumbnail),
[resize, viewportInfo, actualSize, maxSize, isThumbnail]
);
const [visibility, setVisibility] = useState(false);
+ const { displayImg, error } = usePDFPage({
+ pdf,
+ pageNum,
+ width: size.width,
+ height: size.height,
+ scale,
+ visibility,
+ });
+
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
- if (!img) return;
+ if (!displayImg) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
- canvas.width = img.width;
- canvas.height = img.height;
- ctx.drawImage(img, 0, 0);
- }, [img]);
-
- useEffect(() => {
- if (!visibility) return;
- if (!page) return;
-
- const width = size.width;
- const height = size.height;
- if (width * height === 0) return;
-
- page.render({
- width,
- height,
- scale,
- });
-
- return () => {
- page.render.unsubscribe();
- };
- }, [visibility, page, size, scale]);
-
- useEffect(() => {
- if (!visibility) return;
- if (!pdf) return;
-
- const width = size.width;
- const height = size.height;
- const key = `${width}:${height}:${scale}`;
- const { page, release } = pdf.page(pageNum, key);
-
- setPage(page);
-
- return () => {
- release();
- setPage(null);
- };
- }, [visibility, pdf, pageNum, size, scale]);
+ canvas.width = displayImg.width;
+ canvas.height = displayImg.height;
+ ctx.drawImage(displayImg, 0, 0);
+ }, [displayImg]);
useEffect(() => {
const pageView = pageViewRef.current;
@@ -126,7 +180,7 @@ export const PDFPageRenderer = ({
onClick={() => onSelect?.(pageNum)}
>