mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 01:09:54 +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 type { AttachmentViewerProps } from './types';
|
||||||
import { getAttachmentType } from './utils';
|
import { getAttachmentType } from './utils';
|
||||||
|
|
||||||
// In Embed view
|
// Embed view
|
||||||
export const AttachmentEmbedPreview = ({ model }: AttachmentViewerProps) => {
|
export const AttachmentEmbedPreview = ({ model }: AttachmentViewerProps) => {
|
||||||
const attachmentType = getAttachmentType(model);
|
const attachmentType = getAttachmentType(model);
|
||||||
const element = useMemo(() => {
|
const element = useMemo(() => {
|
||||||
|
|||||||
@@ -36,11 +36,15 @@ export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AttachmentViewerInner = (props: AttachmentViewerBaseProps) => {
|
const AttachmentViewerInner = (props: AttachmentViewerBaseProps) => {
|
||||||
return props.model.props.type.endsWith('pdf') ? (
|
const isPDF = props.model.props.type.endsWith('pdf');
|
||||||
<AttachmentPreviewErrorBoundary>
|
|
||||||
<PDFViewer {...props} />
|
if (isPDF) {
|
||||||
</AttachmentPreviewErrorBoundary>
|
return (
|
||||||
) : (
|
<AttachmentPreviewErrorBoundary>
|
||||||
<AttachmentFallback {...props} />
|
<PDFViewer {...props} />
|
||||||
);
|
</AttachmentPreviewErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AttachmentFallback {...props} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IconButton, observeResize } from '@affine/component';
|
import { IconButton, Menu, observeResize } from '@affine/component';
|
||||||
import type { PDF, PDFRendererState } from '@affine/core/modules/pdf';
|
import type { PDF, PDFMeta, PDFRendererState } from '@affine/core/modules/pdf';
|
||||||
import { PDFService, PDFStatus } from '@affine/core/modules/pdf';
|
import { PDFService, PDFStatus } from '@affine/core/modules/pdf';
|
||||||
import {
|
import {
|
||||||
Item,
|
Item,
|
||||||
@@ -14,10 +14,23 @@ import {
|
|||||||
ScrollSeekPlaceholder,
|
ScrollSeekPlaceholder,
|
||||||
} from '@affine/core/modules/pdf/views';
|
} from '@affine/core/modules/pdf/views';
|
||||||
import track from '@affine/track';
|
import track from '@affine/track';
|
||||||
import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc';
|
import {
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
CollapseIcon,
|
||||||
|
ExpandIcon,
|
||||||
|
InformationIcon,
|
||||||
|
} from '@blocksuite/icons/rc';
|
||||||
|
import { LiveData, useLiveData, useService } from '@toeverything/infra';
|
||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
import clsx from 'clsx';
|
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 {
|
import {
|
||||||
type ScrollSeekConfiguration,
|
type ScrollSeekConfiguration,
|
||||||
Virtuoso,
|
Virtuoso,
|
||||||
@@ -42,10 +55,10 @@ function calculatePageNum(el: HTMLElement, pageCount: number) {
|
|||||||
|
|
||||||
export interface PDFViewerInnerProps {
|
export interface PDFViewerInnerProps {
|
||||||
pdf: PDF;
|
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 [cursor, setCursor] = useState(0);
|
||||||
const [collapsed, setCollapsed] = useState(true);
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
const [viewportInfo, setViewportInfo] = useState({ width: 0, height: 0 });
|
const [viewportInfo, setViewportInfo] = useState({ width: 0, height: 0 });
|
||||||
@@ -66,13 +79,13 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
|||||||
const el = pagesScrollerRef.current;
|
const el = pagesScrollerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const { pageCount } = state.meta;
|
const { pageCount } = meta;
|
||||||
if (!pageCount) return;
|
if (!pageCount) return;
|
||||||
|
|
||||||
const cursor = calculatePageNum(el, pageCount);
|
const cursor = calculatePageNum(el, pageCount);
|
||||||
|
|
||||||
setCursor(cursor);
|
setCursor(cursor);
|
||||||
}, [pagesScrollerRef, state]);
|
}, [pagesScrollerRef, meta]);
|
||||||
|
|
||||||
const onPageSelect = useCallback(
|
const onPageSelect = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
@@ -121,7 +134,7 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
|||||||
|
|
||||||
const thumbnailsConfig = useMemo(() => {
|
const thumbnailsConfig = useMemo(() => {
|
||||||
const { height: vh } = viewportInfo;
|
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 t = Math.min(maxSize.width / maxSize.height, 1);
|
||||||
const pw = THUMBNAIL_WIDTH / t;
|
const pw = THUMBNAIL_WIDTH / t;
|
||||||
const newMaxSize = {
|
const newMaxSize = {
|
||||||
@@ -158,7 +171,7 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
|||||||
},
|
},
|
||||||
style: { height },
|
style: { height },
|
||||||
};
|
};
|
||||||
}, [state, viewportInfo, onPageSelect]);
|
}, [meta, viewportInfo, onPageSelect]);
|
||||||
|
|
||||||
// 1. works fine if they are the same size
|
// 1. works fine if they are the same size
|
||||||
// 2. uses the `observeIntersection` when targeting different sizes
|
// 2. uses the `observeIntersection` when targeting different sizes
|
||||||
@@ -189,7 +202,7 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
|||||||
scrollerRef={updateScrollerRef}
|
scrollerRef={updateScrollerRef}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
className={styles.virtuoso}
|
className={styles.virtuoso}
|
||||||
totalCount={state.meta.pageCount}
|
totalCount={meta.pageCount}
|
||||||
itemContent={pageContent}
|
itemContent={pageContent}
|
||||||
components={{
|
components={{
|
||||||
Item,
|
Item,
|
||||||
@@ -204,7 +217,7 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
|||||||
width: viewportInfo.width - 40,
|
width: viewportInfo.width - 40,
|
||||||
height: viewportInfo.height - 40,
|
height: viewportInfo.height - 40,
|
||||||
},
|
},
|
||||||
meta: state.meta,
|
meta,
|
||||||
resize: fitToPage,
|
resize: fitToPage,
|
||||||
pageClassName: styles.pdfPage,
|
pageClassName: styles.pdfPage,
|
||||||
}}
|
}}
|
||||||
@@ -216,7 +229,7 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
|||||||
key={`${pdf.id}-thumbnail`}
|
key={`${pdf.id}-thumbnail`}
|
||||||
ref={thumbnailsScrollerHandleRef}
|
ref={thumbnailsScrollerHandleRef}
|
||||||
className={styles.virtuoso}
|
className={styles.virtuoso}
|
||||||
totalCount={state.meta.pageCount}
|
totalCount={meta.pageCount}
|
||||||
itemContent={pageContent}
|
itemContent={pageContent}
|
||||||
components={{
|
components={{
|
||||||
Item,
|
Item,
|
||||||
@@ -232,9 +245,9 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
|||||||
<div className={clsx(['indicator', styles.pdfIndicator])}>
|
<div className={clsx(['indicator', styles.pdfIndicator])}>
|
||||||
<div>
|
<div>
|
||||||
<span className="page-cursor">
|
<span className="page-cursor">
|
||||||
{state.meta.pageCount > 0 ? cursor + 1 : 0}
|
{meta.pageCount > 0 ? cursor + 1 : 0}
|
||||||
</span>
|
</span>
|
||||||
/<span className="page-count">{state.meta.pageCount}</span>
|
/<span className="page-count">{meta.pageCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={collapsed ? <CollapseIcon /> : <ExpandIcon />}
|
icon={collapsed ? <CollapseIcon /> : <ExpandIcon />}
|
||||||
@@ -246,11 +259,76 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function PDFViewerStatus({
|
type PDFViewerStatusProps = {
|
||||||
pdf,
|
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
|
...props
|
||||||
}: AttachmentViewerProps & { pdf: PDF }) {
|
}: AttachmentViewerProps & { reload: () => void }) {
|
||||||
const state = useLiveData(pdf.state$);
|
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(() => {
|
useEffect(() => {
|
||||||
if (state.status !== PDFStatus.Error) return;
|
if (state.status !== PDFStatus.Error) return;
|
||||||
@@ -258,17 +336,6 @@ function PDFViewerStatus({
|
|||||||
track.$.attachment.$.openPDFRendererFail();
|
track.$.attachment.$.openPDFRendererFail();
|
||||||
}, [state]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const { pdf, release } = pdfService.get(model);
|
const { pdf, release } = pdfService.get(model);
|
||||||
setPdf(pdf);
|
setPdf(pdf);
|
||||||
@@ -278,15 +345,28 @@ export function PDFViewer({ model, ...props }: AttachmentViewerProps) {
|
|||||||
};
|
};
|
||||||
}, [model, pdfService, setPdf]);
|
}, [model, pdfService, setPdf]);
|
||||||
|
|
||||||
if (!pdf) {
|
if (pdf && state.status === PDFStatus.Opened) {
|
||||||
return <PDFLoading />;
|
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 = () => (
|
const PDFLoading = () => (
|
||||||
<div style={{ margin: 'auto' }}>
|
<div className={styles.pdfLoadingWrapper}>
|
||||||
<LoadingSvg />
|
<LoadingSvg />
|
||||||
</div>
|
</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',
|
borderWidth: '1px',
|
||||||
borderStyle: 'solid',
|
borderStyle: 'solid',
|
||||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||||
background: cssVar('--affine-background-primary-color'),
|
background: cssVar('backgroundPrimaryColor'),
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
contentVisibility: 'visible',
|
contentVisibility: 'visible',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -132,8 +132,8 @@ export const pdfControlButton = style({
|
|||||||
height: '36px',
|
height: '36px',
|
||||||
borderWidth: '1px',
|
borderWidth: '1px',
|
||||||
borderStyle: 'solid',
|
borderStyle: 'solid',
|
||||||
borderColor: cssVar('--affine-border-color'),
|
borderColor: cssVar('borderColor'),
|
||||||
background: cssVar('--affine-white'),
|
background: cssVar('white'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pdfFooter = style({
|
export const pdfFooter = style({
|
||||||
@@ -173,3 +173,53 @@ export const pdfPageCount = style({
|
|||||||
lineHeight: '20px',
|
lineHeight: '20px',
|
||||||
color: cssVarV2('text/secondary'),
|
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'),
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type AttachmentPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const useLoadAttachment = (pageId: string, attachmentId: string) => {
|
const useLoadAttachment = (pageId: string, attachmentId: string) => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const docsService = useService(DocsService);
|
const docsService = useService(DocsService);
|
||||||
const docRecord = useLiveData(docsService.list.doc$(pageId));
|
const docRecord = useLiveData(docsService.list.doc$(pageId));
|
||||||
const [doc, setDoc] = useState<Doc | null>(null);
|
const [doc, setDoc] = useState<Doc | null>(null);
|
||||||
@@ -23,6 +24,7 @@ const useLoadAttachment = (pageId: string, attachmentId: string) => {
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!docRecord) {
|
if (!docRecord) {
|
||||||
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,44 +40,23 @@ const useLoadAttachment = (pageId: string, attachmentId: string) => {
|
|||||||
doc
|
doc
|
||||||
.waitForSyncReady()
|
.waitForSyncReady()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
const block = doc.blockSuiteDoc.getBlock(attachmentId);
|
const model =
|
||||||
if (block) {
|
doc.blockSuiteDoc.getModelById<AttachmentBlockModel>(attachmentId);
|
||||||
setModel(block.model as AttachmentBlockModel);
|
setModel(model);
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
release();
|
release();
|
||||||
dispose();
|
dispose();
|
||||||
};
|
};
|
||||||
}, [docRecord, docsService, pageId, attachmentId]);
|
}, [docRecord, docsService, pageId, attachmentId, setLoading]);
|
||||||
|
|
||||||
return { doc, model };
|
return { doc, model, loading };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AttachmentPage = ({
|
const Loading = () => {
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.attachmentSkeletonStyle}>
|
<div className={styles.attachmentSkeletonStyle}>
|
||||||
<Skeleton
|
<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 = () => {
|
export const Component = () => {
|
||||||
const { pageId, attachmentId } = useParams();
|
const { pageId, attachmentId } = useParams();
|
||||||
|
|
||||||
if (!pageId || !attachmentId) {
|
if (pageId && attachmentId) {
|
||||||
return <PageNotFound noPermission />;
|
return <AttachmentPage pageId={pageId} attachmentId={attachmentId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AttachmentPage pageId={pageId} attachmentId={attachmentId} />;
|
return <PageNotFound noPermission />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,49 +1,62 @@
|
|||||||
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
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) {
|
export function getAttachmentType(model: AttachmentBlockModel) {
|
||||||
|
const type = model.props.type;
|
||||||
|
|
||||||
// Check MIME type first
|
// Check MIME type first
|
||||||
if (model.props.type.startsWith('image/')) {
|
if (type.startsWith('image/')) {
|
||||||
return 'image';
|
return 'image';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.props.type.startsWith('audio/')) {
|
if (type.startsWith('audio/')) {
|
||||||
return 'audio';
|
return 'audio';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.props.type.startsWith('video/')) {
|
if (type.startsWith('video/')) {
|
||||||
return 'video';
|
return 'video';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.props.type === 'application/pdf') {
|
if (type === 'application/pdf') {
|
||||||
return 'pdf';
|
return 'pdf';
|
||||||
}
|
}
|
||||||
|
|
||||||
// If MIME type doesn't match, check file extension
|
// 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 (
|
if (imageExts.has(ext)) {
|
||||||
[
|
|
||||||
'jpg',
|
|
||||||
'jpeg',
|
|
||||||
'png',
|
|
||||||
'gif',
|
|
||||||
'webp',
|
|
||||||
'svg',
|
|
||||||
'avif',
|
|
||||||
'tiff',
|
|
||||||
'bmp',
|
|
||||||
].includes(ext)
|
|
||||||
) {
|
|
||||||
return 'image';
|
return 'image';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'opus'].includes(ext)) {
|
if (audioExts.has(ext)) {
|
||||||
return 'audio';
|
return 'audio';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (videoExts.has(ext)) {
|
||||||
['mp4', 'webm', 'avi', 'mov', 'mkv', 'mpeg', 'ogv', '3gp'].includes(ext)
|
|
||||||
) {
|
|
||||||
return 'video';
|
return 'video';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +68,7 @@ export function getAttachmentType(model: AttachmentBlockModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadBlobToBuffer(model: AttachmentBlockModel) {
|
export async function downloadBlobToBuffer(model: AttachmentBlockModel) {
|
||||||
const sourceId = model.props.sourceId;
|
const sourceId = model.props.sourceId$.peek();
|
||||||
if (!sourceId) {
|
if (!sourceId) {
|
||||||
throw new Error('Attachment not found');
|
throw new Error('Attachment not found');
|
||||||
}
|
}
|
||||||
@@ -65,6 +78,5 @@ export async function downloadBlobToBuffer(model: AttachmentBlockModel) {
|
|||||||
throw new Error('Attachment not found');
|
throw new Error('Attachment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
return await blob.arrayBuffer();
|
||||||
return arrayBuffer;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,9 +37,7 @@ export class PDF extends Entity<AttachmentBlockModel> {
|
|||||||
readonly state$ = LiveData.from<PDFRendererState>(
|
readonly state$ = LiveData.from<PDFRendererState>(
|
||||||
// @ts-expect-error type alias
|
// @ts-expect-error type alias
|
||||||
from(downloadBlobToBuffer(this.props)).pipe(
|
from(downloadBlobToBuffer(this.props)).pipe(
|
||||||
switchMap(buffer => {
|
switchMap(data => this.renderer.ob$('open', { data })),
|
||||||
return this.renderer.ob$('open', { data: buffer });
|
|
||||||
}),
|
|
||||||
map(meta => ({ status: PDFStatus.Opened, meta })),
|
map(meta => ({ status: PDFStatus.Opened, meta })),
|
||||||
// @ts-expect-error type alias
|
// @ts-expect-error type alias
|
||||||
startWith({ status: PDFStatus.Opening }),
|
startWith({ status: PDFStatus.Opening }),
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ export function configurePDFModule(framework: Framework) {
|
|||||||
|
|
||||||
export { PDF, type PDFRendererState, PDFStatus } from './entities/pdf';
|
export { PDF, type PDFRendererState, PDFStatus } from './entities/pdf';
|
||||||
export { PDFPage } from './entities/pdf-page';
|
export { PDFPage } from './entities/pdf-page';
|
||||||
export { PDFRenderer } from './renderer';
|
export { type PDFMeta, PDFRenderer } from './renderer';
|
||||||
export { PDFService } from './services/pdf';
|
export { PDFService } from './services/pdf';
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ export const AttachmentPreviewPeekView = ({
|
|||||||
}: AttachmentPreviewModalProps) => {
|
}: AttachmentPreviewModalProps) => {
|
||||||
const { doc } = useEditor(docId);
|
const { doc } = useEditor(docId);
|
||||||
const blocksuiteDoc = doc?.blockSuiteDoc;
|
const blocksuiteDoc = doc?.blockSuiteDoc;
|
||||||
const model = useMemo(() => {
|
const model = useMemo(
|
||||||
const model = blocksuiteDoc?.getBlock(blockId)?.model;
|
() => blocksuiteDoc?.getModelById<AttachmentBlockModel>(blockId) ?? null,
|
||||||
if (!model) return null;
|
[blockId, blocksuiteDoc]
|
||||||
return model as AttachmentBlockModel;
|
);
|
||||||
}, [blockId, blocksuiteDoc]);
|
|
||||||
|
|
||||||
return model === null ? null : <AttachmentViewer model={model} />;
|
if (model) {
|
||||||
|
return <AttachmentViewer model={model} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -355,7 +355,6 @@ test('should re-render pdf viewer', async ({ page }) => {
|
|||||||
|
|
||||||
const title = getBlockSuiteEditorTitle(page);
|
const title = getBlockSuiteEditorTitle(page);
|
||||||
await title.click();
|
await title.click();
|
||||||
await page.keyboard.type('PDF preview');
|
|
||||||
|
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
@@ -386,3 +385,78 @@ test('should re-render pdf viewer', async ({ page }) => {
|
|||||||
|
|
||||||
expect(portalId).not.toEqual(newPortalId);
|
expect(portalId).not.toEqual(newPortalId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should display status when an error is thrown in peek view', async ({
|
||||||
|
context,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openHomePage(page);
|
||||||
|
await clickNewPageButton(page);
|
||||||
|
await waitForEmptyEditor(page);
|
||||||
|
|
||||||
|
const title = getBlockSuiteEditorTitle(page);
|
||||||
|
await title.click();
|
||||||
|
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await importAttachment(page, 'lorem-ipsum.pdf');
|
||||||
|
|
||||||
|
const attachment = page.locator('affine-attachment');
|
||||||
|
await attachment.click();
|
||||||
|
|
||||||
|
const toolbar = locateToolbar(page);
|
||||||
|
|
||||||
|
// Switches to embed view
|
||||||
|
await toolbar.getByLabel('Switch view').click();
|
||||||
|
await toolbar.getByLabel('Embed view').click();
|
||||||
|
|
||||||
|
await context.setOffline(true);
|
||||||
|
|
||||||
|
await attachment.dblclick();
|
||||||
|
|
||||||
|
// Peek view
|
||||||
|
const pdfViewer = page.getByTestId('pdf-viewer');
|
||||||
|
await expect(pdfViewer).toBeHidden();
|
||||||
|
|
||||||
|
const statusWrapper = page.getByTestId('pdf-viewer-status-wrapper');
|
||||||
|
await expect(statusWrapper).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display 404 when attachment is not found', async ({ page }) => {
|
||||||
|
await openHomePage(page);
|
||||||
|
await clickNewPageButton(page);
|
||||||
|
await waitForEmptyEditor(page);
|
||||||
|
|
||||||
|
const title = getBlockSuiteEditorTitle(page);
|
||||||
|
await title.click();
|
||||||
|
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await importAttachment(page, 'lorem-ipsum.pdf');
|
||||||
|
|
||||||
|
const attachment = page.locator('affine-attachment');
|
||||||
|
await attachment.click();
|
||||||
|
|
||||||
|
const toolbar = locateToolbar(page);
|
||||||
|
|
||||||
|
// Switches to embed view
|
||||||
|
await toolbar.getByLabel('Switch view').click();
|
||||||
|
await toolbar.getByLabel('Embed view').click();
|
||||||
|
|
||||||
|
await attachment.dblclick();
|
||||||
|
|
||||||
|
// Peek view
|
||||||
|
const pdfViewer = page.getByTestId('pdf-viewer');
|
||||||
|
await expect(pdfViewer).toBeVisible();
|
||||||
|
|
||||||
|
const statusWrapper = page.getByTestId('pdf-viewer-status-wrapper');
|
||||||
|
await expect(statusWrapper).toBeHidden();
|
||||||
|
|
||||||
|
await clickPeekViewControl(page, 1);
|
||||||
|
|
||||||
|
await page.goto(page.url() + 'test');
|
||||||
|
|
||||||
|
await expect(pdfViewer).toBeHidden();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('not-found')).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user