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 18, 2024
1 parent 3a018ce commit c04ab50
Show file tree
Hide file tree
Showing 13 changed files with 324 additions and 119 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 @@ -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,29 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
[]
);

const fitToPage = useCallback(
(viewportInfo: PageSize, actualSize: PageSize, maxSize: PageSize) => {
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 {
width = vw * (w / mw);
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 +105,25 @@ 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]}
maxSize={meta.maxSize}
onSelect={onPageSelect}
resize={resize}
/>
);
},
Expand All @@ -100,22 +132,34 @@ 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 { pageCount: t, pageSizes, maxSize } = state.meta;
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 height = Math.min(
vh - 60 - 24 - 24 - 2 - 8,
pageSizes.reduce(
(h, { width, height }) =>
h + pw * (width / maxSize.width) * (height / width),
0
) +
(t - 1) * 12
);
return {
context: {
width: pw,
height: ph,
onPageSelect,
viewportInfo: {
width: pw,
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 +198,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 +222,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
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
Loading

0 comments on commit c04ab50

Please sign in to comment.