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:
fundon
2025-05-22 01:11:03 +00:00
parent 346c0df800
commit 21ea65edc5
10 changed files with 341 additions and 114 deletions

View File

@@ -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(() => {

View File

@@ -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} />;
};

View File

@@ -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} />;
}

View File

@@ -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'),
});

View File

@@ -16,6 +16,7 @@ type AttachmentPageProps = {
};
const useLoadAttachment = (pageId: string, attachmentId: string) => {
const [loading, setLoading] = useState(true);
const docsService = useService(DocsService);
const docRecord = useLiveData(docsService.list.doc$(pageId));
const [doc, setDoc] = useState<Doc | null>(null);
@@ -23,6 +24,7 @@ const useLoadAttachment = (pageId: string, attachmentId: string) => {
useLayoutEffect(() => {
if (!docRecord) {
setLoading(false);
return;
}
@@ -38,44 +40,23 @@ const useLoadAttachment = (pageId: string, attachmentId: string) => {
doc
.waitForSyncReady()
.then(() => {
const block = doc.blockSuiteDoc.getBlock(attachmentId);
if (block) {
setModel(block.model as AttachmentBlockModel);
}
const model =
doc.blockSuiteDoc.getModelById<AttachmentBlockModel>(attachmentId);
setModel(model);
})
.catch(console.error);
.catch(console.error)
.finally(() => setLoading(false));
return () => {
release();
dispose();
};
}, [docRecord, docsService, pageId, attachmentId]);
}, [docRecord, docsService, pageId, attachmentId, setLoading]);
return { doc, model };
return { doc, model, loading };
};
export const AttachmentPage = ({
pageId,
attachmentId,
}: AttachmentPageProps): ReactElement => {
const { doc, model } = useLoadAttachment(pageId, attachmentId);
if (!doc) {
return <PageNotFound noPermission={false} />;
}
if (doc && model) {
return (
<FrameworkScope scope={doc.scope}>
<ViewTitle title={model.props.name} />
<ViewIcon
icon={model.props.type.endsWith('pdf') ? 'pdf' : 'attachment'}
/>
<AttachmentViewerView model={model} />
</FrameworkScope>
);
}
const Loading = () => {
return (
<div className={styles.attachmentSkeletonStyle}>
<Skeleton
@@ -109,12 +90,37 @@ export const AttachmentPage = ({
);
};
export const AttachmentPage = ({
pageId,
attachmentId,
}: AttachmentPageProps): ReactElement => {
const { doc, model, loading } = useLoadAttachment(pageId, attachmentId);
if (loading) {
return <Loading />;
}
if (doc && model) {
return (
<FrameworkScope scope={doc.scope}>
<ViewTitle title={model.props.name} />
<ViewIcon
icon={model.props.type.endsWith('pdf') ? 'pdf' : 'attachment'}
/>
<AttachmentViewerView model={model} />
</FrameworkScope>
);
}
return <PageNotFound noPermission={false} />;
};
export const Component = () => {
const { pageId, attachmentId } = useParams();
if (!pageId || !attachmentId) {
return <PageNotFound noPermission />;
if (pageId && attachmentId) {
return <AttachmentPage pageId={pageId} attachmentId={attachmentId} />;
}
return <AttachmentPage pageId={pageId} attachmentId={attachmentId} />;
return <PageNotFound noPermission />;
};

View File

@@ -1,49 +1,62 @@
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
const imageExts = new Set([
'jpg',
'jpeg',
'png',
'gif',
'webp',
'svg',
'avif',
'tiff',
'bmp',
]);
const audioExts = new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'opus']);
const videoExts = new Set([
'mp4',
'webm',
'avi',
'mov',
'mkv',
'mpeg',
'ogv',
'3gp',
]);
export function getAttachmentType(model: AttachmentBlockModel) {
const type = model.props.type;
// Check MIME type first
if (model.props.type.startsWith('image/')) {
if (type.startsWith('image/')) {
return 'image';
}
if (model.props.type.startsWith('audio/')) {
if (type.startsWith('audio/')) {
return 'audio';
}
if (model.props.type.startsWith('video/')) {
if (type.startsWith('video/')) {
return 'video';
}
if (model.props.type === 'application/pdf') {
if (type === 'application/pdf') {
return 'pdf';
}
// If MIME type doesn't match, check file extension
const ext = model.props.name.split('.').pop()?.toLowerCase() || '';
const ext = model.props.name.split('.').pop()?.toLowerCase() ?? '';
if (
[
'jpg',
'jpeg',
'png',
'gif',
'webp',
'svg',
'avif',
'tiff',
'bmp',
].includes(ext)
) {
if (imageExts.has(ext)) {
return 'image';
}
if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'opus'].includes(ext)) {
if (audioExts.has(ext)) {
return 'audio';
}
if (
['mp4', 'webm', 'avi', 'mov', 'mkv', 'mpeg', 'ogv', '3gp'].includes(ext)
) {
if (videoExts.has(ext)) {
return 'video';
}
@@ -55,7 +68,7 @@ export function getAttachmentType(model: AttachmentBlockModel) {
}
export async function downloadBlobToBuffer(model: AttachmentBlockModel) {
const sourceId = model.props.sourceId;
const sourceId = model.props.sourceId$.peek();
if (!sourceId) {
throw new Error('Attachment not found');
}
@@ -65,6 +78,5 @@ export async function downloadBlobToBuffer(model: AttachmentBlockModel) {
throw new Error('Attachment not found');
}
const arrayBuffer = await blob.arrayBuffer();
return arrayBuffer;
return await blob.arrayBuffer();
}

View File

@@ -37,9 +37,7 @@ export class PDF extends Entity<AttachmentBlockModel> {
readonly state$ = LiveData.from<PDFRendererState>(
// @ts-expect-error type alias
from(downloadBlobToBuffer(this.props)).pipe(
switchMap(buffer => {
return this.renderer.ob$('open', { data: buffer });
}),
switchMap(data => this.renderer.ob$('open', { data })),
map(meta => ({ status: PDFStatus.Opened, meta })),
// @ts-expect-error type alias
startWith({ status: PDFStatus.Opening }),

View File

@@ -15,5 +15,5 @@ export function configurePDFModule(framework: Framework) {
export { PDF, type PDFRendererState, PDFStatus } from './entities/pdf';
export { PDFPage } from './entities/pdf-page';
export { PDFRenderer } from './renderer';
export { type PDFMeta, PDFRenderer } from './renderer';
export { PDFService } from './services/pdf';

View File

@@ -15,11 +15,14 @@ export const AttachmentPreviewPeekView = ({
}: AttachmentPreviewModalProps) => {
const { doc } = useEditor(docId);
const blocksuiteDoc = doc?.blockSuiteDoc;
const model = useMemo(() => {
const model = blocksuiteDoc?.getBlock(blockId)?.model;
if (!model) return null;
return model as AttachmentBlockModel;
}, [blockId, blocksuiteDoc]);
const model = useMemo(
() => blocksuiteDoc?.getModelById<AttachmentBlockModel>(blockId) ?? null,
[blockId, blocksuiteDoc]
);
return model === null ? null : <AttachmentViewer model={model} />;
if (model) {
return <AttachmentViewer model={model} />;
}
return null;
};