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 Nov 16, 2024
1 parent 3a018ce commit e6082d0
Show file tree
Hide file tree
Showing 12 changed files with 279 additions and 110 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 Down Expand Up @@ -43,16 +45,17 @@ 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 };
: { pageCount: 0, pageSizes: [] };
})
: new LiveData({ pageCount: 0, width: 0, height: 0 });
: new LiveData<PDFMeta>({ pageCount: 0, pageSizes: [] });
}, [pdfEntity])
);
const img = useLiveData(
Expand Down Expand Up @@ -107,44 +110,45 @@ 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 });

return () => {
pageEntity.page.render.unsubscribe();
};
}, [visibility, pageEntity, meta]);
}, [visibility, pageEntity, pageSize]);

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 { width, height } = size;
const pageEntity = pdfEntity.pdf.page(cursor, `${width}:${height}:2`);

setPageEntity(pageEntity);
setPageSize(size);

return () => {
pageEntity.release();
setPageSize(null);
setPageEntity(null);
};
}, [visibility, pdfEntity, cursor, meta]);
Expand Down Expand Up @@ -191,7 +195,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 @@ -4,6 +4,7 @@ import type {
PDFRendererState,
PDFStatus,
} from '@affine/core/modules/pdf';
import type { PageSize } from '@affine/core/modules/pdf/renderer/types';
import {
Item,
List,
Expand Down Expand Up @@ -51,6 +52,28 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
[]
);

const fitToPage = useCallback(
(viewportInfo: PageSize, actualSize: PageSize) => {
const { width: vw, height: vh } = viewportInfo;
const { width: w, height: h } = actualSize;
let width = 0;
let height = 0;
if (h / w > vh / vw) {
height = vh;
width = (w / h) * height;
} else {
width = vw;
height = (h / w) * width;
}
return {
width: Math.ceil(width),
height: Math.ceil(height),
aspectRatio: width / height,
};
},
[]
);

const onScroll = useCallback(() => {
const el = pagesScrollerRef.current;
if (!el) return;
Expand Down Expand Up @@ -81,17 +104,24 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
(
index: number,
_: unknown,
{ width, height, onPageSelect, pageClassName }: PDFVirtuosoContext
{
viewportInfo,
meta,
onPageSelect,
resize,
pageClassName,
}: 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]}
onSelect={onPageSelect}
resize={resize}
/>
);
},
Expand All @@ -100,22 +130,28 @@ 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: t, pageSizes } = state.meta;
const height = Math.min(
vh - 60 - 24 - 24 - 2 - 8,
pageSizes.reduce((h, { height }) => h + height, 0) + (t - 1) * 12
);
return {
context: {
width: pw,
height: ph,
onPageSelect,
viewportInfo: {
width: THUMBNAIL_WIDTH,
height,
},
meta: state.meta,
resize: fitToPage,
pageClassName: styles.pdfThumbnail,
},
style: { height },
};
}, [state, viewportInfo, onPageSelect]);
}, [state, viewportInfo, onPageSelect, fitToPage]);

// 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 +190,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 +214,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 @@ -148,9 +148,8 @@ export const useSharingUrl = ({ workspaceId, pageId }: UseSharingUrl) => {
if (sharingUrl) {
copyTextToClipboard(sharingUrl)
.then(success => {
if (success) {
notify.success({ title: t['Copied link to clipboard']() });
}
if (!success) return;
notify.success({ title: t['Copied link to clipboard']() });
})
.catch(err => {
console.error(err);
Expand Down
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
18 changes: 12 additions & 6 deletions packages/frontend/core/src/modules/pdf/renderer/types.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
export type PDFMeta = {
pageCount: number;
export type PageSize = {
width: number;
height: number;
};

export type PDFMeta = {
pageCount: number;
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;
};
28 changes: 20 additions & 8 deletions packages/frontend/core/src/modules/pdf/renderer/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ class PDFRendererBackend extends OpConsumer<ClientOps> {
observer.next(doc);

return () => {
doc.close();
setTimeout(() => {
doc.close();
}, 1000); // Waits for ObjectPool GC
};
});
}),
Expand All @@ -60,15 +62,26 @@ 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;

for (; i < pageCount; i++) {
const page = doc.page(i);
if (!page) {
throw new Error('Document has no pages');
}
const size = page.size();
pageSizes.push({
width: Math.ceil(size.width),
height: Math.ceil(size.height),
});
page.close();
}

return {
pageCount: doc.pageCount(),
width: firstPage.width(),
height: firstPage.height(),
pageCount,
pageSizes,
};
})
);
Expand Down Expand Up @@ -100,7 +113,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;
Expand Down
Loading

0 comments on commit e6082d0

Please sign in to comment.