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:
@@ -5,6 +5,8 @@ import {
|
|||||||
PDFService,
|
PDFService,
|
||||||
PDFStatus,
|
PDFStatus,
|
||||||
} from '@affine/core/modules/pdf';
|
} 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 { LoadingSvg, PDFPageCanvas } from '@affine/core/modules/pdf/views';
|
||||||
import { PeekViewService } from '@affine/core/modules/peek-view';
|
import { PeekViewService } from '@affine/core/modules/peek-view';
|
||||||
import { stopPropagation } from '@affine/core/utils';
|
import { stopPropagation } from '@affine/core/utils';
|
||||||
@@ -30,9 +32,18 @@ import type { PDFViewerProps } from './pdf-viewer';
|
|||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
import * as embeddedStyles from './styles.embedded.css';
|
import * as embeddedStyles from './styles.embedded.css';
|
||||||
|
|
||||||
|
function defaultMeta() {
|
||||||
|
return {
|
||||||
|
pageCount: 0,
|
||||||
|
pageSizes: [],
|
||||||
|
maxSize: { width: 0, height: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type PDFViewerEmbeddedInnerProps = PDFViewerProps;
|
type PDFViewerEmbeddedInnerProps = PDFViewerProps;
|
||||||
|
|
||||||
export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) {
|
export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) {
|
||||||
|
const scale = window.devicePixelRatio;
|
||||||
const peekView = useService(PeekViewService).peekView;
|
const peekView = useService(PeekViewService).peekView;
|
||||||
const pdfService = useService(PDFService);
|
const pdfService = useService(PDFService);
|
||||||
const [pdfEntity, setPdfEntity] = useState<{
|
const [pdfEntity, setPdfEntity] = useState<{
|
||||||
@@ -43,28 +54,25 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) {
|
|||||||
page: PDFPage;
|
page: PDFPage;
|
||||||
release: () => void;
|
release: () => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [pageSize, setPageSize] = useState<PageSize | null>(null);
|
||||||
|
|
||||||
const meta = useLiveData(
|
const meta = useLiveData(
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
return pdfEntity
|
return pdfEntity
|
||||||
? pdfEntity.pdf.state$.map(s => {
|
? pdfEntity.pdf.state$.map(s => {
|
||||||
return s.status === PDFStatus.Opened
|
return s.status === PDFStatus.Opened ? s.meta : defaultMeta();
|
||||||
? s.meta
|
|
||||||
: { pageCount: 0, width: 0, height: 0 };
|
|
||||||
})
|
})
|
||||||
: new LiveData({ pageCount: 0, width: 0, height: 0 });
|
: new LiveData<PDFMeta>(defaultMeta());
|
||||||
}, [pdfEntity])
|
}, [pdfEntity])
|
||||||
);
|
);
|
||||||
const img = useLiveData(
|
const img = useLiveData(
|
||||||
useMemo(() => {
|
useMemo(() => (pageEntity ? pageEntity.page.bitmap$ : null), [pageEntity])
|
||||||
return pageEntity ? pageEntity.page.bitmap$ : null;
|
|
||||||
}, [pageEntity])
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [cursor, setCursor] = useState(0);
|
const [cursor, setCursor] = useState(0);
|
||||||
const viewerRef = useRef<HTMLDivElement>(null);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [visibility, setVisibility] = useState(false);
|
const [visibility, setVisibility] = useState(false);
|
||||||
|
const viewerRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
const peek = useCallback(() => {
|
const peek = useCallback(() => {
|
||||||
@@ -107,47 +115,51 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) {
|
|||||||
if (!img) return;
|
if (!img) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const { width, height } = meta;
|
|
||||||
if (width * height === 0) return;
|
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
canvas.width = width * 2;
|
canvas.width = img.width;
|
||||||
canvas.height = height * 2;
|
canvas.height = img.height;
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0);
|
||||||
}, [img, meta]);
|
}, [img]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visibility) return;
|
if (!visibility) return;
|
||||||
if (!pageEntity) return;
|
if (!pageEntity) return;
|
||||||
|
if (!pageSize) return;
|
||||||
|
|
||||||
const { width, height } = meta;
|
const { width, height } = pageSize;
|
||||||
if (width * height === 0) return;
|
|
||||||
|
|
||||||
pageEntity.page.render({ width, height, scale: 2 });
|
pageEntity.page.render({ width, height, scale });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
pageEntity.page.render.unsubscribe();
|
pageEntity.page.render.unsubscribe();
|
||||||
};
|
};
|
||||||
}, [visibility, pageEntity, meta]);
|
}, [visibility, pageEntity, pageSize, scale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visibility) return;
|
if (!visibility) return;
|
||||||
if (!pdfEntity) return;
|
if (!pdfEntity) return;
|
||||||
|
|
||||||
const { width, height } = meta;
|
const size = meta.pageSizes[cursor];
|
||||||
if (width * height === 0) return;
|
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);
|
setPageEntity(pageEntity);
|
||||||
|
setPageSize(size);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
pageEntity.release();
|
pageEntity.release();
|
||||||
|
setPageSize(null);
|
||||||
setPageEntity(null);
|
setPageEntity(null);
|
||||||
};
|
};
|
||||||
}, [visibility, pdfEntity, cursor, meta]);
|
}, [visibility, pdfEntity, cursor, meta, scale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visibility) return;
|
if (!visibility) return;
|
||||||
@@ -191,7 +203,7 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
minHeight: '759px',
|
minHeight: '253px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PDFPageCanvas ref={canvasRef} />
|
<PDFPageCanvas ref={canvasRef} />
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
} from 'react-virtuoso';
|
} from 'react-virtuoso';
|
||||||
|
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
import { calculatePageNum } from './utils';
|
import { calculatePageNum, fitToPage } from './utils';
|
||||||
|
|
||||||
const THUMBNAIL_WIDTH = 94;
|
const THUMBNAIL_WIDTH = 94;
|
||||||
|
|
||||||
@@ -81,17 +81,27 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
|||||||
(
|
(
|
||||||
index: number,
|
index: number,
|
||||||
_: unknown,
|
_: unknown,
|
||||||
{ width, height, onPageSelect, pageClassName }: PDFVirtuosoContext
|
{
|
||||||
|
viewportInfo,
|
||||||
|
meta,
|
||||||
|
onPageSelect,
|
||||||
|
pageClassName,
|
||||||
|
resize,
|
||||||
|
isThumbnail,
|
||||||
|
}: PDFVirtuosoContext
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
<PDFPageRenderer
|
<PDFPageRenderer
|
||||||
key={index}
|
key={`${pageClassName}-${index}`}
|
||||||
pdf={pdf}
|
pdf={pdf}
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
pageNum={index}
|
pageNum={index}
|
||||||
onSelect={onPageSelect}
|
|
||||||
className={pageClassName}
|
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 thumbnailsConfig = useMemo(() => {
|
||||||
const { height: vh } = viewportInfo;
|
const { height: vh } = viewportInfo;
|
||||||
const { pageCount: t, height: h, width: w } = state.meta;
|
const { pageCount, pageSizes, maxSize } = state.meta;
|
||||||
const p = h / (w || 1);
|
const t = Math.min(maxSize.width / maxSize.height, 1);
|
||||||
const pw = THUMBNAIL_WIDTH;
|
const pw = THUMBNAIL_WIDTH / t;
|
||||||
const ph = Math.ceil(pw * p);
|
const newMaxSize = {
|
||||||
const height = Math.min(vh - 60 - 24 - 24 - 2 - 8, t * ph + (t - 1) * 12);
|
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 {
|
return {
|
||||||
context: {
|
context: {
|
||||||
width: pw,
|
|
||||||
height: ph,
|
|
||||||
onPageSelect,
|
onPageSelect,
|
||||||
|
viewportInfo: {
|
||||||
|
width: pw,
|
||||||
|
height,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
pageCount,
|
||||||
|
maxSize: newMaxSize,
|
||||||
|
pageSizes: newPageSizes,
|
||||||
|
},
|
||||||
|
resize: fitToPage,
|
||||||
|
isThumbnail: true,
|
||||||
pageClassName: styles.pdfThumbnail,
|
pageClassName: styles.pdfThumbnail,
|
||||||
},
|
},
|
||||||
style: { height },
|
style: { height },
|
||||||
};
|
};
|
||||||
}, [state, viewportInfo, onPageSelect]);
|
}, [state, viewportInfo, onPageSelect]);
|
||||||
|
|
||||||
|
// 1. works fine if they are the same size
|
||||||
|
// 2. uses the `observeIntersection` when targeting different sizes
|
||||||
const scrollSeekConfig = useMemo<ScrollSeekConfiguration>(() => {
|
const scrollSeekConfig = useMemo<ScrollSeekConfiguration>(() => {
|
||||||
return {
|
return {
|
||||||
enter: velocity => Math.abs(velocity) > 1024,
|
enter: velocity => Math.abs(velocity) > 1024,
|
||||||
@@ -154,8 +189,12 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
|||||||
ScrollSeekPlaceholder,
|
ScrollSeekPlaceholder,
|
||||||
}}
|
}}
|
||||||
context={{
|
context={{
|
||||||
width: state.meta.width,
|
viewportInfo: {
|
||||||
height: state.meta.height,
|
width: viewportInfo.width - 40,
|
||||||
|
height: viewportInfo.height - 40,
|
||||||
|
},
|
||||||
|
meta: state.meta,
|
||||||
|
resize: fitToPage,
|
||||||
pageClassName: styles.pdfPage,
|
pageClassName: styles.pdfPage,
|
||||||
}}
|
}}
|
||||||
scrollSeekConfiguration={scrollSeekConfig}
|
scrollSeekConfiguration={scrollSeekConfig}
|
||||||
@@ -174,9 +213,9 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
|||||||
Scroller,
|
Scroller,
|
||||||
ScrollSeekPlaceholder,
|
ScrollSeekPlaceholder,
|
||||||
}}
|
}}
|
||||||
scrollSeekConfiguration={scrollSeekConfig}
|
|
||||||
style={thumbnailsConfig.style}
|
style={thumbnailsConfig.style}
|
||||||
context={thumbnailsConfig.context}
|
context={thumbnailsConfig.context}
|
||||||
|
scrollSeekConfiguration={scrollSeekConfig}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx(['indicator', styles.pdfIndicator])}>
|
<div className={clsx(['indicator', styles.pdfIndicator])}>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function PDFViewerStatus({ pdf, ...props }: PDFViewerProps & { pdf: PDF }) {
|
|||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
if (state?.status !== PDFStatus.Opened) {
|
if (state?.status !== PDFStatus.Opened) {
|
||||||
return <LoadingSvg />;
|
return <PDFLoading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PDFViewerInner {...props} pdf={pdf} state={state} />;
|
return <PDFViewerInner {...props} pdf={pdf} state={state} />;
|
||||||
@@ -38,12 +38,20 @@ export function PDFViewer({ model, ...props }: PDFViewerProps) {
|
|||||||
const { pdf, release } = pdfService.get(model);
|
const { pdf, release } = pdfService.get(model);
|
||||||
setPdf(pdf);
|
setPdf(pdf);
|
||||||
|
|
||||||
return release;
|
return () => {
|
||||||
|
release();
|
||||||
|
};
|
||||||
}, [model, pdfService, setPdf]);
|
}, [model, pdfService, setPdf]);
|
||||||
|
|
||||||
if (!pdf) {
|
if (!pdf) {
|
||||||
return <LoadingSvg />;
|
return <PDFLoading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PDFViewerStatus {...props} model={model} pdf={pdf} />;
|
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))',
|
'0px 4px 20px 0px var(--transparent-black-200, rgba(0, 0, 0, 0.10))',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
maxHeight: 'max-content',
|
maxHeight: 'max-content',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pdfThumbnails = style({
|
export const pdfThumbnails = style({
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export const pdfContainer = style({
|
|||||||
background: cssVar('--affine-background-primary-color'),
|
background: cssVar('--affine-background-primary-color'),
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
contentVisibility: 'visible',
|
contentVisibility: 'visible',
|
||||||
|
display: 'flex',
|
||||||
|
minHeight: 'fit-content',
|
||||||
|
height: '100%',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pdfViewer = style({
|
export const pdfViewer = style({
|
||||||
@@ -21,6 +26,7 @@ export const pdfViewer = style({
|
|||||||
padding: '12px',
|
padding: '12px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
background: cssVarV2('layer/background/secondary'),
|
background: cssVarV2('layer/background/secondary'),
|
||||||
|
flex: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pdfPlaceholder = style({
|
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 type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
|
||||||
import { filesize } from 'filesize';
|
import { filesize } from 'filesize';
|
||||||
|
|
||||||
@@ -46,3 +47,29 @@ export function calculatePageNum(el: HTMLElement, pageCount: number) {
|
|||||||
const cursor = Math.min(index, pageCount - 1);
|
const cursor = Math.min(index, pageCount - 1);
|
||||||
return cursor;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
LiveData,
|
LiveData,
|
||||||
mapInto,
|
mapInto,
|
||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
import { map, switchMap } from 'rxjs';
|
import { filter, map, switchMap } from 'rxjs';
|
||||||
|
|
||||||
import type { RenderPageOpts } from '../renderer';
|
import type { RenderPageOpts } from '../renderer';
|
||||||
import type { PDF } from './pdf';
|
import type { PDF } from './pdf';
|
||||||
@@ -25,7 +25,8 @@ export class PDFPage extends Entity<{ pdf: PDF; pageNum: number }> {
|
|||||||
pageNum: this.pageNum,
|
pageNum: this.pageNum,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
map(data => data.bitmap),
|
map(data => data?.bitmap),
|
||||||
|
filter(Boolean),
|
||||||
mapInto(this.bitmap$),
|
mapInto(this.bitmap$),
|
||||||
catchErrorInto(this.error$, error => {
|
catchErrorInto(this.error$, error => {
|
||||||
logger.error('Failed to render page', error);
|
logger.error('Failed to render page', error);
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
export type PDFMeta = {
|
export type PageSize = {
|
||||||
pageCount: number;
|
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PDFMeta = {
|
||||||
|
pageCount: number;
|
||||||
|
maxSize: PageSize;
|
||||||
|
pageSizes: PageSize[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageSizeOpts = {
|
||||||
|
pageNum: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type RenderPageOpts = {
|
export type RenderPageOpts = {
|
||||||
pageNum: number;
|
pageNum: number;
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
scale?: number;
|
scale?: number;
|
||||||
};
|
} & PageSize;
|
||||||
|
|
||||||
export type RenderedPage = RenderPageOpts & {
|
export type RenderedPage = {
|
||||||
bitmap: ImageBitmap;
|
bitmap: ImageBitmap;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
map,
|
map,
|
||||||
Observable,
|
Observable,
|
||||||
ReplaySubject,
|
ReplaySubject,
|
||||||
|
retry,
|
||||||
share,
|
share,
|
||||||
switchMap,
|
switchMap,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
@@ -32,7 +33,7 @@ class PDFRendererBackend extends OpConsumer<ClientOps> {
|
|||||||
|
|
||||||
private readonly doc$ = this.binary$.pipe(
|
private readonly doc$ = this.binary$.pipe(
|
||||||
filter(Boolean),
|
filter(Boolean),
|
||||||
combineLatestWith(this.viewer$),
|
combineLatestWith(this.viewer$.pipe(retry(1))),
|
||||||
switchMap(([buffer, viewer]) => {
|
switchMap(([buffer, viewer]) => {
|
||||||
return new Observable<Document | undefined>(observer => {
|
return new Observable<Document | undefined>(observer => {
|
||||||
const doc = viewer.open(buffer);
|
const doc = viewer.open(buffer);
|
||||||
@@ -45,7 +46,9 @@ class PDFRendererBackend extends OpConsumer<ClientOps> {
|
|||||||
observer.next(doc);
|
observer.next(doc);
|
||||||
|
|
||||||
return () => {
|
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');
|
throw new Error('Document not opened');
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstPage = doc.page(0);
|
const pageCount = doc.pageCount();
|
||||||
if (!firstPage) {
|
const pageSizes = [];
|
||||||
throw new Error('Document has no pages');
|
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 {
|
return {
|
||||||
pageCount: doc.pageCount(),
|
pageCount,
|
||||||
width: firstPage.width(),
|
pageSizes,
|
||||||
height: firstPage.height(),
|
maxSize: { width: maxWidth, height: maxHeight },
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -100,7 +120,6 @@ class PDFRendererBackend extends OpConsumer<ClientOps> {
|
|||||||
|
|
||||||
async renderPage(viewer: Viewer, doc: Document, opts: RenderPageOpts) {
|
async renderPage(viewer: Viewer, doc: Document, opts: RenderPageOpts) {
|
||||||
const page = doc.page(opts.pageNum);
|
const page = doc.page(opts.pageNum);
|
||||||
|
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
|
|
||||||
const scale = opts.scale ?? 1;
|
const scale = opts.scale ?? 1;
|
||||||
|
|||||||
@@ -3,11 +3,20 @@ import clsx from 'clsx';
|
|||||||
import { type CSSProperties, forwardRef, memo } from 'react';
|
import { type CSSProperties, forwardRef, memo } from 'react';
|
||||||
import type { ScrollSeekPlaceholderProps, VirtuosoProps } from 'react-virtuoso';
|
import type { ScrollSeekPlaceholderProps, VirtuosoProps } from 'react-virtuoso';
|
||||||
|
|
||||||
|
import type { PDFMeta } from '../renderer';
|
||||||
|
import type { PageSize } from '../renderer/types';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
export type PDFVirtuosoContext = {
|
export type PDFVirtuosoContext = {
|
||||||
width: number;
|
viewportInfo: PageSize;
|
||||||
height: number;
|
meta: PDFMeta;
|
||||||
|
resize: (
|
||||||
|
viewportInfo: PageSize,
|
||||||
|
actualSize: PageSize,
|
||||||
|
maxSize: PageSize,
|
||||||
|
isThumbnail?: boolean
|
||||||
|
) => { aspectRatio: number } & PageSize;
|
||||||
|
isThumbnail?: boolean;
|
||||||
pageClassName?: string;
|
pageClassName?: string;
|
||||||
onPageSelect?: (index: number) => void;
|
onPageSelect?: (index: number) => void;
|
||||||
};
|
};
|
||||||
@@ -32,16 +41,29 @@ export const ScrollSeekPlaceholder = forwardRef<
|
|||||||
ScrollSeekPlaceholderProps & {
|
ScrollSeekPlaceholderProps & {
|
||||||
context?: PDFVirtuosoContext;
|
context?: PDFVirtuosoContext;
|
||||||
}
|
}
|
||||||
>(({ context }, ref) => {
|
>(({ context, index }, ref) => {
|
||||||
const className = context?.pageClassName;
|
const className = context?.pageClassName;
|
||||||
const width = context?.width ?? 537;
|
const isThumbnail = context?.isThumbnail;
|
||||||
const height = context?.height ?? 759;
|
const size = context?.meta.pageSizes[index];
|
||||||
const style = { width, aspectRatio: `${width} / ${height}` };
|
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 (
|
return (
|
||||||
<div className={className} style={style} ref={ref}>
|
<Item
|
||||||
<LoadingSvg />
|
data-index={index}
|
||||||
</div>
|
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';
|
LoadingSvg.displayName = 'pdf-loading';
|
||||||
|
|
||||||
export const PDFPageCanvas = forwardRef<HTMLCanvasElement>((props, ref) => {
|
export const PDFPageCanvas = forwardRef<
|
||||||
return <canvas className={styles.pdfPageCanvas} ref={ref} {...props} />;
|
HTMLCanvasElement,
|
||||||
|
{ style?: CSSProperties }
|
||||||
|
>(({ style, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
className={styles.pdfPageCanvas}
|
||||||
|
ref={ref}
|
||||||
|
style={style}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
PDFPageCanvas.displayName = 'pdf-page-canvas';
|
PDFPageCanvas.displayName = 'pdf-page-canvas';
|
||||||
|
|||||||
@@ -1,54 +1,56 @@
|
|||||||
|
import { observeIntersection } from '@affine/component';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { useLiveData } from '@toeverything/infra';
|
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 { PDF } from '../entities/pdf';
|
||||||
import type { PDFPage } from '../entities/pdf-page';
|
import type { PDFPage } from '../entities/pdf-page';
|
||||||
|
import type { PageSize } from '../renderer/types';
|
||||||
import { LoadingSvg, PDFPageCanvas } from './components';
|
import { LoadingSvg, PDFPageCanvas } from './components';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
interface PDFPageProps {
|
interface PDFPageProps {
|
||||||
pdf: PDF;
|
pdf: PDF;
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
pageNum: number;
|
pageNum: number;
|
||||||
|
actualSize: PageSize;
|
||||||
|
maxSize: PageSize;
|
||||||
|
viewportInfo: PageSize;
|
||||||
|
resize: (
|
||||||
|
viewportInfo: PageSize,
|
||||||
|
actualSize: PageSize,
|
||||||
|
maxSize: PageSize,
|
||||||
|
isThumbnail?: boolean
|
||||||
|
) => { aspectRatio: number } & PageSize;
|
||||||
scale?: number;
|
scale?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
onSelect?: (pageNum: number) => void;
|
onSelect?: (pageNum: number) => void;
|
||||||
|
isThumbnail?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PDFPageRenderer = ({
|
export const PDFPageRenderer = ({
|
||||||
pdf,
|
pdf,
|
||||||
width,
|
|
||||||
height,
|
|
||||||
pageNum,
|
pageNum,
|
||||||
className,
|
className,
|
||||||
|
actualSize,
|
||||||
|
maxSize,
|
||||||
|
viewportInfo,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
resize,
|
||||||
|
isThumbnail,
|
||||||
scale = window.devicePixelRatio,
|
scale = window.devicePixelRatio,
|
||||||
}: PDFPageProps) => {
|
}: PDFPageProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const [pdfPage, setPdfPage] = useState<PDFPage | null>(null);
|
const pageViewRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const img = useLiveData(pdfPage?.bitmap$ ?? null);
|
const [page, setPage] = useState<PDFPage | null>(null);
|
||||||
const error = useLiveData(pdfPage?.error$ ?? null);
|
const img = useLiveData(useMemo(() => (page ? page.bitmap$ : null), [page]));
|
||||||
const style = { width, aspectRatio: `${width} / ${height}` };
|
const error = useLiveData(page?.error$ ?? null);
|
||||||
|
const size = useMemo(
|
||||||
useEffect(() => {
|
() => resize(viewportInfo, actualSize, maxSize, isThumbnail),
|
||||||
if (width * height === 0) return;
|
[resize, viewportInfo, actualSize, maxSize, isThumbnail]
|
||||||
|
);
|
||||||
const { page, release } = pdf.page(pageNum, `${width}:${height}:${scale}`);
|
const [visibility, setVisibility] = useState(false);
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -56,30 +58,107 @@ export const PDFPageRenderer = ({
|
|||||||
if (!img) return;
|
if (!img) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
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;
|
if (width * height === 0) return;
|
||||||
|
|
||||||
canvas.width = width * scale;
|
page.render({
|
||||||
canvas.height = height * scale;
|
width,
|
||||||
ctx.drawImage(img, 0, 0);
|
height,
|
||||||
}, [img, width, height, scale]);
|
scale,
|
||||||
|
});
|
||||||
|
|
||||||
if (error) {
|
return () => {
|
||||||
return (
|
page.render.unsubscribe();
|
||||||
<div className={className} style={style}>
|
};
|
||||||
<p className={styles.pdfPageError}>
|
}, [visibility, page, size, scale]);
|
||||||
{t['com.affine.pdf.page.render.error']()}
|
|
||||||
</p>
|
useEffect(() => {
|
||||||
</div>
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={pageViewRef}
|
||||||
className={className}
|
className={className}
|
||||||
style={style}
|
style={resize?.(viewportInfo, actualSize, maxSize, isThumbnail)}
|
||||||
onClick={() => onSelect?.(pageNum)}
|
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>
|
</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',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
minHeight: 'calc(100% - 40px)',
|
||||||
gap: '20px',
|
gap: '20px',
|
||||||
selectors: {
|
selectors: {
|
||||||
'&.small-gap': {
|
'&.small-gap': {
|
||||||
@@ -40,15 +41,14 @@ export const pdfPageError = style({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const pdfPageCanvas = style({
|
export const pdfPageCanvas = style({
|
||||||
width: '100%',
|
maxWidth: '100%',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pdfLoading = style({
|
export const pdfLoading = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
margin: 'auto',
|
width: '179.66px',
|
||||||
width: '100%',
|
height: '253px',
|
||||||
height: '100%',
|
aspectRatio: '539 / 759',
|
||||||
maxWidth: '537px',
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user