mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-26 23:02:57 +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:
+35
-23
@@ -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} />
|
||||
|
||||
@@ -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])}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user