diff --git a/packages/frontend/core/src/blocksuite/attachment-viewer/attachment-embed-preview.tsx b/packages/frontend/core/src/blocksuite/attachment-viewer/attachment-embed-preview.tsx index c861ec9504..3f21c5ec3c 100644 --- a/packages/frontend/core/src/blocksuite/attachment-viewer/attachment-embed-preview.tsx +++ b/packages/frontend/core/src/blocksuite/attachment-viewer/attachment-embed-preview.tsx @@ -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(() => { diff --git a/packages/frontend/core/src/blocksuite/attachment-viewer/index.tsx b/packages/frontend/core/src/blocksuite/attachment-viewer/index.tsx index 341876e594..35c18b855f 100644 --- a/packages/frontend/core/src/blocksuite/attachment-viewer/index.tsx +++ b/packages/frontend/core/src/blocksuite/attachment-viewer/index.tsx @@ -36,11 +36,15 @@ export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => { }; const AttachmentViewerInner = (props: AttachmentViewerBaseProps) => { - return props.model.props.type.endsWith('pdf') ? ( - - - - ) : ( - - ); + const isPDF = props.model.props.type.endsWith('pdf'); + + if (isPDF) { + return ( + + + + ); + } + + return ; }; diff --git a/packages/frontend/core/src/blocksuite/attachment-viewer/pdf/pdf-viewer.tsx b/packages/frontend/core/src/blocksuite/attachment-viewer/pdf/pdf-viewer.tsx index 1836a76077..f2283e8797 100644 --- a/packages/frontend/core/src/blocksuite/attachment-viewer/pdf/pdf-viewer.tsx +++ b/packages/frontend/core/src/blocksuite/attachment-viewer/pdf/pdf-viewer.tsx @@ -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; + 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) => {
- {state.meta.pageCount > 0 ? cursor + 1 : 0} + {meta.pageCount > 0 ? cursor + 1 : 0} - /{state.meta.pageCount} + /{meta.pageCount}
: } @@ -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 ( +
+
{message}
+
+ +
+
+ ); +} + +function PDFViewerStatus(props: PDFViewerStatusProps) { + return ( +
+ } + contentWrapperStyle={{ + padding: '8px', + boxShadow: cssVar('overlayShadow'), + }} + contentOptions={{ + sideOffset: 8, + }} + > + + +
+ ); +} + +function PDFViewerContainer({ + model, + reload, ...props -}: AttachmentViewerProps & { pdf: PDF }) { - const state = useLiveData(pdf.state$); +}: AttachmentViewerProps & { reload: () => void }) { + const pdfService = useService(PDFService); + const [pdf, setPdf] = useState(null); + const state = useLiveData( + useMemo( + () => + pdf?.state$ ?? + new LiveData({ 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 ; - } - - return ; -} - -export function PDFViewer({ model, ...props }: AttachmentViewerProps) { - const pdfService = useService(PDFService); - const [pdf, setPdf] = useState(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 ; + if (pdf && state.status === PDFStatus.Opened) { + return ; } - return ; + return ( + <> + + {state.status === PDFStatus.Error && ( + + )} + + ); } const PDFLoading = () => ( -
+
); + +export function PDFViewer(props: AttachmentViewerProps) { + const [refreshKey, setRefreshKey] = useState(null); + const reload = useCallback(() => setRefreshKey(nanoid()), []); + return ; +} diff --git a/packages/frontend/core/src/blocksuite/attachment-viewer/pdf/styles.css.ts b/packages/frontend/core/src/blocksuite/attachment-viewer/pdf/styles.css.ts index 90c306de51..a11bd7ef2b 100644 --- a/packages/frontend/core/src/blocksuite/attachment-viewer/pdf/styles.css.ts +++ b/packages/frontend/core/src/blocksuite/attachment-viewer/pdf/styles.css.ts @@ -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'), +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx index 843cff6e92..d6c9ecf399 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx @@ -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(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(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 ; - } - - if (doc && model) { - return ( - - - - - - ); - } - +const Loading = () => { return (
{ + const { doc, model, loading } = useLoadAttachment(pageId, attachmentId); + + if (loading) { + return ; + } + + if (doc && model) { + return ( + + + + + + ); + } + + return ; +}; + export const Component = () => { const { pageId, attachmentId } = useParams(); - if (!pageId || !attachmentId) { - return ; + if (pageId && attachmentId) { + return ; } - return ; + return ; }; diff --git a/packages/frontend/core/src/modules/media/utils.ts b/packages/frontend/core/src/modules/media/utils.ts index 3a2a567d58..24b973562d 100644 --- a/packages/frontend/core/src/modules/media/utils.ts +++ b/packages/frontend/core/src/modules/media/utils.ts @@ -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(); } diff --git a/packages/frontend/core/src/modules/pdf/entities/pdf.ts b/packages/frontend/core/src/modules/pdf/entities/pdf.ts index b874110f6c..3e2306af46 100644 --- a/packages/frontend/core/src/modules/pdf/entities/pdf.ts +++ b/packages/frontend/core/src/modules/pdf/entities/pdf.ts @@ -37,9 +37,7 @@ export class PDF extends Entity { readonly state$ = LiveData.from( // @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 }), diff --git a/packages/frontend/core/src/modules/pdf/index.ts b/packages/frontend/core/src/modules/pdf/index.ts index 5a6a4f5a15..fa64c51bfc 100644 --- a/packages/frontend/core/src/modules/pdf/index.ts +++ b/packages/frontend/core/src/modules/pdf/index.ts @@ -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'; diff --git a/packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx b/packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx index 2b970edef1..98f3785e8f 100644 --- a/packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx @@ -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(blockId) ?? null, + [blockId, blocksuiteDoc] + ); - return model === null ? null : ; + if (model) { + return ; + } + + return null; }; diff --git a/tests/affine-local/e2e/attachment-preview.spec.ts b/tests/affine-local/e2e/attachment-preview.spec.ts index ccf529dbf5..f6dc89a407 100644 --- a/tests/affine-local/e2e/attachment-preview.spec.ts +++ b/tests/affine-local/e2e/attachment-preview.spec.ts @@ -355,7 +355,6 @@ test('should re-render pdf viewer', async ({ page }) => { const title = getBlockSuiteEditorTitle(page); await title.click(); - await page.keyboard.type('PDF preview'); await page.keyboard.press('Enter'); @@ -386,3 +385,78 @@ test('should re-render pdf viewer', async ({ page }) => { 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(); +});