From 25086b921eaaaaee375b6e0b9604662fff23a42d Mon Sep 17 00:00:00 2001 From: Fangdun Tsai Date: Mon, 28 Oct 2024 14:21:29 +0800 Subject: [PATCH] chore: improve pdf preview --- packages/frontend/component/package.json | 3 - .../components/attachment-viewer/error.tsx | 28 -- .../src/components/attachment-viewer/utils.ts | 40 -- .../components/attachment-viewer/viewer.tsx | 417 ----------------- .../attachment-viewer/worker/types.ts | 33 -- .../attachment-viewer/worker/worker.ts | 164 ------- packages/frontend/core/package.json | 2 + .../components/attachment-viewer/error.tsx | 85 ++++ .../components/attachment-viewer/index.tsx | 25 ++ .../attachment-viewer/styles.css.ts | 36 +- .../components/attachment-viewer/titlebar.tsx | 25 +- .../src/components/attachment-viewer/utils.ts | 63 +++ .../components/attachment-viewer/viewer.tsx | 425 ++++++++++++++++++ .../attachment-viewer/worker/types.ts | 69 +++ .../attachment-viewer/worker/utils.ts | 13 + .../attachment-viewer/worker/worker.ts | 223 +++++++++ .../pages/workspace/attachment/index.css.ts | 10 + .../pages/workspace/attachment/index.tsx | 158 +++++-- .../view/attachment-preview/index.tsx | 2 +- .../peek-view/view/peek-view-controls.tsx | 65 +++ .../peek-view/view/peek-view-manager.tsx | 5 +- .../modules/workbench/entities/workbench.ts | 8 + packages/frontend/i18n/src/resources/en.json | 7 +- .../e2e/attachment-preview.spec.ts | 118 +++++ tests/fixtures/lorem-ipsum.pdf | Bin 0 -> 23286 bytes yarn.lock | 5 +- 26 files changed, 1274 insertions(+), 755 deletions(-) delete mode 100644 packages/frontend/component/src/components/attachment-viewer/error.tsx delete mode 100644 packages/frontend/component/src/components/attachment-viewer/utils.ts delete mode 100644 packages/frontend/component/src/components/attachment-viewer/viewer.tsx delete mode 100644 packages/frontend/component/src/components/attachment-viewer/worker/types.ts delete mode 100644 packages/frontend/component/src/components/attachment-viewer/worker/worker.ts create mode 100644 packages/frontend/core/src/components/attachment-viewer/error.tsx rename packages/frontend/{component => core}/src/components/attachment-viewer/index.tsx (56%) rename packages/frontend/{component => core}/src/components/attachment-viewer/styles.css.ts (88%) rename packages/frontend/{component => core}/src/components/attachment-viewer/titlebar.tsx (81%) create mode 100644 packages/frontend/core/src/components/attachment-viewer/utils.ts create mode 100644 packages/frontend/core/src/components/attachment-viewer/viewer.tsx create mode 100644 packages/frontend/core/src/components/attachment-viewer/worker/types.ts create mode 100644 packages/frontend/core/src/components/attachment-viewer/worker/utils.ts create mode 100644 packages/frontend/core/src/components/attachment-viewer/worker/worker.ts create mode 100644 packages/frontend/core/src/desktop/pages/workspace/attachment/index.css.ts create mode 100644 tests/affine-local/e2e/attachment-preview.spec.ts create mode 100644 tests/fixtures/lorem-ipsum.pdf diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index ea7db8e8c1086..dfe1ec502d917 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -47,8 +47,6 @@ "check-password-strength": "^2.0.10", "clsx": "^2.1.0", "dayjs": "^1.11.10", - "file-type": "^19.1.0", - "filesize": "^10.1.6", "jotai": "^2.8.0", "lit": "^3.1.2", "lodash-es": "^4.17.21", @@ -61,7 +59,6 @@ "react-paginate": "^8.2.0", "react-router-dom": "^6.22.3", "react-transition-state": "^2.1.1", - "react-virtuoso": "^4.7.8", "sonner": "^1.4.41", "swr": "^2.2.5", "zod": "^3.22.4" diff --git a/packages/frontend/component/src/components/attachment-viewer/error.tsx b/packages/frontend/component/src/components/attachment-viewer/error.tsx deleted file mode 100644 index 2a9bf9550ce7d..0000000000000 --- a/packages/frontend/component/src/components/attachment-viewer/error.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { AttachmentBlockModel } from '@blocksuite/blocks'; -import { ArrowDownBigIcon, PageIcon } from '@blocksuite/icons/rc'; -import clsx from 'clsx'; - -import { Button } from '../../ui/button'; -import * as styles from './styles.css'; - -interface ErrorProps { - model: AttachmentBlockModel; - ext: string; - isPDF: boolean; -} - -export const Error = ({ ext }: ErrorProps) => { - return ( -
- -

Unable to preview this file

-

.{ext} file type not supported.

-
- - -
-
- ); -}; diff --git a/packages/frontend/component/src/components/attachment-viewer/utils.ts b/packages/frontend/component/src/components/attachment-viewer/utils.ts deleted file mode 100644 index 2238b3eb09857..0000000000000 --- a/packages/frontend/component/src/components/attachment-viewer/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { fileTypeFromBuffer } from 'file-type'; - -export async function saveBufferToFile(url: string, filename: string) { - // given input url may not have correct mime type - const blob = await attachmentUrlToBlob(url); - if (!blob) { - return; - } - - const blobUrl = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = blobUrl; - a.download = filename; - document.body.append(a); - a.click(); - a.remove(); - URL.revokeObjectURL(blobUrl); -} - -export async function attachmentUrlToBlob( - url: string -): Promise { - const buffer = await fetch(url).then(response => response.arrayBuffer()); - - if (!buffer) { - console.warn('Could not get blob'); - return; - } - try { - const type = await fileTypeFromBuffer(buffer); - if (!type) { - return; - } - const blob = new Blob([buffer], { type: type.mime }); - return blob; - } catch (error) { - console.error('Error converting attachment to blob', error); - } - return; -} diff --git a/packages/frontend/component/src/components/attachment-viewer/viewer.tsx b/packages/frontend/component/src/components/attachment-viewer/viewer.tsx deleted file mode 100644 index 78b19d60b0210..0000000000000 --- a/packages/frontend/component/src/components/attachment-viewer/viewer.tsx +++ /dev/null @@ -1,417 +0,0 @@ -import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc'; -import clsx from 'clsx'; -import { debounce } from 'lodash-es'; -import type { HTMLAttributes, PropsWithChildren, ReactElement } from 'react'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import type { VirtuosoHandle } from 'react-virtuoso'; -import { Virtuoso } from 'react-virtuoso'; - -import { IconButton } from '../../ui/button'; -import { Scrollable } from '../../ui/scrollbar'; -import * as styles from './styles.css'; -// import { observeResize } from '../../utils'; -import type { MessageData, MessageDataType } from './worker/types'; -import { MessageOp, State } from './worker/types'; - -const Page = React.memo( - ({ - width, - height, - className, - }: { - index: number; - width: number; - height: number; - className: string; - }) => { - return ( -
- -
- ); - } -); - -Page.displayName = 'viewer-page'; - -const THUMBNAIL_WIDTH = 94; - -const Thumbnail = React.memo( - ({ - index, - width, - height, - className, - onSelect, - }: { - index: number; - width: number; - height: number; - className: string; - onSelect: (index: number) => void; - }) => { - return ( -
onSelect(index)}> - -
- ); - } -); - -Thumbnail.displayName = 'viewer-thumbnail'; - -const Scroller = React.forwardRef< - HTMLDivElement, - PropsWithChildren> ->(({ style, ...props }, ref) => { - return ( - - - - - ); -}); - -Scroller.displayName = 'viewer-scroller'; - -interface ViewerProps { - model: AttachmentBlockModel; -} - -export const Viewer = ({ model }: ViewerProps): ReactElement => { - const [connected, setConnected] = useState(false); - const [loaded, setLoaded] = useState(false); - const [docInfo, setDocInfo] = useState({ - cursor: 0, - total: 0, - width: 1, - height: 1, - }); - const viewerRef = useRef(null); - const scrollerRef = useRef(null); - const scrollerHandleRef = useRef(null); - const workerRef = useRef(null); - - const [mainVisibleRange, setMainVisibleRange] = useState({ - startIndex: 0, - endIndex: 0, - }); - - const [collapsed, setCollapsed] = useState(true); - const thumbnailsScrollerHandleRef = useRef(null); - const thumbnailsScrollerRef = useRef(null); - const [thumbnailsVisibleRange, setThumbnailsVisibleRange] = useState({ - startIndex: 0, - endIndex: 0, - }); - - const post = useCallback( - ( - type: T, - data?: MessageDataType[T], - transfer = [] - ) => { - workerRef.current?.postMessage( - { - state: State.Poll, - type, - [type]: data, - }, - transfer - ); - }, - [workerRef] - ); - - const render = useCallback( - (id: number, imageData: ImageData) => { - const el = scrollerRef.current; - if (!el) return; - - const canvas: HTMLCanvasElement | null = el.querySelector( - `[data-index="${id}"] canvas` - ); - if (!canvas) return; - if (canvas.dataset.rendered) return; - - // TODO(@fundon): improve - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.putImageData(imageData, 0, 0); - canvas.dataset.rendered = 'true'; - } - }, - [scrollerRef] - ); - - const renderThumbnail = useCallback( - (id: number, imageData: ImageData) => { - const el = thumbnailsScrollerRef.current; - if (!el) return; - - const canvas: HTMLCanvasElement | null = el.querySelector( - `[data-index="${id}"] canvas` - ); - if (!canvas) return; - if (canvas.dataset.rendered) return; - - // TODO(@fundon): improve - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.putImageData(imageData, 0, 0); - canvas.dataset.rendered = 'true'; - } - }, - [thumbnailsScrollerRef] - ); - - const onScroll = useCallback(() => { - const el = scrollerRef.current; - if (!el) return; - - const { scrollTop, scrollHeight } = el; - - setDocInfo(info => { - const cursor = Math.ceil(scrollTop / (scrollHeight / info.total)); - // thumbnailsScrollerHandleRef.current?.scrollToIndex(cursor) - return { - ...info, - cursor, - }; - }); - // }, [scrollerRef, thumbnailsScrollerHandleRef]); - }, [scrollerRef]); - - const onSelect = useCallback( - (index: number) => { - scrollerHandleRef.current?.scrollToIndex(index); - setDocInfo(info => ({ ...info, cursor: index })); - }, - [scrollerHandleRef] - ); - - const updateMainVisibleRange = useMemo( - () => debounce(setMainVisibleRange, 233, { leading: true, trailing: true }), - [setMainVisibleRange] - ); - - const updateThumbnailsVisibleRange = useMemo( - () => - debounce(setThumbnailsVisibleRange, 233, { - leading: true, - trailing: true, - }), - [setThumbnailsVisibleRange] - ); - - // useEffect(() => { - // const el = viewerRef.current; - // if (!el) return; - - // return observeResize(el, entry => { - // console.log(entry); - // }); - // }, []); - - useEffect(() => { - const { startIndex, endIndex } = mainVisibleRange; - let index = startIndex; - for (; index < endIndex + 1; index++) { - post(MessageOp.Render, { index, kind: 'page' }); - } - }, [mainVisibleRange, post]); - - useEffect(() => { - const { startIndex, endIndex } = thumbnailsVisibleRange; - let index = startIndex; - for (; index < endIndex + 1; index++) { - post(MessageOp.Render, { index, kind: 'thumbnail' }); - } - }, [thumbnailsVisibleRange, post]); - - useEffect(() => { - workerRef.current = new Worker( - /* webpackChunkName: "pdf.worker" */ new URL( - './worker/worker.ts', - import.meta.url - ) - ); - - async function process({ data }: MessageEvent) { - const { type, state } = data; - - if (type === MessageOp.Init) { - setConnected(state === State.Ready); - return; - } - if (type === MessageOp.Open) { - setLoaded(state === State.Ready); - return; - } - - if (state === State.Poll) return; - - switch (type) { - case MessageOp.ReadInfo: { - const action = data[type]; - setDocInfo(info => ({ ...info, ...action })); - break; - } - case MessageOp.Rendered: { - const { index, imageData, kind } = data[type]; - if (kind === 'page') { - render(index, imageData); - } else { - renderThumbnail(index, imageData); - } - break; - } - } - } - - workerRef.current.addEventListener('message', event => { - process(event).catch(console.error); - }); - - return () => { - workerRef.current?.terminate(); - }; - }, [model, post, render, renderThumbnail]); - - useEffect(() => { - if (!connected) return; - if (!model.sourceId) return; - - model.doc.blobSync - .get(model.sourceId) - .then(blob => { - if (!blob) return; - post(MessageOp.Open, { blob, dpi: window.devicePixelRatio }); - }) - .catch(console.error); - }, [connected, model, post]); - - useEffect(() => { - if (!loaded) return; - post(MessageOp.ReadInfo); - }, [loaded, post]); - - const pageContent = (index: number) => { - return ( - - ); - }; - - const thumbnailContent = (index: number) => { - return ( - - ); - }; - - const components = useMemo(() => { - return { - Scroller, - }; - }, []); - - return ( -
- { - if (scrollerRef.current) return; - scrollerRef.current = scroller as HTMLElement; - }} - className={styles.virtuoso} - rangeChanged={updateMainVisibleRange} - increaseViewportBy={{ - top: docInfo.height * Math.min(5, docInfo.total), - bottom: docInfo.height * Math.min(5, docInfo.total), - }} - totalCount={docInfo.total} - itemContent={pageContent} - components={components} - /> -
- {collapsed ? null : ( -
- { - if (thumbnailsScrollerRef.current) return; - thumbnailsScrollerRef.current = scroller as HTMLElement; - }} - rangeChanged={updateThumbnailsVisibleRange} - className={styles.virtuoso} - totalCount={docInfo.total} - itemContent={thumbnailContent} - components={components} - /> -
- )} -
-
- {docInfo.cursor + 1}/{docInfo.total} -
- : } - onClick={() => setCollapsed(state => !state)} - /> -
-
-
- ); -}; diff --git a/packages/frontend/component/src/components/attachment-viewer/worker/types.ts b/packages/frontend/component/src/components/attachment-viewer/worker/types.ts deleted file mode 100644 index 4ec6b2a064931..0000000000000 --- a/packages/frontend/component/src/components/attachment-viewer/worker/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -export enum State { - Poll, - Ready, -} - -export enum MessageOp { - Init, - Open, - ReadInfo, - Render, - Rendered, -} - -export type MessageDataMap = { - [MessageOp.Init]: undefined; - [MessageOp.Open]: { blob: Blob; dpi: number }; - [MessageOp.ReadInfo]: { total: number; width: number; height: number }; - [MessageOp.Render]: { index: number; kind: 'page' | 'thumbnail' }; - [MessageOp.Rendered]: { - index: number; - imageData: ImageData; - kind: 'page' | 'thumbnail'; - }; -}; - -export type MessageDataType = { - [P in keyof T]: T[P]; -}; - -export type MessageData = { - state: State; - type: T; -} & P; diff --git a/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts b/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts deleted file mode 100644 index d6423a6083148..0000000000000 --- a/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import type { Document } from '@toeverything/pdf-viewer'; -import { - createPDFium, - PageRenderingflags, - Runtime, - Viewer, -} from '@toeverything/pdf-viewer'; - -import type { MessageData, MessageDataType } from './types'; -import { MessageOp, State } from './types'; - -const logger = new DebugLogger('affine:pdf-worker'); - -let dpi = 2; -let inited = false; -let viewer: Viewer | null = null; -let doc: Document | undefined = undefined; - -const cached = new Map(); -const docInfo = { cursor: 0, total: 0, width: 1, height: 1 }; -const flags = PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT; - -function post(type: T, data?: MessageDataType[T]) { - self.postMessage({ state: State.Ready, type, [type]: data }); -} - -async function resizeImageData( - imageData: ImageData, - options: { - resizeWidth: number; - resizeHeight: number; - } -) { - const { resizeWidth: w, resizeHeight: h } = options; - const bitmap = await createImageBitmap( - imageData, - 0, - 0, - imageData.width, - imageData.height, - options - ); - const canvas = new OffscreenCanvas(w, h); - const ctx = canvas.getContext('2d'); - if (!ctx) return imageData; - ctx.drawImage(bitmap, 0, 0); - return ctx.getImageData(0, 0, w, h); -} - -async function start() { - logger.debug('pdf worker pending'); - self.postMessage({ state: State.Poll, type: MessageOp.Init }); - - const pdfium = await createPDFium(); - viewer = new Viewer(new Runtime(pdfium)); - inited = true; - - self.postMessage({ state: State.Ready, type: MessageOp.Init }); - logger.debug('pdf worker ready'); -} - -async function process({ data }: MessageEvent) { - if (!inited || !viewer) { - await start(); - } - - if (!viewer) return; - - const { type, state } = data; - - if (state !== State.Poll) return; - - switch (type) { - case MessageOp.Open: { - const action = data[type]; - if (!action?.blob) return; - - dpi = action.dpi; - doc = await viewer.openWithBlob(action.blob); - - if (!doc) return; - - post(MessageOp.Open); - break; - } - - case MessageOp.ReadInfo: { - if (!doc) return; - - const page = doc.page(0); - if (page) { - docInfo.cursor = 0; - docInfo.total = doc.pageCount(); - docInfo.height = page.height(); - docInfo.width = page.width(); - page.close(); - post(MessageOp.ReadInfo, docInfo); - } - break; - } - - case MessageOp.Render: { - if (!doc) return; - - const { index, kind } = data[type]; - - let imageData = cached.size > 0 ? cached.get(index) : undefined; - if (imageData) { - if (kind === 'thumbnail') { - const resizeWidth = (94 * dpi) >> 0; - const resizeHeight = - ((docInfo.height / docInfo.width) * resizeWidth) >> 0; - imageData = await resizeImageData(imageData, { - resizeWidth, - resizeHeight, - }); - } - - post(MessageOp.Rendered, { index, imageData, kind }); - return; - } - - const width = Math.ceil(docInfo.width * dpi); - const height = Math.ceil(docInfo.height * dpi); - const page = doc.page(index); - - if (page) { - const bitmap = viewer.createBitmap(width, height, 0); - bitmap.fill(0, 0, width, height); - page.render(bitmap, 0, 0, width, height, 0, flags); - - const data = bitmap.toBytes(); - - bitmap.close(); - page.close(); - - imageData = new ImageData(new Uint8ClampedArray(data), width, height); - - cached.set(index, imageData); - - if (kind === 'thumbnail') { - const resizeWidth = (94 * dpi) >> 0; - const resizeHeight = - ((docInfo.height / docInfo.width) * resizeWidth) >> 0; - imageData = await resizeImageData(imageData, { - resizeWidth, - resizeHeight, - }); - } - - post(MessageOp.Rendered, { index, imageData, kind }); - } - - break; - } - } -} - -self.addEventListener('message', (event: MessageEvent) => { - process(event).catch(console.error); -}); - -start().catch(console.error); diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index fc980b245dc5c..7e41b50169fe9 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -36,6 +36,7 @@ "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-toolbar": "^1.0.4", "@sentry/react": "^8.0.0", + "@toeverything/pdf-viewer": "^0.1.0", "@toeverything/theme": "^1.0.16", "@vanilla-extract/dynamic": "^2.1.0", "animejs": "^3.2.2", @@ -45,6 +46,7 @@ "core-js": "^3.36.1", "dayjs": "^1.11.10", "file-type": "^19.1.0", + "filesize": "^10.1.6", "foxact": "^0.2.33", "fuse.js": "^7.0.0", "graphemer": "^1.4.0", diff --git a/packages/frontend/core/src/components/attachment-viewer/error.tsx b/packages/frontend/core/src/components/attachment-viewer/error.tsx new file mode 100644 index 0000000000000..80dd9034d6400 --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/error.tsx @@ -0,0 +1,85 @@ +import { Button } from '@affine/component'; +import { useI18n } from '@affine/i18n'; +import type { AttachmentBlockModel } from '@blocksuite/blocks'; +import { ArrowDownBigIcon } from '@blocksuite/icons/rc'; +import clsx from 'clsx'; + +import * as styles from './styles.css'; +import { download } from './utils'; + +// https://github.com/toeverything/blocksuite/blob/master/packages/affine/components/src/icons/file-icons.ts +// TODO: should move file icons to icons repo +const FileIcon = () => ( + + + +); + +const PDFFileIcon = () => ( + + + + + + +); + +interface ErrorProps { + model: AttachmentBlockModel; + ext: string; + isPDF: boolean; +} + +export const Error = ({ model, ext, isPDF }: ErrorProps) => { + const t = useI18n(); + + return ( +
+ {isPDF ? : } +

+ {t['com.affine.attachment.preview.error.title']()} +

+

+ .{ext} {t['com.affine.attachment.preview.error.subtitle']()} +

+
+ +
+
+ ); +}; diff --git a/packages/frontend/component/src/components/attachment-viewer/index.tsx b/packages/frontend/core/src/components/attachment-viewer/index.tsx similarity index 56% rename from packages/frontend/component/src/components/attachment-viewer/index.tsx rename to packages/frontend/core/src/components/attachment-viewer/index.tsx index f2ee24c081895..8c862935aea9d 100644 --- a/packages/frontend/component/src/components/attachment-viewer/index.tsx +++ b/packages/frontend/core/src/components/attachment-viewer/index.tsx @@ -1,3 +1,4 @@ +import { ViewBody, ViewHeader } from '@affine/core/modules/workbench'; import type { AttachmentBlockModel } from '@blocksuite/blocks'; import { filesize } from 'filesize'; import { useMemo } from 'react'; @@ -11,6 +12,7 @@ export type AttachmentViewerProps = { model: AttachmentBlockModel; }; +// In Peek view export const AttachmentViewer = ({ model }: AttachmentViewerProps) => { const props = useMemo(() => { const pieces = model.name.split('.'); @@ -28,3 +30,26 @@ export const AttachmentViewer = ({ model }: AttachmentViewerProps) => { ); }; + +// In View container +export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => { + const props = useMemo(() => { + const pieces = model.name.split('.'); + const ext = pieces.pop() || ''; + const name = pieces.join('.'); + const isPDF = ext === 'pdf'; + const size = filesize(model.size); + return { model, name, ext, size, isPDF }; + }, [model]); + + return ( + <> + + + + + {props.isPDF ? : } + + + ); +}; diff --git a/packages/frontend/component/src/components/attachment-viewer/styles.css.ts b/packages/frontend/core/src/components/attachment-viewer/styles.css.ts similarity index 88% rename from packages/frontend/component/src/components/attachment-viewer/styles.css.ts rename to packages/frontend/core/src/components/attachment-viewer/styles.css.ts index 193fa68e99391..0ba25f051447c 100644 --- a/packages/frontend/component/src/components/attachment-viewer/styles.css.ts +++ b/packages/frontend/core/src/components/attachment-viewer/styles.css.ts @@ -43,21 +43,19 @@ export const titlebarName = style({ }); export const body = style({ + position: 'relative', + zIndex: 0, display: 'flex', flex: 1, - position: 'relative', selectors: { '&:before': { position: 'absolute', - // position: 'fixed', content: '', top: 0, right: 0, bottom: 0, left: 0, - // width: '100%', - // height: '100%', - // zIndex: -1, + zIndex: -1, }, '&:not(.gridding):before': { backgroundColor: cssVarV2('layer/background/secondary'), @@ -103,8 +101,22 @@ export const errorBtns = style({ marginTop: '28px', }); -export const viewerPage = style({ +export const mainItemWrapper = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', margin: '20px auto', + selectors: { + '&:first-of-type': { + marginTop: 0, + }, + '&:last-of-type': { + marginBottom: 0, + }, + }, +}); + +export const viewerPage = style({ maxWidth: 'calc(100% - 40px)', background: cssVarV2('layer/white'), boxSizing: 'border-box', @@ -116,18 +128,20 @@ export const viewerPage = style({ }); export const thumbnails = style({ + display: 'flex', + flexDirection: 'column', position: 'absolute', boxSizing: 'border-box', width: '120px', padding: '12px 0', right: '30px', bottom: '30px', + maxHeight: 'calc(100% - 60px)', borderRadius: '8px', borderWidth: '1px', borderStyle: 'solid', borderColor: cssVarV2('layer/insideBorder/border'), backgroundColor: cssVarV2('layer/background/primary'), - boxShadow: cssVarV2('shadow/overlayPanelShadow/2-color'), fontSize: '12px', fontWeight: 500, lineHeight: '20px', @@ -135,8 +149,11 @@ export const thumbnails = style({ }); export const thumbnailsPages = style({ + position: 'relative', display: 'flex', flexDirection: 'column', + maxHeight: '100%', + overflow: 'hidden', // gap: '12px', selectors: { '&.collapsed': { @@ -148,8 +165,11 @@ export const thumbnailsPages = style({ }, }); -export const thumbnailsPage = style({ +export const thumbnailsItemWrapper = style({ margin: '0px 12px 12px', +}); + +export const thumbnailsPage = style({ display: 'flex', overflow: 'clip', // width: '100%', diff --git a/packages/frontend/component/src/components/attachment-viewer/titlebar.tsx b/packages/frontend/core/src/components/attachment-viewer/titlebar.tsx similarity index 81% rename from packages/frontend/component/src/components/attachment-viewer/titlebar.tsx rename to packages/frontend/core/src/components/attachment-viewer/titlebar.tsx index 0874a8e10ac8f..7620bf559a774 100644 --- a/packages/frontend/component/src/components/attachment-viewer/titlebar.tsx +++ b/packages/frontend/core/src/components/attachment-viewer/titlebar.tsx @@ -1,6 +1,7 @@ +import { IconButton, Menu, MenuItem } from '@affine/component'; import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; import { - EditIcon, + //EditIcon, LocalDataIcon, MoreHorizontalIcon, ZoomDownIcon, @@ -9,25 +10,21 @@ import { import clsx from 'clsx'; import { useState } from 'react'; -import { IconButton } from '../../ui/button'; -import { Menu, MenuItem } from '../../ui/menu'; import * as styles from './styles.css'; -import { saveBufferToFile } from './utils'; +import { download } from './utils'; const items = [ + /* { name: 'Rename', icon: , action(_model: AttachmentBlockModel) {}, }, + */ { name: 'Download', icon: , - action(model: AttachmentBlockModel) { - const { sourceId, name } = model; - if (!sourceId) return; - saveBufferToFile(sourceId, name).catch(console.error); - }, + action: download, }, ]; @@ -53,7 +50,7 @@ export const Titlebar = ({ ext, size, zoom = 100, - isPDF = false, + //isPDF = false, }: TitlebarProps) => { const [openMenu, setOpenMenu] = useState(false); @@ -65,7 +62,10 @@ export const Titlebar = ({ .{ext}
{size}
- }> + } + onClick={() => download(model)} + > } rootOptions={{ @@ -86,7 +86,8 @@ export const Titlebar = ({ styles.titlebarChild, 'zoom', { - show: isPDF, + // show: isPDF, + show: false, }, ])} > diff --git a/packages/frontend/core/src/components/attachment-viewer/utils.ts b/packages/frontend/core/src/components/attachment-viewer/utils.ts new file mode 100644 index 0000000000000..20cfee102a0c6 --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/utils.ts @@ -0,0 +1,63 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; + +export async function getAttachmentBlob(model: AttachmentBlockModel) { + const sourceId = model.sourceId; + if (!sourceId) { + return null; + } + + const doc = model.doc; + let blob = await doc.blobSync.get(sourceId); + + if (blob) { + blob = new Blob([blob], { type: model.type }); + } + + return blob; +} + +export function download(model: AttachmentBlockModel) { + (async () => { + const blob = await getAttachmentBlob(model); + if (!blob) { + return; + } + + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = model.name; + document.body.append(a); + a.click(); + a.remove(); + URL.revokeObjectURL(blobUrl); + })().catch(console.error); +} + +export function renderItem( + scroller: HTMLElement | null, + id: number, + imageData: ImageData +) { + if (!scroller) return; + + const wrapper = scroller.querySelector(`[data-index="${id}"]`); + if (!wrapper) return; + + const item = wrapper.firstElementChild; + if (!item) return; + if (item.firstElementChild) return; + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + canvas.width = imageData.width; + canvas.height = imageData.height; + canvas.style.width = '100%'; + canvas.style.height = '100%'; + + ctx.putImageData(imageData, 0, 0); + + item.append(canvas); +} diff --git a/packages/frontend/core/src/components/attachment-viewer/viewer.tsx b/packages/frontend/core/src/components/attachment-viewer/viewer.tsx new file mode 100644 index 0000000000000..a25860fe19431 --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/viewer.tsx @@ -0,0 +1,425 @@ +import { IconButton, observeResize, Scrollable } from '@affine/component'; +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc'; +import clsx from 'clsx'; +import { debounce } from 'lodash-es'; +import type { ReactElement } from 'react'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; +import { Virtuoso } from 'react-virtuoso'; + +import * as styles from './styles.css'; +import { getAttachmentBlob, renderItem } from './utils'; +import type { DocInfo, MessageData, MessageDataType } from './worker/types'; +import { MessageOp, RenderKind, State } from './worker/types'; + +type ItemProps = VirtuosoProps; + +const Page = React.memo( + ({ + width, + height, + className, + }: { + index: number; + width: number; + height: number; + className: string; + }) => { + return ( +
+ ); + } +); + +Page.displayName = 'viewer-page'; + +const THUMBNAIL_WIDTH = 94; + +const Thumbnail = React.memo( + ({ + index, + width, + height, + className, + onSelect, + }: { + index: number; + width: number; + height: number; + className: string; + onSelect: (index: number) => void; + }) => { + return ( +
onSelect(index)} + >
+ ); + } +); + +Thumbnail.displayName = 'viewer-thumbnail'; + +const Scroller = React.forwardRef( + ({ ...props }, ref) => { + return ( + + + + + ); + } +); + +Scroller.displayName = 'viewer-scroller'; + +const Item = React.forwardRef( + ({ ...props }, ref) => { + return
; + } +); + +Item.displayName = 'viewer-item'; + +interface ViewerProps { + model: AttachmentBlockModel; +} + +export const Viewer = ({ model }: ViewerProps): ReactElement => { + const [state, setState] = useState(State.Connecting); + const [viewportInfo, setViewportInfo] = useState({ + dpi: window.devicePixelRatio, + width: 1, + height: 1, + }); + const [docInfo, setDocInfo] = useState({ + total: 0, + width: 1, + height: 1, + }); + const [cursor, setCursor] = useState(0); + const viewerRef = useRef(null); + const scrollerRef = useRef(null); + const scrollerHandleRef = useRef(null); + const workerRef = useRef(null); + + const [mainVisibleRange, setMainVisibleRange] = useState({ + startIndex: 0, + endIndex: 0, + }); + + const [collapsed, setCollapsed] = useState(true); + const thumbnailsScrollerHandleRef = useRef(null); + const thumbnailsScrollerRef = useRef(null); + const [thumbnailsVisibleRange, setThumbnailsVisibleRange] = useState({ + startIndex: 0, + endIndex: 0, + }); + + const post = useCallback( + ( + type: T, + data?: MessageDataType[T], + transfers?: Transferable[] + ) => { + const message = { type, [type]: data }; + if (transfers?.length) { + workerRef.current?.postMessage(message, transfers); + return; + } + workerRef.current?.postMessage(message); + }, + [workerRef] + ); + + const render = useCallback( + (id: number, kind: RenderKind, imageData: ImageData) => { + renderItem( + (kind === RenderKind.Page ? scrollerRef : thumbnailsScrollerRef) + .current, + id, + imageData + ); + }, + [scrollerRef, thumbnailsScrollerRef] + ); + + const onScroll = useCallback(() => { + const el = scrollerRef.current; + if (!el) return; + + const { total } = docInfo; + if (!total) return; + + const { scrollTop, scrollHeight } = el; + const itemHeight = scrollHeight / total; + const n = scrollTop / itemHeight; + const t = n / total; + const index = Math.floor(n + t); + const cursor = Math.min(index, total - 1); + + setCursor(cursor); + }, [scrollerRef, docInfo]); + + const onSelect = useCallback( + (index: number) => { + scrollerHandleRef.current?.scrollToIndex({ + index, + align: 'start', + behavior: 'smooth', + }); + }, + [scrollerHandleRef] + ); + + const updateMainVisibleRange = useMemo( + () => debounce(setMainVisibleRange, 233, { trailing: true }), + [setMainVisibleRange] + ); + + const updateThumbnailsVisibleRange = useMemo( + () => debounce(setThumbnailsVisibleRange, 233, { trailing: true }), + [setThumbnailsVisibleRange] + ); + + useEffect(() => { + const el = viewerRef.current; + if (!el) return; + + return observeResize(el, entry => { + const rect = entry.contentRect; + setViewportInfo(info => ({ + ...info, + width: rect.width, + height: rect.height, + })); + }); + }, [viewerRef]); + + useEffect(() => { + post(MessageOp.Render, { + range: mainVisibleRange, + kind: RenderKind.Page, + scale: 1 * viewportInfo.dpi, + }); + }, [viewportInfo, mainVisibleRange, post]); + + useEffect(() => { + if (collapsed) return; + + post(MessageOp.Render, { + range: thumbnailsVisibleRange, + kind: RenderKind.Thumbnail, + scale: (THUMBNAIL_WIDTH / docInfo.width) * viewportInfo.dpi, + }); + }, [collapsed, docInfo, viewportInfo, thumbnailsVisibleRange, post]); + + useLayoutEffect(() => { + workerRef.current = new Worker( + /* webpackChunkName: "pdf.worker" */ new URL( + './worker/worker.ts', + import.meta.url + ) + ); + + async function process({ data }: MessageEvent) { + const { type } = data; + + switch (type) { + case MessageOp.Init: { + setState(State.Connecting); + break; + } + + case MessageOp.Inited: { + setState(State.Connected); + break; + } + + case MessageOp.Opened: { + const info = data[type]; + setDocInfo(o => ({ ...o, ...info })); + setState(State.Opened); + break; + } + + case MessageOp.Rendered: { + const { index, kind, imageData } = data[type]; + render(index, kind, imageData); + break; + } + } + } + + workerRef.current.addEventListener('message', event => { + process(event).catch(console.error); + }); + + return () => { + workerRef.current?.terminate(); + }; + }, [model, post, render]); + + useEffect(() => { + if (!model.sourceId) return; + if (state !== State.Connected) return; + + getAttachmentBlob(model) + .then(blob => { + if (!blob) return; + return blob.arrayBuffer(); + }) + .then(buffer => { + if (!buffer) return; + setState(State.Opening); + post(MessageOp.Open, buffer, [buffer]); + }) + .catch(console.error); + }, [state, post, model, docInfo]); + + const pageContent = useCallback( + (index: number) => { + return ( + + ); + }, + [docInfo] + ); + + const thumbnailContent = useCallback( + (index: number) => { + return ( + + ); + }, + [cursor, docInfo, onSelect] + ); + + const mainComponents = useMemo(() => { + return { + Header: () =>
, + Footer: () =>
, + Item: (props: ItemProps) => ( + + ), + Scroller, + }; + }, []); + + const thumbnailsComponents = useMemo(() => { + return { + Item: (props: ItemProps) => ( + + ), + Scroller, + }; + }, []); + + const increaseViewportBy = useMemo(() => { + const size = Math.min(5, docInfo.total); + const itemHeight = docInfo.height + 20; + const height = Math.ceil(size * itemHeight); + return { + top: height, + bottom: height, + }; + }, [docInfo]); + + const mainStyle = useMemo(() => { + const { height: vh } = viewportInfo; + const { total: t, height: h, width: w } = docInfo; + const height = Math.min( + vh - 60 - 24 - 24 - 2 - 8, + t * THUMBNAIL_WIDTH * (h / w) + (t - 1) * 12 + ); + return { + height: `${height}px`, + }; + }, [docInfo, viewportInfo]); + + return ( +
+ + onScroll={onScroll} + ref={scrollerHandleRef} + scrollerRef={scroller => { + if (scrollerRef.current) return; + scrollerRef.current = scroller as HTMLElement; + }} + className={styles.virtuoso} + rangeChanged={updateMainVisibleRange} + increaseViewportBy={increaseViewportBy} + totalCount={docInfo.total} + itemContent={pageContent} + components={mainComponents} + /> +
+
+ + style={mainStyle} + ref={thumbnailsScrollerHandleRef} + scrollerRef={scroller => { + if (thumbnailsScrollerRef.current) return; + thumbnailsScrollerRef.current = scroller as HTMLElement; + }} + rangeChanged={updateThumbnailsVisibleRange} + className={styles.virtuoso} + totalCount={docInfo.total} + itemContent={thumbnailContent} + components={thumbnailsComponents} + /> +
+
+
+ + {docInfo.total > 0 ? cursor + 1 : 0} + + /{docInfo.total} +
+ : } + onClick={() => setCollapsed(!collapsed)} + /> +
+
+
+ ); +}; diff --git a/packages/frontend/core/src/components/attachment-viewer/worker/types.ts b/packages/frontend/core/src/components/attachment-viewer/worker/types.ts new file mode 100644 index 0000000000000..5a259dca391aa --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/worker/types.ts @@ -0,0 +1,69 @@ +export enum State { + Connecting = 0, + Connected, + Opening, + Opened, + Failed, +} + +export type DocInfo = { + total: number; + width: number; + height: number; +}; + +export type ViewportInfo = { + // TODO(@fundon): zoom & scale + dpi: number; + width: number; + height: number; +}; + +export enum MessageState { + Poll, + Ready, +} + +export enum MessageOp { + Init, + Inited, + Open, + Opened, + Render, + Rendered, +} + +export enum RenderKind { + Page, + Thumbnail, +} + +export type Range = { + startIndex: number; + endIndex: number; +}; + +export interface MessageDataMap { + [MessageOp.Init]: undefined; + [MessageOp.Inited]: undefined; + [MessageOp.Open]: ArrayBuffer; + [MessageOp.Opened]: DocInfo; + [MessageOp.Render]: { + range: Range; + kind: RenderKind; + scale: number; + }; + [MessageOp.Rendered]: { + index: number; + kind: RenderKind; + imageData: ImageData; + }; +} + +export type MessageDataType = { + [P in keyof T]: T[P]; +}; + +export type MessageData = { + type: T; +} & P; diff --git a/packages/frontend/core/src/components/attachment-viewer/worker/utils.ts b/packages/frontend/core/src/components/attachment-viewer/worker/utils.ts new file mode 100644 index 0000000000000..e2f107df19fee --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/worker/utils.ts @@ -0,0 +1,13 @@ +export function resizeImageBitmap( + imageData: ImageData, + options: { + resizeWidth: number; + resizeHeight: number; + } +) { + return createImageBitmap(imageData, 0, 0, imageData.width, imageData.height, { + colorSpaceConversion: 'none', + resizeQuality: 'pixelated', + ...options, + }); +} diff --git a/packages/frontend/core/src/components/attachment-viewer/worker/worker.ts b/packages/frontend/core/src/components/attachment-viewer/worker/worker.ts new file mode 100644 index 0000000000000..e0d2e73003c7d --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/worker/worker.ts @@ -0,0 +1,223 @@ +import { DebugLogger } from '@affine/debug'; +import type { Document } from '@toeverything/pdf-viewer'; +import { + createPDFium, + PageRenderingflags, + Runtime, + Viewer, +} from '@toeverything/pdf-viewer'; + +import type { MessageData, MessageDataType } from './types'; +import { MessageOp, RenderKind } from './types'; + +const logger = new DebugLogger('affine:worker:pdf'); + +let inited = false; +let viewer: Viewer | null = null; +let doc: Document | undefined = undefined; + +// Caches images with the range. +const cached = new Map(); +const docInfo = { total: 0, width: 1, height: 1 }; +const flags = PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT; +const ranges = { + [`${RenderKind.Page}:startIndex`]: 0, + [`${RenderKind.Page}:endIndex`]: 0, + [`${RenderKind.Thumbnail}:startIndex`]: 0, + [`${RenderKind.Thumbnail}:endIndex`]: 0, +}; + +function post(type: T, data?: MessageDataType[T]) { + const message = { type, [type]: data }; + self.postMessage(message); +} + +function renderToImageData(index: number, scale: number) { + if (!viewer || !doc) return; + + const page = doc.page(index); + + if (!page) return; + + const width = Math.ceil(docInfo.width * scale); + const height = Math.ceil(docInfo.height * scale); + + const bitmap = viewer.createBitmap(width, height, 0); + bitmap.fill(0, 0, width, height); + page.render(bitmap, 0, 0, width, height, 0, flags); + + const data = new Uint8ClampedArray(bitmap.toBytes()); + + bitmap.close(); + page.close(); + + return new ImageData(data, width, height); +} + +function createJob(index: number, kind: RenderKind, scale: number) { + return () => runJob(index, kind, scale); +} + +async function runJob(index: number, kind: RenderKind, scale: number) { + const key = `${kind}:${index}`; + + let imageData = cached.size > 0 ? cached.get(key) : undefined; + + if (!imageData) { + try { + imageData = renderToImageData(index, scale); + } catch (err) { + console.error(err); + } + + if (!imageData) return; + + cached.set(key, imageData); + } + + post(MessageOp.Rendered, { index, kind, imageData }); +} + +function clearOut(kind: RenderKind, startIndex: number, endIndex: number) { + const oldStartIndex = ranges[`${kind}:startIndex`]; + const oldEndIndex = ranges[`${kind}:endIndex`]; + let i = 0; + let l = 0; + + if (oldEndIndex < startIndex || oldStartIndex > endIndex) { + i = oldStartIndex; + l = oldEndIndex; + } else { + const oldMid = Math.ceil((oldStartIndex + oldEndIndex) / 2); + const mid = Math.ceil((startIndex + endIndex) / 2); + const diff = Math.abs(mid - oldMid); + + if (mid > oldMid) { + i = oldStartIndex; + l = i + diff; + } else if (mid < oldMid) { + i = endIndex; + l = i + diff; + } + } + + for (; i < l + 1; i++) { + cached.delete(`${kind}:${i}`); + } + + ranges[`${kind}:startIndex`] = startIndex; + ranges[`${kind}:endIndex`] = endIndex; +} + +async function start() { + inited = true; + + logger.debug('pdf worker pending'); + self.postMessage({ type: MessageOp.Init }); + + const pdfium = await createPDFium(); + viewer = new Viewer(new Runtime(pdfium)); + + self.postMessage({ type: MessageOp.Inited }); + logger.debug('pdf worker ready'); +} + +async function process({ data }: MessageEvent) { + if (!inited) { + await start(); + } + + if (!viewer) return; + + const { type } = data; + + switch (type) { + case MessageOp.Open: { + const buffer = data[type]; + if (!buffer) return; + + doc = viewer.open(new Uint8Array(buffer)); + + if (!doc) return; + + const page = doc.page(0); + + if (!page) return; + + Object.assign(docInfo, { + total: doc.pageCount(), + height: Math.ceil(page.height()), + width: Math.ceil(page.width()), + }); + page.close(); + post(MessageOp.Opened, docInfo); + + break; + } + + case MessageOp.Render: { + if (!doc) return; + + const { + kind, + scale, + range: { startIndex, endIndex }, + } = data[type]; + + if (startIndex > endIndex || startIndex < 0) return; + + const { total } = docInfo; + const queue: (() => Promise)[] = []; + + if (startIndex === 0) { + for (let n = startIndex; n <= endIndex; n++) { + const b = createJob(n, kind, scale); + queue.push(b); + } + } else if (endIndex + 1 === total) { + for (let n = endIndex; n >= startIndex; n--) { + const a = createJob(n, kind, scale); + queue.push(a); + } + } else { + const mid = Math.floor((startIndex + endIndex) / 2); + const m = createJob(mid, kind, scale); + queue.push(m); + + let n = 1; + const s = Math.max(endIndex - mid, mid - startIndex); + for (; n <= s; n++) { + const j = Math.min(mid + n, endIndex); + const i = Math.max(mid - (j - mid), 0); + const a = createJob(j, kind, scale); + const b = createJob(i, kind, scale); + const ab = () => Promise.all([a(), b()]); + queue.push(ab); + } + } + + queueMicrotask(() => { + (async () => { + for (const q of queue) { + await q(); + } + })() + .catch(console.error) + .finally(() => { + clearOut(kind, startIndex, endIndex); + }); + }); + + break; + } + } +} + +self.addEventListener('message', (event: MessageEvent) => { + process(event).catch(console.error); +}); + +start().catch(error => { + inited = false; + console.log(error); +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/attachment/index.css.ts b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.css.ts new file mode 100644 index 0000000000000..d678bbac3c077 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.css.ts @@ -0,0 +1,10 @@ +import { style } from '@vanilla-extract/css'; + +export const attachmentSkeletonStyle = style({ + margin: '20px', +}); + +export const attachmentSkeletonItemStyle = style({ + marginTop: '20px', + marginBottom: '20px', +}); 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 4f95a5abca858..cb3e75562da0c 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx @@ -1,4 +1,4 @@ -import { AttachmentViewer } from '@affine/component/attachment-viewer'; +import { Skeleton } from '@affine/component'; import { type AttachmentBlockModel, matchFlavours, @@ -7,81 +7,151 @@ import { type Doc, DocsService, FrameworkScope, + useLiveData, useService, } from '@toeverything/infra'; -import { type ReactElement, useEffect, useLayoutEffect, useState } from 'react'; +import { type ReactElement, useLayoutEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { - ViewBody, - ViewHeader, - ViewIcon, - ViewTitle, -} from '../../../../modules/workbench'; +import { AttachmentViewerView } from '../../../../components/attachment-viewer'; +import { ViewIcon, ViewTitle } from '../../../../modules/workbench'; import { PageNotFound } from '../../404'; +import * as styles from './index.css'; + +enum State { + Loading, + NotFound, + Found, +} -const useLoadAttachment = (pageId?: string, attachmentId?: string) => { +type AttachmentPageProps = { + pageId: string; + attachmentId: string; +}; + +const useLoadAttachment = (pageId: string, attachmentId: string) => { const docsService = useService(DocsService); + const docRecord = useLiveData(docsService.list.doc$(pageId)); + const [doc, setDoc] = useState(null); + const [state, setState] = useState(State.Loading); const [model, setModel] = useState(null); useLayoutEffect(() => { - if (!pageId) return; + if (!docRecord) { + setState(State.NotFound); + return; + } const { doc, release } = docsService.open(pageId); + setDoc(doc); - if (!doc.blockSuiteDoc.ready) { - doc.blockSuiteDoc.load(); + const disposables: Disposable[] = []; + let notFound = true; + + if (doc.blockSuiteDoc.ready) { + const block = doc.blockSuiteDoc.getBlock(attachmentId); + if (block) { + notFound = false; + setModel(block.model as AttachmentBlockModel); + setState(State.Found); + } } - setDoc(doc); + if (notFound) { + doc.blockSuiteDoc.load(); - return () => { - release(); - }; - }, [docsService, pageId]); + const tid = setTimeout(() => setState(State.NotFound), 5 * 10000); // 50s + const disposable = doc.blockSuiteDoc.slots.blockUpdated + .filter(({ type, id }) => type === 'add' && id === attachmentId) + // @ts-expect-error allow + .filter(({ model }) => matchFlavours(model, ['affine:attachment'])) + // @ts-expect-error allow + .once(({ model }) => { + clearTimeout(tid); + setModel(model as AttachmentBlockModel); + setState(State.Found); + }); - useEffect(() => { - if (!doc) return; - if (!attachmentId) return; + disposables.push({ + [Symbol.dispose]: () => clearTimeout(tid), + }); + disposables.push({ + [Symbol.dispose]: () => disposable.dispose(), + }); + } - const disposable = doc.blockSuiteDoc.slots.blockUpdated - .filter(({ type, id }) => type === 'add' && id === attachmentId) - // @ts-expect-error allow - .filter(({ model }) => matchFlavours(model, ['affine:attachment'])) - // @ts-expect-error allow - .once(({ model }) => setModel(model as AttachmentBlockModel)); + disposables.push({ + [Symbol.dispose]: () => release(), + }); return () => { - disposable.dispose(); + disposables.forEach(d => d[Symbol.dispose]()); }; - }, [doc, attachmentId]); + }, [docRecord, docsService, pageId, attachmentId]); - return { doc, model }; + return { state, doc, model }; }; -export const AttachmentPage = (): ReactElement => { - const params = useParams(); - const { doc, model } = useLoadAttachment(params.pageId, params.attachmentId); +export const AttachmentPage = ({ + pageId, + attachmentId, +}: AttachmentPageProps): ReactElement => { + const { state, doc, model } = useLoadAttachment(pageId, attachmentId); - if (!doc || !model) { + if (state === State.NotFound) { return ; } + if (state === State.Found && doc && model) { + return ( + + + + + + ); + } + return ( - <> - - - - - - - - - +
+ + + + + +
); }; export const Component = () => { - return ; + const { pageId, attachmentId } = useParams(); + + if (!pageId || !attachmentId) { + return ; + } + + return ; }; 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 1ea2cb240ab21..1a4992552e678 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 @@ -1,8 +1,8 @@ -import { AttachmentViewer } from '@affine/component/attachment-viewer'; import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; import { type PropsWithChildren, Suspense, useMemo } from 'react'; import { ErrorBoundary, type FallbackProps } from 'react-error-boundary'; +import { AttachmentViewer } from '../../../../components/attachment-viewer'; import { useEditor } from '../utils'; const ErrorLogger = (props: FallbackProps) => { diff --git a/packages/frontend/core/src/modules/peek-view/view/peek-view-controls.tsx b/packages/frontend/core/src/modules/peek-view/view/peek-view-controls.tsx index 01fbdfdc52548..5bf02a34d4e5a 100644 --- a/packages/frontend/core/src/modules/peek-view/view/peek-view-controls.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/peek-view-controls.tsx @@ -152,3 +152,68 @@ export const DocPeekViewControls = ({
); }; + +export const AttachmentPeekViewControls = ({ + docRef, + className, + ...rest +}: DocPeekViewControlsProps) => { + const peekView = useService(PeekViewService).peekView; + const workbench = useService(WorkbenchService).workbench; + const t = useI18n(); + const controls = useMemo(() => { + return [ + { + icon: , + nameKey: 'close', + name: t['com.affine.peek-view-controls.close'](), + onClick: () => peekView.close(), + }, + { + icon: , + name: t['com.affine.peek-view-controls.open-attachment'](), + nameKey: 'open', + onClick: () => { + const { docId, blockIds: [blockId] = [] } = docRef; + if (docId && blockId) { + workbench.openAttachment(docId, blockId); + } + peekView.close('none'); + }, + }, + { + icon: , + nameKey: 'new-tab', + name: t['com.affine.peek-view-controls.open-attachment-in-new-tab'](), + onClick: () => { + const { docId, blockIds: [blockId] = [] } = docRef; + if (docId && blockId) { + workbench.openAttachment(docId, blockId, { at: 'new-tab' }); + } + peekView.close('none'); + }, + }, + BUILD_CONFIG.isElectron && { + icon: , + nameKey: 'split-view', + name: t[ + 'com.affine.peek-view-controls.open-attachment-in-split-view' + ](), + onClick: () => { + const { docId, blockIds: [blockId] = [] } = docRef; + if (docId && blockId) { + workbench.openAttachment(docId, blockId, { at: 'beside' }); + } + peekView.close('none'); + }, + }, + ].filter((opt): opt is ControlButtonProps => Boolean(opt)); + }, [t, peekView, workbench, docRef]); + return ( +
+ {controls.map(option => ( + + ))} +
+ ); +}; diff --git a/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx index ed9faa91e46c5..ea039b04fabc5 100644 --- a/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx @@ -14,6 +14,7 @@ import { type PeekViewModalContainerProps, } from './modal-container'; import { + AttachmentPeekViewControls, DefaultPeekViewControls, DocPeekViewControls, } from './peek-view-controls'; @@ -57,7 +58,9 @@ const renderControls = ({ info }: ActivePeekView) => { return ; } - // TODO(@fundon): attachment's controls + if (info.type === 'attachment') { + return ; + } if (info.type === 'image') { return null; // image controls are rendered in the image preview diff --git a/packages/frontend/core/src/modules/workbench/entities/workbench.ts b/packages/frontend/core/src/modules/workbench/entities/workbench.ts index bd1fe328cea15..b842f4937c5d5 100644 --- a/packages/frontend/core/src/modules/workbench/entities/workbench.ts +++ b/packages/frontend/core/src/modules/workbench/entities/workbench.ts @@ -141,6 +141,14 @@ export class Workbench extends Entity { this.open(`/${docId}${query}`, options); } + openAttachment( + docId: string, + blockId: string, + options?: WorkbenchOpenOptions + ) { + this.open(`/${docId}/${blockId}`, options); + } + openCollections(options?: WorkbenchOpenOptions) { this.open('/collection', options); } diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 9c1fe9979fb93..00ce3379bb308 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -942,6 +942,9 @@ "com.affine.peek-view-controls.open-doc-in-new-tab": "Open in new tab", "com.affine.peek-view-controls.open-doc-in-split-view": "Open in split view", "com.affine.peek-view-controls.open-info": "Open doc info", + "com.affine.peek-view-controls.open-attachment": "Open this attachment", + "com.affine.peek-view-controls.open-attachment-in-new-tab": "Open in new tab", + "com.affine.peek-view-controls.open-attachment-in-split-view": "Open in split view", "com.affine.quicksearch.group.creation": "New", "com.affine.quicksearch.group.searchfor": "Search for \"{{query}}\"", "com.affine.resetSyncStatus.button": "Reset sync", @@ -1388,5 +1391,7 @@ "com.affine.m.selector.remove-warning.confirm": "Do not ask again", "com.affine.m.selector.remove-warning.cancel": "Cancel", "com.affine.m.selector.remove-warning.where-tag": "tag", - "com.affine.m.selector.remove-warning.where-folder": "folder" + "com.affine.m.selector.remove-warning.where-folder": "folder", + "com.affine.attachment.preview.error.title": "Unable to preview this file", + "com.affine.attachment.preview.error.subtitle": "file type not supported." } diff --git a/tests/affine-local/e2e/attachment-preview.spec.ts b/tests/affine-local/e2e/attachment-preview.spec.ts new file mode 100644 index 0000000000000..092d393912de0 --- /dev/null +++ b/tests/affine-local/e2e/attachment-preview.spec.ts @@ -0,0 +1,118 @@ +/* eslint-disable unicorn/prefer-dom-node-dataset */ +import path from 'node:path'; + +import { test } from '@affine-test/kit/playwright'; +import { openHomePage } from '@affine-test/kit/utils/load-page'; +import { + clickNewPageButton, + getBlockSuiteEditorTitle, + waitForEditorLoad, +} from '@affine-test/kit/utils/page-logic'; +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +async function clickPeekViewControl(page: Page, n = 0) { + await page.getByTestId('peek-view-control').nth(n).click(); + await page.waitForTimeout(500); +} + +async function insertAttachment(page: Page, filepath: string) { + await page.evaluate(() => { + // Force fallback to input[type=file] in tests + // See https://github.com/microsoft/playwright/issues/8850 + // @ts-expect-error allow + window.showOpenFilePicker = undefined; + }); + + const fileChooser = page.waitForEvent('filechooser'); + + // open slash menu + await page.keyboard.type('/attachment', { delay: 50 }); + await page.keyboard.press('Enter'); + + await (await fileChooser).setFiles(filepath); +} + +test('attachment preview should be shown', async ({ page }) => { + await openHomePage(page); + await waitForEditorLoad(page); + await clickNewPageButton(page); + const title = getBlockSuiteEditorTitle(page); + await title.click(); + await page.keyboard.press('Enter'); + + await insertAttachment( + page, + path.join(__dirname, '../../fixtures/lorem-ipsum.pdf') + ); + + await page.locator('affine-attachment').first().dblclick(); + + const attachmentViewer = page.getByTestId('attachment-viewer'); + await expect(attachmentViewer).toBeVisible(); + + await page.waitForTimeout(500); + + const pageCount = attachmentViewer.locator('.page-count'); + expect(await pageCount.textContent()).toBe('1'); + const pageTotal = attachmentViewer.locator('.page-total'); + expect(await pageTotal.textContent()).toBe('3'); + + const thumbnails = attachmentViewer.locator('.thumbnails'); + await thumbnails.locator('button').click(); + + await page.waitForTimeout(500); + + expect( + await thumbnails + .getByTestId('virtuoso-item-list') + .locator('[data-item-index]') + .count() + ).toBe(3); + + await clickPeekViewControl(page); + await expect(attachmentViewer).not.toBeVisible(); +}); + +test('attachment preview can be expanded', async ({ page }) => { + await openHomePage(page); + await waitForEditorLoad(page); + await clickNewPageButton(page); + const title = getBlockSuiteEditorTitle(page); + await title.click(); + await page.keyboard.press('Enter'); + + await insertAttachment( + page, + path.join(__dirname, '../../fixtures/lorem-ipsum.pdf') + ); + + await page.locator('affine-attachment').first().dblclick(); + + const attachmentViewer = page.getByTestId('attachment-viewer'); + + await page.waitForTimeout(500); + + await expect(attachmentViewer).toBeVisible(); + + await clickPeekViewControl(page, 1); + + await page.waitForTimeout(500); + + const pageCount = attachmentViewer.locator('.page-count'); + expect(await pageCount.textContent()).toBe('1'); + const pageTotal = attachmentViewer.locator('.page-total'); + expect(await pageTotal.textContent()).toBe('3'); + + const thumbnails = attachmentViewer.locator('.thumbnails'); + await thumbnails.locator('button').click(); + + await page.waitForTimeout(500); + + expect( + await thumbnails + .getByTestId('virtuoso-item-list') + .locator('[data-item-index]') + .count() + ).toBe(3); +}); diff --git a/tests/fixtures/lorem-ipsum.pdf b/tests/fixtures/lorem-ipsum.pdf new file mode 100644 index 0000000000000000000000000000000000000000..784a17c5b5a4a440a439edf56d18953e4b60bca8 GIT binary patch literal 23286 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