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.
-
- }>
- Download
-
-
-
-
- );
-};
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']()}
+
+
+ }
+ onClick={() => download(model)}
+ >
+ Download
+
+
+
+ );
+};
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"