Skip to content

Commit

Permalink
feat: pdf embed view component
Browse files Browse the repository at this point in the history
  • Loading branch information
fundon committed Nov 14, 2024
1 parent c712e87 commit e62030d
Show file tree
Hide file tree
Showing 12 changed files with 487 additions and 240 deletions.
28 changes: 13 additions & 15 deletions packages/frontend/core/src/components/attachment-viewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ViewBody, ViewHeader } from '@affine/core/modules/workbench';
import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';

import { AttachmentPreviewErrorBoundary, Error } from './error';
import { PDFViewer } from './pdf-viewer';
import { PDFViewer, type PDFViewerProps } from './pdf-viewer';
import * as styles from './styles.css';
import { Titlebar } from './titlebar';
import { buildAttachmentProps } from './utils';
Expand All @@ -18,13 +18,7 @@ export const AttachmentViewer = ({ model }: AttachmentViewerProps) => {
return (
<div className={styles.viewerContainer}>
<Titlebar {...props} />
{model.type.endsWith('pdf') ? (
<AttachmentPreviewErrorBoundary>
<PDFViewer {...props} />
</AttachmentPreviewErrorBoundary>
) : (
<Error {...props} />
)}
<AttachmentViewerInner {...props} />
</div>
);
};
Expand All @@ -39,14 +33,18 @@ export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => {
<Titlebar {...props} />
</ViewHeader>
<ViewBody>
{model.type.endsWith('pdf') ? (
<AttachmentPreviewErrorBoundary>
<PDFViewer {...props} />
</AttachmentPreviewErrorBoundary>
) : (
<Error {...props} />
)}
<AttachmentViewerInner {...props} />
</ViewBody>
</>
);
};

const AttachmentViewerInner = (props: PDFViewerProps) => {
return props.model.type.endsWith('pdf') ? (
<AttachmentPreviewErrorBoundary>
<PDFViewer {...props} />
</AttachmentPreviewErrorBoundary>
) : (
<Error {...props} />
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { IconButton } from '@affine/component';
import { PDFPageRenderer } from '@affine/core/modules/pdf/views';
import { PeekViewService } from '@affine/core/modules/peek-view';
import { stopPropagation } from '@affine/core/utils';
import {
ArrowDownSmallIcon,
ArrowUpSmallIcon,
AttachmentIcon,
CenterPeekIcon,
} from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import clsx from 'clsx';
import { type MouseEvent, useCallback, useMemo, useRef, useState } from 'react';

import type { PDFViewerProps } from './pdf-viewer';
import type { PDFViewerInnerProps } from './pdf-viewer-inner';
import * as styles from './styles.css';
import * as embeddedStyles from './styles.embedded.css';

type PDFViewerEmbeddedInnerProps = PDFViewerProps & PDFViewerInnerProps;

export function PDFViewerEmbeddedInner({
pdf,
state,
model,
}: PDFViewerEmbeddedInnerProps) {
const peekView = useService(PeekViewService).peekView;

const [cursor, setCursor] = useState(0);
const viewerRef = useRef<HTMLDivElement>(null);

const peek = useCallback(() => {
const target = viewerRef.current?.closest(
`affine-attachment[data-block-id="${model.id}"]`
);
if (!target) return;
peekView.open({ element: target as HTMLElement }).catch(console.error);
}, [viewerRef, peekView, model]);

const navigator = useMemo(() => {
const p = cursor - 1;
const n = cursor + 1;

return {
prev: {
disabled: p < 0,
onClick: (e: MouseEvent) => {
e.stopPropagation();
setCursor(p);
},
},
next: {
disabled: n >= state.meta.pageCount,
onClick: (e: MouseEvent) => {
e.stopPropagation();
setCursor(n);
},
},
peek: {
onClick: (e: MouseEvent) => {
e.stopPropagation();
peek();
},
},
};
}, [cursor, state, peek]);

return (
<div ref={viewerRef} className={embeddedStyles.pdfContainer}>
<main className={embeddedStyles.pdfViewer}>
<PDFPageRenderer
key={cursor}
pageNum={cursor}
pdf={pdf}
width={state.meta.width}
height={state.meta.height}
className={styles.pdfPage}
/>
<div className={embeddedStyles.pdfControls}>
<IconButton
size={16}
icon={<ArrowUpSmallIcon />}
className={embeddedStyles.pdfControlButton}
onDoubleClick={stopPropagation}
{...navigator.prev}
/>
<IconButton
size={16}
icon={<ArrowDownSmallIcon />}
className={embeddedStyles.pdfControlButton}
onDoubleClick={stopPropagation}
{...navigator.next}
/>
<IconButton
size={16}
icon={<CenterPeekIcon />}
className={embeddedStyles.pdfControlButton}
onDoubleClick={stopPropagation}
{...navigator.peek}
/>
</div>
</main>
<footer className={embeddedStyles.pdfFooter}>
<div
className={clsx([embeddedStyles.pdfFooterItem, { truncate: true }])}
>
<AttachmentIcon />
<span className={embeddedStyles.pdfTitle}>{model.name}</span>
</div>
<div
className={clsx([
embeddedStyles.pdfFooterItem,
embeddedStyles.pdfPageCount,
])}
>
<span>{state.meta.pageCount > 0 ? cursor + 1 : '-'}</span>/
<span>{state.meta.pageCount > 0 ? state.meta.pageCount : '-'}</span>
</div>
</footer>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { IconButton, observeResize } from '@affine/component';
import type {
PDF,
PDFRendererState,
PDFStatus,
} from '@affine/core/modules/pdf';
import {
Item,
List,
ListPadding,
ListWithSmallGap,
PDFPageRenderer,
type PDFVirtuosoContext,
type PDFVirtuosoProps,
Scroller,
ScrollSeekPlaceholder,
} from '@affine/core/modules/pdf/views';
import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
type ScrollSeekConfiguration,
Virtuoso,
type VirtuosoHandle,
} from 'react-virtuoso';

import * as styles from './styles.css';
import { calculatePageNum } from './utils';

const THUMBNAIL_WIDTH = 94;

export interface PDFViewerInnerProps {
pdf: PDF;
state: Extract<PDFRendererState, { status: PDFStatus.Opened }>;
}

export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
const [cursor, setCursor] = useState(0);
const [collapsed, setCollapsed] = useState(true);
const [viewportInfo, setViewportInfo] = useState({ width: 0, height: 0 });

const viewerRef = useRef<HTMLDivElement>(null);
const pagesScrollerRef = useRef<HTMLElement | null>(null);
const pagesScrollerHandleRef = useRef<VirtuosoHandle>(null);
const thumbnailsScrollerHandleRef = useRef<VirtuosoHandle>(null);

const updateScrollerRef = useCallback(
(scroller: HTMLElement | Window | null) => {
pagesScrollerRef.current = scroller as HTMLElement;
},
[]
);

const onScroll = useCallback(() => {
const el = pagesScrollerRef.current;
if (!el) return;

const { pageCount } = state.meta;
if (!pageCount) return;

const cursor = calculatePageNum(el, pageCount);

setCursor(cursor);
}, [pagesScrollerRef, state]);

const onPageSelect = useCallback(
(index: number) => {
const scroller = pagesScrollerHandleRef.current;
if (!scroller) return;

scroller.scrollToIndex({
index,
align: 'center',
behavior: 'smooth',
});
},
[pagesScrollerHandleRef]
);

const pageContent = useCallback(
(
index: number,
_: unknown,
{ width, height, onPageSelect, pageClassName }: PDFVirtuosoContext
) => {
return (
<PDFPageRenderer
key={index}
pdf={pdf}
width={width}
height={height}
pageNum={index}
onSelect={onPageSelect}
className={pageClassName}
/>
);
},
[pdf]
);

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);
return {
context: {
width: pw,
height: ph,
onPageSelect,
pageClassName: styles.pdfThumbnail,
},
style: { height },
};
}, [state, viewportInfo, onPageSelect]);

const scrollSeekConfig = useMemo<ScrollSeekConfiguration>(() => {
return {
enter: velocity => Math.abs(velocity) > 1024,
exit: velocity => Math.abs(velocity) < 10,
};
}, []);

useEffect(() => {
const viewer = viewerRef.current;
if (!viewer) return;
return observeResize(viewer, ({ contentRect: { width, height } }) =>
setViewportInfo({ width, height })
);
}, []);

return (
<div
ref={viewerRef}
data-testid="pdf-viewer"
className={clsx([styles.viewer, { gridding: true, scrollable: true }])}
>
<Virtuoso<PDFVirtuosoProps>
key={pdf.id}
ref={pagesScrollerHandleRef}
scrollerRef={updateScrollerRef}
onScroll={onScroll}
className={styles.virtuoso}
totalCount={state.meta.pageCount}
itemContent={pageContent}
components={{
Item,
List,
Scroller,
Header: ListPadding,
Footer: ListPadding,
ScrollSeekPlaceholder,
}}
context={{
width: state.meta.width,
height: state.meta.height,
pageClassName: styles.pdfPage,
}}
scrollSeekConfiguration={scrollSeekConfig}
/>
<div className={clsx(['thumbnails', styles.pdfThumbnails])}>
<div className={clsx([styles.pdfThumbnailsList, { collapsed }])}>
<Virtuoso<PDFVirtuosoProps>
key={`${pdf.id}-thumbnail`}
ref={thumbnailsScrollerHandleRef}
className={styles.virtuoso}
totalCount={state.meta.pageCount}
itemContent={pageContent}
components={{
Item,
List: ListWithSmallGap,
Scroller,
ScrollSeekPlaceholder,
}}
scrollSeekConfiguration={scrollSeekConfig}
style={thumbnailsConfig.style}
context={thumbnailsConfig.context}
/>
</div>
<div className={clsx(['indicator', styles.pdfIndicator])}>
<div>
<span className="page-cursor">
{state.meta.pageCount > 0 ? cursor + 1 : 0}
</span>
/<span className="page-count">{state.meta.pageCount}</span>
</div>
<IconButton
icon={collapsed ? <CollapseIcon /> : <ExpandIcon />}
onClick={() => setCollapsed(!collapsed)}
/>
</div>
</div>
</div>
);
};
Loading

0 comments on commit e62030d

Please sign in to comment.