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 0000000000000..784a17c5b5a4a Binary files /dev/null and b/tests/fixtures/lorem-ipsum.pdf differ diff --git a/yarn.lock b/yarn.lock index c65b7a1ab166a..2fe3ed773c6ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -361,8 +361,6 @@ __metadata: check-password-strength: "npm:^2.0.10" clsx: "npm:^2.1.0" dayjs: "npm:^1.11.10" - file-type: "npm:^19.1.0" - filesize: "npm:^10.1.6" jotai: "npm:^2.8.0" lit: "npm:^3.1.2" lodash-es: "npm:^4.17.21" @@ -375,7 +373,6 @@ __metadata: react-paginate: "npm:^8.2.0" react-router-dom: "npm:^6.22.3" react-transition-state: "npm:^2.1.1" - react-virtuoso: "npm:^4.7.8" sonner: "npm:^1.4.41" storybook: "npm:^8.2.9" swr: "npm:^2.2.5" @@ -434,6 +431,7 @@ __metadata: "@radix-ui/react-toolbar": "npm:^1.0.4" "@sentry/react": "npm:^8.0.0" "@testing-library/react": "npm:^16.0.0" + "@toeverything/pdf-viewer": "npm:^0.1.0" "@toeverything/theme": "npm:^1.0.16" "@types/animejs": "npm:^3.1.12" "@types/bytes": "npm:^3.1.4" @@ -449,6 +447,7 @@ __metadata: dayjs: "npm:^1.11.10" fake-indexeddb: "npm:^6.0.0" file-type: "npm:^19.1.0" + filesize: "npm:^10.1.6" foxact: "npm:^0.2.33" fuse.js: "npm:^7.0.0" graphemer: "npm:^1.4.0"