Skip to content

Commit

Permalink
feat: pdf viewer supports fit to page
Browse files Browse the repository at this point in the history
  • Loading branch information
fundon committed Dec 10, 2024
1 parent bec5e53 commit 481ad7d
Show file tree
Hide file tree
Showing 12 changed files with 347 additions and 114 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<{
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -191,7 +203,7 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) {
justifyContent: 'center',
alignItems: 'center',
width: '100%',
minHeight: '759px',
minHeight: '253px',
}}
>
<PDFPageCanvas ref={canvasRef} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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}
/>
);
},
Expand All @@ -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,
Expand Down Expand Up @@ -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}
Expand All @@ -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])}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Loading } from '@affine/component';
import { type PDF, PDFService, PDFStatus } from '@affine/core/modules/pdf';
import { LoadingSvg } from '@affine/core/modules/pdf/views';
import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect, useState } from 'react';
Expand All @@ -10,7 +10,7 @@ function PDFViewerStatus({ pdf, ...props }: PDFViewerProps & { pdf: PDF }) {
const state = useLiveData(pdf.state$);

if (state?.status !== PDFStatus.Opened) {
return <LoadingSvg />;
return <PDFLoading />;
}

return <PDFViewerInner {...props} pdf={pdf} state={state} />;
Expand All @@ -31,12 +31,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' }}>
<Loading />
</div>
);
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -21,6 +26,7 @@ export const pdfViewer = style({
padding: '12px',
overflow: 'hidden',
background: cssVarV2('layer/background/secondary'),
flex: 1,
});

export const pdfPlaceholder = style({
Expand Down
27 changes: 27 additions & 0 deletions packages/frontend/core/src/components/attachment-viewer/utils.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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,
};
}
5 changes: 3 additions & 2 deletions packages/frontend/core/src/modules/pdf/entities/pdf-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
Loading

0 comments on commit 481ad7d

Please sign in to comment.