mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-25 14:28:51 +08:00
feat(core): add status to pdf viewer (#12349)
Closes: [BS-3439](https://linear.app/affine-design/issue/BS-3439/pdf-独立页面split-view-中的-status-组件) Related to: [BS-3143](https://linear.app/affine-design/issue/BS-3143/更新-loading-和错误样式) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added a dedicated error handling and reload interface for PDF attachments, allowing users to retry loading PDFs when errors occur. - **Refactor** - Improved PDF viewer interface with clearer loading and error states. - Enhanced attachment type detection for better performance and maintainability. - Streamlined attachment preview logic for more direct and efficient model retrieval. - Simplified internal PDF metadata handling and control flow for improved clarity. - Clarified conditional rendering logic in attachment viewer components. - Introduced explicit loading state management and refined rendering logic in attachment pages. - **Style** - Updated and added styles for PDF viewer controls and error status display. - **Tests** - Added end-to-end tests validating PDF preview error handling and attachment not-found scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
+1
-1
@@ -6,7 +6,7 @@ import { PDFViewerEmbedded } from './pdf/pdf-viewer-embedded';
|
||||
import type { AttachmentViewerProps } from './types';
|
||||
import { getAttachmentType } from './utils';
|
||||
|
||||
// In Embed view
|
||||
// Embed view
|
||||
export const AttachmentEmbedPreview = ({ model }: AttachmentViewerProps) => {
|
||||
const attachmentType = getAttachmentType(model);
|
||||
const element = useMemo(() => {
|
||||
|
||||
@@ -36,11 +36,15 @@ export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => {
|
||||
};
|
||||
|
||||
const AttachmentViewerInner = (props: AttachmentViewerBaseProps) => {
|
||||
return props.model.props.type.endsWith('pdf') ? (
|
||||
<AttachmentPreviewErrorBoundary>
|
||||
<PDFViewer {...props} />
|
||||
</AttachmentPreviewErrorBoundary>
|
||||
) : (
|
||||
<AttachmentFallback {...props} />
|
||||
);
|
||||
const isPDF = props.model.props.type.endsWith('pdf');
|
||||
|
||||
if (isPDF) {
|
||||
return (
|
||||
<AttachmentPreviewErrorBoundary>
|
||||
<PDFViewer {...props} />
|
||||
</AttachmentPreviewErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return <AttachmentFallback {...props} />;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IconButton, observeResize } from '@affine/component';
|
||||
import type { PDF, PDFRendererState } from '@affine/core/modules/pdf';
|
||||
import { IconButton, Menu, observeResize } from '@affine/component';
|
||||
import type { PDF, PDFMeta, PDFRendererState } from '@affine/core/modules/pdf';
|
||||
import { PDFService, PDFStatus } from '@affine/core/modules/pdf';
|
||||
import {
|
||||
Item,
|
||||
@@ -14,10 +14,23 @@ import {
|
||||
ScrollSeekPlaceholder,
|
||||
} from '@affine/core/modules/pdf/views';
|
||||
import track from '@affine/track';
|
||||
import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import {
|
||||
CollapseIcon,
|
||||
ExpandIcon,
|
||||
InformationIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { LiveData, useLiveData, useService } from '@toeverything/infra';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
type ScrollSeekConfiguration,
|
||||
Virtuoso,
|
||||
@@ -42,10 +55,10 @@ function calculatePageNum(el: HTMLElement, pageCount: number) {
|
||||
|
||||
export interface PDFViewerInnerProps {
|
||||
pdf: PDF;
|
||||
state: Extract<PDFRendererState, { status: PDFStatus.Opened }>;
|
||||
meta: PDFMeta;
|
||||
}
|
||||
|
||||
export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
||||
export const PDFViewerInner = ({ pdf, meta }: PDFViewerInnerProps) => {
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [viewportInfo, setViewportInfo] = useState({ width: 0, height: 0 });
|
||||
@@ -66,13 +79,13 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
||||
const el = pagesScrollerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const { pageCount } = state.meta;
|
||||
const { pageCount } = meta;
|
||||
if (!pageCount) return;
|
||||
|
||||
const cursor = calculatePageNum(el, pageCount);
|
||||
|
||||
setCursor(cursor);
|
||||
}, [pagesScrollerRef, state]);
|
||||
}, [pagesScrollerRef, meta]);
|
||||
|
||||
const onPageSelect = useCallback(
|
||||
(index: number) => {
|
||||
@@ -121,7 +134,7 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
||||
|
||||
const thumbnailsConfig = useMemo(() => {
|
||||
const { height: vh } = viewportInfo;
|
||||
const { pageCount, pageSizes, maxSize } = state.meta;
|
||||
const { pageCount, pageSizes, maxSize } = meta;
|
||||
const t = Math.min(maxSize.width / maxSize.height, 1);
|
||||
const pw = THUMBNAIL_WIDTH / t;
|
||||
const newMaxSize = {
|
||||
@@ -158,7 +171,7 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
||||
},
|
||||
style: { height },
|
||||
};
|
||||
}, [state, viewportInfo, onPageSelect]);
|
||||
}, [meta, viewportInfo, onPageSelect]);
|
||||
|
||||
// 1. works fine if they are the same size
|
||||
// 2. uses the `observeIntersection` when targeting different sizes
|
||||
@@ -189,7 +202,7 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
||||
scrollerRef={updateScrollerRef}
|
||||
onScroll={onScroll}
|
||||
className={styles.virtuoso}
|
||||
totalCount={state.meta.pageCount}
|
||||
totalCount={meta.pageCount}
|
||||
itemContent={pageContent}
|
||||
components={{
|
||||
Item,
|
||||
@@ -204,7 +217,7 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
||||
width: viewportInfo.width - 40,
|
||||
height: viewportInfo.height - 40,
|
||||
},
|
||||
meta: state.meta,
|
||||
meta,
|
||||
resize: fitToPage,
|
||||
pageClassName: styles.pdfPage,
|
||||
}}
|
||||
@@ -216,7 +229,7 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
||||
key={`${pdf.id}-thumbnail`}
|
||||
ref={thumbnailsScrollerHandleRef}
|
||||
className={styles.virtuoso}
|
||||
totalCount={state.meta.pageCount}
|
||||
totalCount={meta.pageCount}
|
||||
itemContent={pageContent}
|
||||
components={{
|
||||
Item,
|
||||
@@ -232,9 +245,9 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
||||
<div className={clsx(['indicator', styles.pdfIndicator])}>
|
||||
<div>
|
||||
<span className="page-cursor">
|
||||
{state.meta.pageCount > 0 ? cursor + 1 : 0}
|
||||
{meta.pageCount > 0 ? cursor + 1 : 0}
|
||||
</span>
|
||||
/<span className="page-count">{state.meta.pageCount}</span>
|
||||
/<span className="page-count">{meta.pageCount}</span>
|
||||
</div>
|
||||
<IconButton
|
||||
icon={collapsed ? <CollapseIcon /> : <ExpandIcon />}
|
||||
@@ -246,11 +259,76 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
function PDFViewerStatus({
|
||||
pdf,
|
||||
type PDFViewerStatusProps = {
|
||||
message: string;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
function PDFViewerStatusMenuItems({ message, reload }: PDFViewerStatusProps) {
|
||||
const onClick = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
reload();
|
||||
},
|
||||
[reload]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.pdfStatusMenu}>
|
||||
<div>{message}</div>
|
||||
<div className={styles.pdfStatusMenuFooter}>
|
||||
<button
|
||||
data-testid="pdf-viewer-reload"
|
||||
className={styles.pdfReloadButton}
|
||||
onClick={onClick}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PDFViewerStatus(props: PDFViewerStatusProps) {
|
||||
return (
|
||||
<div className={styles.pdfStatus} data-testid="pdf-viewer-status-wrapper">
|
||||
<Menu
|
||||
items={<PDFViewerStatusMenuItems {...props} />}
|
||||
contentWrapperStyle={{
|
||||
padding: '8px',
|
||||
boxShadow: cssVar('overlayShadow'),
|
||||
}}
|
||||
contentOptions={{
|
||||
sideOffset: 8,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
data-testid="pdf-viewer-status"
|
||||
className={styles.pdfStatusButton}
|
||||
>
|
||||
<InformationIcon />
|
||||
</button>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PDFViewerContainer({
|
||||
model,
|
||||
reload,
|
||||
...props
|
||||
}: AttachmentViewerProps & { pdf: PDF }) {
|
||||
const state = useLiveData(pdf.state$);
|
||||
}: AttachmentViewerProps & { reload: () => void }) {
|
||||
const pdfService = useService(PDFService);
|
||||
const [pdf, setPdf] = useState<PDF | null>(null);
|
||||
const state = useLiveData(
|
||||
useMemo(
|
||||
() =>
|
||||
pdf?.state$ ??
|
||||
new LiveData<PDFRendererState>({ status: PDFStatus.IDLE }),
|
||||
[pdf]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== PDFStatus.Error) return;
|
||||
@@ -258,17 +336,6 @@ function PDFViewerStatus({
|
||||
track.$.attachment.$.openPDFRendererFail();
|
||||
}, [state]);
|
||||
|
||||
if (state?.status !== PDFStatus.Opened) {
|
||||
return <PDFLoading />;
|
||||
}
|
||||
|
||||
return <PDFViewerInner {...props} pdf={pdf} state={state} />;
|
||||
}
|
||||
|
||||
export function PDFViewer({ model, ...props }: AttachmentViewerProps) {
|
||||
const pdfService = useService(PDFService);
|
||||
const [pdf, setPdf] = useState<PDF | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const { pdf, release } = pdfService.get(model);
|
||||
setPdf(pdf);
|
||||
@@ -278,15 +345,28 @@ export function PDFViewer({ model, ...props }: AttachmentViewerProps) {
|
||||
};
|
||||
}, [model, pdfService, setPdf]);
|
||||
|
||||
if (!pdf) {
|
||||
return <PDFLoading />;
|
||||
if (pdf && state.status === PDFStatus.Opened) {
|
||||
return <PDFViewerInner {...props} pdf={pdf} meta={state.meta} />;
|
||||
}
|
||||
|
||||
return <PDFViewerStatus {...props} model={model} pdf={pdf} />;
|
||||
return (
|
||||
<>
|
||||
<PDFLoading />
|
||||
{state.status === PDFStatus.Error && (
|
||||
<PDFViewerStatus message={state.error.message} reload={reload} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const PDFLoading = () => (
|
||||
<div style={{ margin: 'auto' }}>
|
||||
<div className={styles.pdfLoadingWrapper}>
|
||||
<LoadingSvg />
|
||||
</div>
|
||||
);
|
||||
|
||||
export function PDFViewer(props: AttachmentViewerProps) {
|
||||
const [refreshKey, setRefreshKey] = useState<string | null>(null);
|
||||
const reload = useCallback(() => setRefreshKey(nanoid()), []);
|
||||
return <PDFViewerContainer key={refreshKey} reload={reload} {...props} />;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export const pdfContainer = style({
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
background: cssVar('--affine-background-primary-color'),
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
userSelect: 'none',
|
||||
contentVisibility: 'visible',
|
||||
display: 'flex',
|
||||
@@ -132,8 +132,8 @@ export const pdfControlButton = style({
|
||||
height: '36px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: cssVar('--affine-border-color'),
|
||||
background: cssVar('--affine-white'),
|
||||
borderColor: cssVar('borderColor'),
|
||||
background: cssVar('white'),
|
||||
});
|
||||
|
||||
export const pdfFooter = style({
|
||||
@@ -173,3 +173,53 @@ export const pdfPageCount = style({
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
export const pdfLoadingWrapper = style({
|
||||
margin: 'auto',
|
||||
});
|
||||
|
||||
export const pdfStatus = style({
|
||||
position: 'absolute',
|
||||
left: '18px',
|
||||
bottom: '18px',
|
||||
});
|
||||
|
||||
export const pdfStatusButton = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
fontSize: '18px',
|
||||
outline: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: cssVarV2('button/pureWhiteText'),
|
||||
background: cssVarV2('status/error'),
|
||||
boxShadow: cssVar('overlayShadow'),
|
||||
});
|
||||
|
||||
export const pdfStatusMenu = style({
|
||||
width: '244px',
|
||||
gap: '8px',
|
||||
color: cssVarV2('text/primary'),
|
||||
lineHeight: '22px',
|
||||
});
|
||||
|
||||
export const pdfStatusMenuFooter = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
});
|
||||
|
||||
export const pdfReloadButton = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '2px 12px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
color: cssVarV2('button/primary'),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user