mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 12:36:24 +08:00
feat(core): pdf viewer supports fit to page (#8812)
### What's Changed * fits to page by default * supports rendering pages of different sizes https://github.com/user-attachments/assets/bf34e6d1-5546-4af1-a131-b801a67e9ace
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
||||
LiveData,
|
||||
mapInto,
|
||||
} from '@toeverything/infra';
|
||||
import { map, switchMap } from 'rxjs';
|
||||
import { filter, map, switchMap } from 'rxjs';
|
||||
|
||||
import type { RenderPageOpts } from '../renderer';
|
||||
import type { PDF } from './pdf';
|
||||
@@ -25,7 +25,8 @@ export class PDFPage extends Entity<{ pdf: PDF; pageNum: number }> {
|
||||
pageNum: this.pageNum,
|
||||
})
|
||||
),
|
||||
map(data => data.bitmap),
|
||||
map(data => data?.bitmap),
|
||||
filter(Boolean),
|
||||
mapInto(this.bitmap$),
|
||||
catchErrorInto(this.error$, error => {
|
||||
logger.error('Failed to render page', error);
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
export type PDFMeta = {
|
||||
pageCount: number;
|
||||
export type PageSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type PDFMeta = {
|
||||
pageCount: number;
|
||||
maxSize: PageSize;
|
||||
pageSizes: PageSize[];
|
||||
};
|
||||
|
||||
export type PageSizeOpts = {
|
||||
pageNum: number;
|
||||
};
|
||||
|
||||
export type RenderPageOpts = {
|
||||
pageNum: number;
|
||||
width: number;
|
||||
height: number;
|
||||
scale?: number;
|
||||
};
|
||||
} & PageSize;
|
||||
|
||||
export type RenderedPage = RenderPageOpts & {
|
||||
export type RenderedPage = {
|
||||
bitmap: ImageBitmap;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
map,
|
||||
Observable,
|
||||
ReplaySubject,
|
||||
retry,
|
||||
share,
|
||||
switchMap,
|
||||
} from 'rxjs';
|
||||
@@ -32,7 +33,7 @@ class PDFRendererBackend extends OpConsumer<ClientOps> {
|
||||
|
||||
private readonly doc$ = this.binary$.pipe(
|
||||
filter(Boolean),
|
||||
combineLatestWith(this.viewer$),
|
||||
combineLatestWith(this.viewer$.pipe(retry(1))),
|
||||
switchMap(([buffer, viewer]) => {
|
||||
return new Observable<Document | undefined>(observer => {
|
||||
const doc = viewer.open(buffer);
|
||||
@@ -45,7 +46,9 @@ class PDFRendererBackend extends OpConsumer<ClientOps> {
|
||||
observer.next(doc);
|
||||
|
||||
return () => {
|
||||
doc.close();
|
||||
setTimeout(() => {
|
||||
doc.close();
|
||||
}, 1000); // Waits for ObjectPool GC
|
||||
};
|
||||
});
|
||||
}),
|
||||
@@ -60,15 +63,32 @@ class PDFRendererBackend extends OpConsumer<ClientOps> {
|
||||
throw new Error('Document not opened');
|
||||
}
|
||||
|
||||
const firstPage = doc.page(0);
|
||||
if (!firstPage) {
|
||||
throw new Error('Document has no pages');
|
||||
const pageCount = doc.pageCount();
|
||||
const pageSizes = [];
|
||||
let i = 0;
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
for (; i < pageCount; i++) {
|
||||
const page = doc.page(i);
|
||||
if (!page) {
|
||||
throw new Error('Page not found');
|
||||
}
|
||||
const size = page.size();
|
||||
const width = Math.ceil(size.width);
|
||||
const height = Math.ceil(size.height);
|
||||
|
||||
maxWidth = Math.max(maxWidth, width);
|
||||
maxHeight = Math.max(maxHeight, height);
|
||||
|
||||
pageSizes.push({ width, height });
|
||||
page.close();
|
||||
}
|
||||
|
||||
return {
|
||||
pageCount: doc.pageCount(),
|
||||
width: firstPage.width(),
|
||||
height: firstPage.height(),
|
||||
pageCount,
|
||||
pageSizes,
|
||||
maxSize: { width: maxWidth, height: maxHeight },
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -100,7 +120,6 @@ class PDFRendererBackend extends OpConsumer<ClientOps> {
|
||||
|
||||
async renderPage(viewer: Viewer, doc: Document, opts: RenderPageOpts) {
|
||||
const page = doc.page(opts.pageNum);
|
||||
|
||||
if (!page) return;
|
||||
|
||||
const scale = opts.scale ?? 1;
|
||||
|
||||
@@ -3,11 +3,20 @@ import clsx from 'clsx';
|
||||
import { type CSSProperties, forwardRef, memo } from 'react';
|
||||
import type { ScrollSeekPlaceholderProps, VirtuosoProps } from 'react-virtuoso';
|
||||
|
||||
import type { PDFMeta } from '../renderer';
|
||||
import type { PageSize } from '../renderer/types';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export type PDFVirtuosoContext = {
|
||||
width: number;
|
||||
height: number;
|
||||
viewportInfo: PageSize;
|
||||
meta: PDFMeta;
|
||||
resize: (
|
||||
viewportInfo: PageSize,
|
||||
actualSize: PageSize,
|
||||
maxSize: PageSize,
|
||||
isThumbnail?: boolean
|
||||
) => { aspectRatio: number } & PageSize;
|
||||
isThumbnail?: boolean;
|
||||
pageClassName?: string;
|
||||
onPageSelect?: (index: number) => void;
|
||||
};
|
||||
@@ -32,16 +41,29 @@ export const ScrollSeekPlaceholder = forwardRef<
|
||||
ScrollSeekPlaceholderProps & {
|
||||
context?: PDFVirtuosoContext;
|
||||
}
|
||||
>(({ context }, ref) => {
|
||||
>(({ context, index }, ref) => {
|
||||
const className = context?.pageClassName;
|
||||
const width = context?.width ?? 537;
|
||||
const height = context?.height ?? 759;
|
||||
const style = { width, aspectRatio: `${width} / ${height}` };
|
||||
const isThumbnail = context?.isThumbnail;
|
||||
const size = context?.meta.pageSizes[index];
|
||||
const maxSize = context?.meta.maxSize;
|
||||
const height = size?.height ?? 759;
|
||||
const style =
|
||||
context?.viewportInfo && size && maxSize
|
||||
? context.resize(context.viewportInfo, size, maxSize, isThumbnail)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className={className} style={style} ref={ref}>
|
||||
<LoadingSvg />
|
||||
</div>
|
||||
<Item
|
||||
data-index={index}
|
||||
data-known-size={height}
|
||||
data-item-index={index}
|
||||
style={{ overflowAnchor: 'none' }}
|
||||
ref={ref}
|
||||
>
|
||||
<div className={className} style={style}>
|
||||
<LoadingSvg />
|
||||
</div>
|
||||
</Item>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -114,8 +136,18 @@ export const LoadingSvg = memo(
|
||||
|
||||
LoadingSvg.displayName = 'pdf-loading';
|
||||
|
||||
export const PDFPageCanvas = forwardRef<HTMLCanvasElement>((props, ref) => {
|
||||
return <canvas className={styles.pdfPageCanvas} ref={ref} {...props} />;
|
||||
export const PDFPageCanvas = forwardRef<
|
||||
HTMLCanvasElement,
|
||||
{ style?: CSSProperties }
|
||||
>(({ style, ...props }, ref) => {
|
||||
return (
|
||||
<canvas
|
||||
className={styles.pdfPageCanvas}
|
||||
ref={ref}
|
||||
style={style}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
PDFPageCanvas.displayName = 'pdf-page-canvas';
|
||||
|
||||
@@ -1,54 +1,56 @@
|
||||
import { observeIntersection } from '@affine/component';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData } from '@toeverything/infra';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { PDF } from '../entities/pdf';
|
||||
import type { PDFPage } from '../entities/pdf-page';
|
||||
import type { PageSize } from '../renderer/types';
|
||||
import { LoadingSvg, PDFPageCanvas } from './components';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
interface PDFPageProps {
|
||||
pdf: PDF;
|
||||
width: number;
|
||||
height: number;
|
||||
pageNum: number;
|
||||
actualSize: PageSize;
|
||||
maxSize: PageSize;
|
||||
viewportInfo: PageSize;
|
||||
resize: (
|
||||
viewportInfo: PageSize,
|
||||
actualSize: PageSize,
|
||||
maxSize: PageSize,
|
||||
isThumbnail?: boolean
|
||||
) => { aspectRatio: number } & PageSize;
|
||||
scale?: number;
|
||||
className?: string;
|
||||
onSelect?: (pageNum: number) => void;
|
||||
isThumbnail?: boolean;
|
||||
}
|
||||
|
||||
export const PDFPageRenderer = ({
|
||||
pdf,
|
||||
width,
|
||||
height,
|
||||
pageNum,
|
||||
className,
|
||||
actualSize,
|
||||
maxSize,
|
||||
viewportInfo,
|
||||
onSelect,
|
||||
resize,
|
||||
isThumbnail,
|
||||
scale = window.devicePixelRatio,
|
||||
}: PDFPageProps) => {
|
||||
const t = useI18n();
|
||||
const [pdfPage, setPdfPage] = useState<PDFPage | null>(null);
|
||||
const pageViewRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const img = useLiveData(pdfPage?.bitmap$ ?? null);
|
||||
const error = useLiveData(pdfPage?.error$ ?? null);
|
||||
const style = { width, aspectRatio: `${width} / ${height}` };
|
||||
|
||||
useEffect(() => {
|
||||
if (width * height === 0) return;
|
||||
|
||||
const { page, release } = pdf.page(pageNum, `${width}:${height}:${scale}`);
|
||||
setPdfPage(page);
|
||||
|
||||
return release;
|
||||
}, [pdf, width, height, pageNum, scale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (width * height === 0) return;
|
||||
|
||||
pdfPage?.render({ width, height, scale });
|
||||
|
||||
return pdfPage?.render.unsubscribe;
|
||||
}, [pdfPage, width, height, scale]);
|
||||
const [page, setPage] = useState<PDFPage | null>(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);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -56,30 +58,107 @@ export const PDFPageRenderer = ({
|
||||
if (!img) 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;
|
||||
|
||||
canvas.width = width * scale;
|
||||
canvas.height = height * scale;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
}, [img, width, height, scale]);
|
||||
page.render({
|
||||
width,
|
||||
height,
|
||||
scale,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<p className={styles.pdfPageError}>
|
||||
{t['com.affine.pdf.page.render.error']()}
|
||||
</p>
|
||||
</div>
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
const pageView = pageViewRef.current;
|
||||
if (!pageView) return;
|
||||
|
||||
return observeIntersection(
|
||||
pageView,
|
||||
debounce(
|
||||
entry => {
|
||||
setVisibility(entry.isIntersecting);
|
||||
},
|
||||
377,
|
||||
{
|
||||
trailing: true,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageViewRef}
|
||||
className={className}
|
||||
style={style}
|
||||
style={resize?.(viewportInfo, actualSize, maxSize, isThumbnail)}
|
||||
onClick={() => onSelect?.(pageNum)}
|
||||
>
|
||||
{img === null ? <LoadingSvg /> : <PDFPageCanvas ref={canvasRef} />}
|
||||
<PageRendererInner
|
||||
img={img}
|
||||
ref={canvasRef}
|
||||
err={error ? t['com.affine.pdf.page.render.error']() : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PageRendererInnerProps {
|
||||
img: ImageBitmap | null;
|
||||
err: string | null;
|
||||
}
|
||||
|
||||
const PageRendererInner = forwardRef<HTMLCanvasElement, PageRendererInnerProps>(
|
||||
({ img, err }, ref) => {
|
||||
if (img) {
|
||||
return (
|
||||
<PDFPageCanvas
|
||||
ref={ref}
|
||||
style={{
|
||||
height: img.height / 2,
|
||||
aspectRatio: `${img.width} / ${img.height}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (err) {
|
||||
return <p className={styles.pdfPageError}>{err}</p>;
|
||||
}
|
||||
|
||||
return <LoadingSvg />;
|
||||
}
|
||||
);
|
||||
|
||||
PageRendererInner.displayName = 'pdf-page-renderer-inner';
|
||||
|
||||
@@ -10,6 +10,7 @@ export const virtuosoList = style({
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 'calc(100% - 40px)',
|
||||
gap: '20px',
|
||||
selectors: {
|
||||
'&.small-gap': {
|
||||
@@ -40,15 +41,14 @@ export const pdfPageError = style({
|
||||
});
|
||||
|
||||
export const pdfPageCanvas = style({
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
});
|
||||
|
||||
export const pdfLoading = style({
|
||||
display: 'flex',
|
||||
alignSelf: 'center',
|
||||
margin: 'auto',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '537px',
|
||||
width: '179.66px',
|
||||
height: '253px',
|
||||
aspectRatio: '539 / 759',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user