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:
fundon
2024-12-26 05:28:18 +00:00
parent bb43e4b664
commit 42ab507d30
12 changed files with 348 additions and 115 deletions

View File

@@ -5,6 +5,8 @@ import {
PDFService,
PDFStatus,
} from '@affine/core/modules/pdf';
import type { PDFMeta } from '@affine/core/modules/pdf/renderer';
import type { PageSize } from '@affine/core/modules/pdf/renderer/types';
import { LoadingSvg, PDFPageCanvas } from '@affine/core/modules/pdf/views';
import { PeekViewService } from '@affine/core/modules/peek-view';
import { stopPropagation } from '@affine/core/utils';
@@ -30,9 +32,18 @@ import type { PDFViewerProps } from './pdf-viewer';
import * as styles from './styles.css';
import * as embeddedStyles from './styles.embedded.css';
function defaultMeta() {
return {
pageCount: 0,
pageSizes: [],
maxSize: { width: 0, height: 0 },
};
}
type PDFViewerEmbeddedInnerProps = PDFViewerProps;
export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) {
const scale = window.devicePixelRatio;
const peekView = useService(PeekViewService).peekView;
const pdfService = useService(PDFService);
const [pdfEntity, setPdfEntity] = useState<{
@@ -43,28 +54,25 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) {
page: PDFPage;
release: () => void;
} | null>(null);
const [pageSize, setPageSize] = useState<PageSize | null>(null);
const meta = useLiveData(
useMemo(() => {
return pdfEntity
? pdfEntity.pdf.state$.map(s => {
return s.status === PDFStatus.Opened
? s.meta
: { pageCount: 0, width: 0, height: 0 };
return s.status === PDFStatus.Opened ? s.meta : defaultMeta();
})
: new LiveData({ pageCount: 0, width: 0, height: 0 });
: new LiveData<PDFMeta>(defaultMeta());
}, [pdfEntity])
);
const img = useLiveData(
useMemo(() => {
return pageEntity ? pageEntity.page.bitmap$ : null;
}, [pageEntity])
useMemo(() => (pageEntity ? pageEntity.page.bitmap$ : null), [pageEntity])
);
const [isLoading, setIsLoading] = useState(true);
const [cursor, setCursor] = useState(0);
const viewerRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [visibility, setVisibility] = useState(false);
const viewerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const peek = useCallback(() => {
@@ -107,47 +115,51 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) {
if (!img) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const { width, height } = meta;
if (width * height === 0) return;
setIsLoading(false);
canvas.width = width * 2;
canvas.height = height * 2;
canvas.width = img.width;
canvas.height = img.height;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
}, [img, meta]);
}, [img]);
useEffect(() => {
if (!visibility) return;
if (!pageEntity) return;
if (!pageSize) return;
const { width, height } = meta;
if (width * height === 0) return;
const { width, height } = pageSize;
pageEntity.page.render({ width, height, scale: 2 });
pageEntity.page.render({ width, height, scale });
return () => {
pageEntity.page.render.unsubscribe();
};
}, [visibility, pageEntity, meta]);
}, [visibility, pageEntity, pageSize, scale]);
useEffect(() => {
if (!visibility) return;
if (!pdfEntity) return;
const { width, height } = meta;
if (width * height === 0) return;
const size = meta.pageSizes[cursor];
if (!size) return;
const pageEntity = pdfEntity.pdf.page(cursor, `${width}:${height}:2`);
const { width, height } = size;
const pageEntity = pdfEntity.pdf.page(
cursor,
`${width}:${height}:${scale}`
);
setPageEntity(pageEntity);
setPageSize(size);
return () => {
pageEntity.release();
setPageSize(null);
setPageEntity(null);
};
}, [visibility, pdfEntity, cursor, meta]);
}, [visibility, pdfEntity, cursor, meta, scale]);
useEffect(() => {
if (!visibility) return;
@@ -191,7 +203,7 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) {
justifyContent: 'center',
alignItems: 'center',
width: '100%',
minHeight: '759px',
minHeight: '253px',
}}
>
<PDFPageCanvas ref={canvasRef} />

View File

@@ -25,7 +25,7 @@ import {
} from 'react-virtuoso';
import * as styles from './styles.css';
import { calculatePageNum } from './utils';
import { calculatePageNum, fitToPage } from './utils';
const THUMBNAIL_WIDTH = 94;
@@ -81,17 +81,27 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
(
index: number,
_: unknown,
{ width, height, onPageSelect, pageClassName }: PDFVirtuosoContext
{
viewportInfo,
meta,
onPageSelect,
pageClassName,
resize,
isThumbnail,
}: PDFVirtuosoContext
) => {
return (
<PDFPageRenderer
key={index}
key={`${pageClassName}-${index}`}
pdf={pdf}
width={width}
height={height}
pageNum={index}
onSelect={onPageSelect}
className={pageClassName}
viewportInfo={viewportInfo}
actualSize={meta.pageSizes[index]}
maxSize={meta.maxSize}
onSelect={onPageSelect}
resize={resize}
isThumbnail={isThumbnail}
/>
);
},
@@ -100,22 +110,47 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
const thumbnailsConfig = useMemo(() => {
const { height: vh } = viewportInfo;
const { pageCount: t, height: h, width: w } = state.meta;
const p = h / (w || 1);
const pw = THUMBNAIL_WIDTH;
const ph = Math.ceil(pw * p);
const height = Math.min(vh - 60 - 24 - 24 - 2 - 8, t * ph + (t - 1) * 12);
const { pageCount, pageSizes, maxSize } = state.meta;
const t = Math.min(maxSize.width / maxSize.height, 1);
const pw = THUMBNAIL_WIDTH / t;
const newMaxSize = {
width: pw,
height: pw * (maxSize.height / maxSize.width),
};
const newPageSizes = pageSizes.map(({ width, height }) => {
const w = newMaxSize.width * (width / maxSize.width);
return {
width: w,
height: w * (height / width),
};
});
const height = Math.min(
vh - 60 - 24 - 24 - 2 - 8,
newPageSizes.reduce((h, { height }) => h + height * t, 0) +
(pageCount - 1) * 12
);
return {
context: {
width: pw,
height: ph,
onPageSelect,
viewportInfo: {
width: pw,
height,
},
meta: {
pageCount,
maxSize: newMaxSize,
pageSizes: newPageSizes,
},
resize: fitToPage,
isThumbnail: true,
pageClassName: styles.pdfThumbnail,
},
style: { height },
};
}, [state, viewportInfo, onPageSelect]);
// 1. works fine if they are the same size
// 2. uses the `observeIntersection` when targeting different sizes
const scrollSeekConfig = useMemo<ScrollSeekConfiguration>(() => {
return {
enter: velocity => Math.abs(velocity) > 1024,
@@ -154,8 +189,12 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
ScrollSeekPlaceholder,
}}
context={{
width: state.meta.width,
height: state.meta.height,
viewportInfo: {
width: viewportInfo.width - 40,
height: viewportInfo.height - 40,
},
meta: state.meta,
resize: fitToPage,
pageClassName: styles.pdfPage,
}}
scrollSeekConfiguration={scrollSeekConfig}
@@ -174,9 +213,9 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
Scroller,
ScrollSeekPlaceholder,
}}
scrollSeekConfiguration={scrollSeekConfig}
style={thumbnailsConfig.style}
context={thumbnailsConfig.context}
scrollSeekConfiguration={scrollSeekConfig}
/>
</div>
<div className={clsx(['indicator', styles.pdfIndicator])}>

View File

@@ -17,7 +17,7 @@ function PDFViewerStatus({ pdf, ...props }: PDFViewerProps & { pdf: PDF }) {
}, [state]);
if (state?.status !== PDFStatus.Opened) {
return <LoadingSvg />;
return <PDFLoading />;
}
return <PDFViewerInner {...props} pdf={pdf} state={state} />;
@@ -38,12 +38,20 @@ export function PDFViewer({ model, ...props }: PDFViewerProps) {
const { pdf, release } = pdfService.get(model);
setPdf(pdf);
return release;
return () => {
release();
};
}, [model, pdfService, setPdf]);
if (!pdf) {
return <LoadingSvg />;
return <PDFLoading />;
}
return <PDFViewerStatus {...props} model={model} pdf={pdf} />;
}
const PDFLoading = () => (
<div style={{ margin: 'auto' }}>
<LoadingSvg />
</div>
);

View File

@@ -128,6 +128,9 @@ export const pdfPage = style({
'0px 4px 20px 0px var(--transparent-black-200, rgba(0, 0, 0, 0.10))',
overflow: 'hidden',
maxHeight: 'max-content',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const pdfThumbnails = style({

View File

@@ -11,6 +11,11 @@ export const pdfContainer = style({
background: cssVar('--affine-background-primary-color'),
userSelect: 'none',
contentVisibility: 'visible',
display: 'flex',
minHeight: 'fit-content',
height: '100%',
flexDirection: 'column',
justifyContent: 'space-between',
});
export const pdfViewer = style({
@@ -21,6 +26,7 @@ export const pdfViewer = style({
padding: '12px',
overflow: 'hidden',
background: cssVarV2('layer/background/secondary'),
flex: 1,
});
export const pdfPlaceholder = style({

View File

@@ -1,3 +1,4 @@
import type { PageSize } from '@affine/core/modules/pdf/renderer/types';
import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
import { filesize } from 'filesize';
@@ -46,3 +47,29 @@ export function calculatePageNum(el: HTMLElement, pageCount: number) {
const cursor = Math.min(index, pageCount - 1);
return cursor;
}
export function fitToPage(
viewportInfo: PageSize,
actualSize: PageSize,
maxSize: PageSize,
isThumbnail?: boolean
) {
const { width: vw, height: vh } = viewportInfo;
const { width: w, height: h } = actualSize;
const { width: mw, height: mh } = maxSize;
let width = 0;
let height = 0;
if (h / w > vh / vw) {
height = vh * (h / mh);
width = (w / h) * height;
} else {
const t = isThumbnail ? Math.min(w / h, 1) : w / mw;
width = vw * t;
height = (h / w) * width;
}
return {
width: Math.ceil(width),
height: Math.ceil(height),
aspectRatio: width / height,
};
}

View File

@@ -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);

View File

@@ -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;
};

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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',
});