diff --git a/packages/frontend/apps/electron/src/main/windows-manager/tab-views-meta-schema.ts b/packages/frontend/apps/electron/src/main/windows-manager/tab-views-meta-schema.ts
index 18a77dba12438..4fca530a475cf 100644
--- a/packages/frontend/apps/electron/src/main/windows-manager/tab-views-meta-schema.ts
+++ b/packages/frontend/apps/electron/src/main/windows-manager/tab-views-meta-schema.ts
@@ -9,6 +9,8 @@ export const workbenchViewIconNameSchema = z.enum([
'page',
'edgeless',
'journal',
+ 'attachment',
+ 'pdf',
]);
export const workbenchViewMetaSchema = z.object({
diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json
index b62eaee7d21c1..ea7db8e8c1086 100644
--- a/packages/frontend/component/package.json
+++ b/packages/frontend/component/package.json
@@ -14,7 +14,7 @@
},
"peerDependencies": {
"@blocksuite/affine": "*",
- "@blocksuite/icons": "2.1.67"
+ "@blocksuite/icons": "*"
},
"dependencies": {
"@affine/cli": "workspace:*",
@@ -24,6 +24,7 @@
"@affine/i18n": "workspace:*",
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
+ "@blocksuite/icons": "2.1.69",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@radix-ui/react-avatar": "^1.0.4",
@@ -40,11 +41,14 @@
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@radix-ui/react-visually-hidden": "^1.1.0",
+ "@toeverything/pdf-viewer": "^0.1.0",
"@toeverything/theme": "^1.0.16",
"@vanilla-extract/dynamic": "^2.1.0",
"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",
@@ -57,6 +61,7 @@
"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
new file mode 100644
index 0000000000000..2a9bf9550ce7d
--- /dev/null
+++ b/packages/frontend/component/src/components/attachment-viewer/error.tsx
@@ -0,0 +1,28 @@
+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/index.tsx b/packages/frontend/component/src/components/attachment-viewer/index.tsx
new file mode 100644
index 0000000000000..f2ee24c081895
--- /dev/null
+++ b/packages/frontend/component/src/components/attachment-viewer/index.tsx
@@ -0,0 +1,30 @@
+import type { AttachmentBlockModel } from '@blocksuite/blocks';
+import { filesize } from 'filesize';
+import { useMemo } from 'react';
+
+import { Error } from './error';
+import * as styles from './styles.css';
+import { Titlebar } from './titlebar';
+import { Viewer } from './viewer';
+
+export type AttachmentViewerProps = {
+ model: AttachmentBlockModel;
+};
+
+export const AttachmentViewer = ({ 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/component/src/components/attachment-viewer/styles.css.ts
new file mode 100644
index 0000000000000..193fa68e99391
--- /dev/null
+++ b/packages/frontend/component/src/components/attachment-viewer/styles.css.ts
@@ -0,0 +1,172 @@
+import { cssVarV2 } from '@toeverything/theme/v2';
+import { style } from '@vanilla-extract/css';
+
+export const viewerContainer = style({
+ position: 'relative',
+ display: 'flex',
+ flexDirection: 'column',
+ width: '100%',
+ height: '100%',
+});
+
+export const titlebar = style({
+ display: 'flex',
+ justifyContent: 'space-between',
+ height: '52px',
+ padding: '10px 8px',
+ background: cssVarV2('layer/background/primary'),
+ fontSize: '12px',
+ fontWeight: 400,
+ color: cssVarV2('text/secondary'),
+ borderTopWidth: '0.5px',
+ borderTopStyle: 'solid',
+ borderTopColor: cssVarV2('layer/insideBorder/border'),
+});
+
+export const titlebarChild = style({
+ selectors: {
+ [`${titlebar} > &`]: {
+ display: 'flex',
+ gap: '12px',
+ alignItems: 'center',
+ paddingLeft: '12px',
+ paddingRight: '12px',
+ },
+ '&.zoom:not(.show)': {
+ display: 'none',
+ },
+ },
+});
+
+export const titlebarName = style({
+ display: 'flex',
+});
+
+export const body = style({
+ 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,
+ },
+ '&:not(.gridding):before': {
+ backgroundColor: cssVarV2('layer/background/secondary'),
+ },
+ '&.gridding:before': {
+ opacity: 0.25,
+ backgroundSize: '20px 20px',
+ backgroundImage: `linear-gradient(${cssVarV2('button/grabber/default')} 1px, transparent 1px), linear-gradient(to right, ${cssVarV2('button/grabber/default')} 1px, transparent 1px)`,
+ },
+ },
+});
+
+export const virtuoso = style({
+ width: '100%',
+});
+
+export const error = style({
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '4px',
+});
+
+export const errorTitle = style({
+ fontSize: '15px',
+ fontWeight: 500,
+ lineHeight: '24px',
+ color: cssVarV2('text/primary'),
+ marginTop: '12px',
+});
+
+export const errorMessage = style({
+ fontSize: '12px',
+ fontWeight: 500,
+ lineHeight: '20px',
+ color: cssVarV2('text/tertiary'),
+});
+
+export const errorBtns = style({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '10px',
+ marginTop: '28px',
+});
+
+export const viewerPage = style({
+ margin: '20px auto',
+ maxWidth: 'calc(100% - 40px)',
+ background: cssVarV2('layer/white'),
+ boxSizing: 'border-box',
+ borderWidth: '1px',
+ borderStyle: 'solid',
+ borderColor: cssVarV2('layer/insideBorder/border'),
+ boxShadow:
+ '0px 4px 20px 0px var(--transparent-black-200, rgba(0, 0, 0, 0.10))',
+});
+
+export const thumbnails = style({
+ position: 'absolute',
+ boxSizing: 'border-box',
+ width: '120px',
+ padding: '12px 0',
+ right: '30px',
+ bottom: '30px',
+ 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',
+ color: cssVarV2('text/secondary'),
+});
+
+export const thumbnailsPages = style({
+ display: 'flex',
+ flexDirection: 'column',
+ // gap: '12px',
+ selectors: {
+ '&.collapsed': {
+ display: 'none',
+ },
+ '&:not(.collapsed)': {
+ marginBottom: '8px',
+ },
+ },
+});
+
+export const thumbnailsPage = style({
+ margin: '0px 12px 12px',
+ display: 'flex',
+ overflow: 'clip',
+ // width: '100%',
+ borderRadius: '4px',
+ borderWidth: '1px',
+ borderStyle: 'solid',
+ borderColor: cssVarV2('layer/insideBorder/border'),
+ selectors: {
+ '&.selected': {
+ borderColor: '#29A3FA',
+ },
+ },
+});
+
+export const thumbnailsIndicator = style({
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ padding: '0 12px',
+});
diff --git a/packages/frontend/component/src/components/attachment-viewer/titlebar.tsx b/packages/frontend/component/src/components/attachment-viewer/titlebar.tsx
new file mode 100644
index 0000000000000..0874a8e10ac8f
--- /dev/null
+++ b/packages/frontend/component/src/components/attachment-viewer/titlebar.tsx
@@ -0,0 +1,99 @@
+import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
+import {
+ EditIcon,
+ LocalDataIcon,
+ MoreHorizontalIcon,
+ ZoomDownIcon,
+ ZoomUpIcon,
+} from '@blocksuite/icons/rc';
+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';
+
+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);
+ },
+ },
+];
+
+export const MenuItems = ({ model }: { model: AttachmentBlockModel }) =>
+ items.map(({ name, icon, action }) => (
+
+ ));
+
+export interface TitlebarProps {
+ model: AttachmentBlockModel;
+ name: string;
+ ext: string;
+ size: string;
+ isPDF: boolean;
+ zoom?: number;
+}
+
+export const Titlebar = ({
+ model,
+ name,
+ ext,
+ size,
+ zoom = 100,
+ isPDF = false,
+}: TitlebarProps) => {
+ const [openMenu, setOpenMenu] = useState(false);
+
+ return (
+
+
+
+
{size}
+
}>
+
}
+ rootOptions={{
+ open: openMenu,
+ onOpenChange: setOpenMenu,
+ }}
+ contentOptions={{
+ side: 'bottom',
+ align: 'center',
+ avoidCollisions: false,
+ }}
+ >
+
}>
+
+
+
+
+ );
+};
diff --git a/packages/frontend/component/src/components/attachment-viewer/utils.ts b/packages/frontend/component/src/components/attachment-viewer/utils.ts
new file mode 100644
index 0000000000000..2238b3eb09857
--- /dev/null
+++ b/packages/frontend/component/src/components/attachment-viewer/utils.ts
@@ -0,0 +1,40 @@
+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
new file mode 100644
index 0000000000000..78b19d60b0210
--- /dev/null
+++ b/packages/frontend/component/src/components/attachment-viewer/viewer.tsx
@@ -0,0 +1,417 @@
+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
new file mode 100644
index 0000000000000..4ec6b2a064931
--- /dev/null
+++ b/packages/frontend/component/src/components/attachment-viewer/worker/types.ts
@@ -0,0 +1,33 @@
+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
new file mode 100644
index 0000000000000..d6423a6083148
--- /dev/null
+++ b/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts
@@ -0,0 +1,164 @@
+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/src/desktop/pages/workspace/attachment/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx
new file mode 100644
index 0000000000000..4f95a5abca858
--- /dev/null
+++ b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx
@@ -0,0 +1,87 @@
+import { AttachmentViewer } from '@affine/component/attachment-viewer';
+import {
+ type AttachmentBlockModel,
+ matchFlavours,
+} from '@blocksuite/affine/blocks';
+import {
+ type Doc,
+ DocsService,
+ FrameworkScope,
+ useService,
+} from '@toeverything/infra';
+import { type ReactElement, useEffect, useLayoutEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+
+import {
+ ViewBody,
+ ViewHeader,
+ ViewIcon,
+ ViewTitle,
+} from '../../../../modules/workbench';
+import { PageNotFound } from '../../404';
+
+const useLoadAttachment = (pageId?: string, attachmentId?: string) => {
+ const docsService = useService(DocsService);
+ const [doc, setDoc] = useState(null);
+ const [model, setModel] = useState(null);
+
+ useLayoutEffect(() => {
+ if (!pageId) return;
+
+ const { doc, release } = docsService.open(pageId);
+
+ if (!doc.blockSuiteDoc.ready) {
+ doc.blockSuiteDoc.load();
+ }
+
+ setDoc(doc);
+
+ return () => {
+ release();
+ };
+ }, [docsService, pageId]);
+
+ useEffect(() => {
+ if (!doc) return;
+ if (!attachmentId) return;
+
+ 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));
+
+ return () => {
+ disposable.dispose();
+ };
+ }, [doc, attachmentId]);
+
+ return { doc, model };
+};
+
+export const AttachmentPage = (): ReactElement => {
+ const params = useParams();
+ const { doc, model } = useLoadAttachment(params.pageId, params.attachmentId);
+
+ if (!doc || !model) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export const Component = () => {
+ return ;
+};
diff --git a/packages/frontend/core/src/desktop/workbench-router.ts b/packages/frontend/core/src/desktop/workbench-router.ts
index 0e810fa39b2d8..189ea393dd844 100644
--- a/packages/frontend/core/src/desktop/workbench-router.ts
+++ b/packages/frontend/core/src/desktop/workbench-router.ts
@@ -29,6 +29,10 @@ export const workbenchRoutes = [
path: '/:pageId',
lazy: () => import('./pages/workspace/detail-page/detail-page'),
},
+ {
+ path: '/:pageId/:attachmentId',
+ lazy: () => import('./pages/workspace/attachment/index'),
+ },
{
path: '*',
lazy: () => import('./pages/404'),
diff --git a/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts b/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts
index 9dfe4935d6280..e6f7a5b0635bb 100644
--- a/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts
+++ b/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts
@@ -1,5 +1,6 @@
import type { BlockComponent, EditorHost } from '@blocksuite/affine/block-std';
import type {
+ AttachmentBlockModel,
DocMode,
EmbedLinkedDocModel,
EmbedSyncedDocModel,
@@ -51,6 +52,11 @@ export type ImagePeekViewInfo = {
docRef: DocReferenceInfo;
};
+export type AttachmentPeekViewInfo = {
+ type: 'attachment';
+ docRef: DocReferenceInfo;
+};
+
export type AIChatBlockPeekViewInfo = {
type: 'ai-chat-block';
docRef: DocReferenceInfo;
@@ -68,6 +74,7 @@ export type ActivePeekView = {
info:
| DocPeekViewInfo
| ImagePeekViewInfo
+ | AttachmentPeekViewInfo
| CustomTemplatePeekViewInfo
| AIChatBlockPeekViewInfo;
};
@@ -90,6 +97,12 @@ const isImageBlockModel = (
return blockModel.flavour === 'affine:image';
};
+const isAttachmentBlockModel = (
+ blockModel: BlockModel
+): blockModel is AttachmentBlockModel => {
+ return blockModel.flavour === 'affine:attachment';
+};
+
const isSurfaceRefModel = (
blockModel: BlockModel
): blockModel is SurfaceRefBlockModel => {
@@ -162,6 +175,14 @@ function resolvePeekInfoFromPeekTarget(
},
};
}
+ } else if (isAttachmentBlockModel(blockModel)) {
+ return {
+ type: 'attachment',
+ docRef: {
+ docId: blockModel.doc.id,
+ blockIds: [blockModel.id],
+ },
+ };
} else if (isImageBlockModel(blockModel)) {
return {
type: 'image',
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
new file mode 100644
index 0000000000000..1ea2cb240ab21
--- /dev/null
+++ b/packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx
@@ -0,0 +1,43 @@
+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 { useEditor } from '../utils';
+
+const ErrorLogger = (props: FallbackProps) => {
+ console.error('image preview modal error', props.error);
+ return null;
+};
+
+export const AttachmentPreviewErrorBoundary = (props: PropsWithChildren) => {
+ return (
+ {props.children}
+ );
+};
+
+export type AttachmentPreviewModalProps = {
+ docId: string;
+ blockId: string;
+};
+
+export const AttachmentPreviewPeekView = ({
+ docId,
+ blockId,
+}: AttachmentPreviewModalProps) => {
+ const { doc } = useEditor(docId);
+ const blocksuiteDoc = doc?.blockSuiteDoc;
+ const model = useMemo(() => {
+ const block = blocksuiteDoc?.getBlock(blockId);
+ if (block?.model) {
+ return block.model as AttachmentBlockModel;
+ }
+ return null;
+ }, [blockId, blocksuiteDoc]);
+
+ return (
+
+ {model ? : null}
+
+ );
+};
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 f2fe712a6090c..ed9faa91e46c5 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
@@ -6,6 +6,7 @@ import { useEffect, useMemo } from 'react';
import type { ActivePeekView } from '../entities/peek-view';
import { PeekViewService } from '../services/peek-view';
+import { AttachmentPreviewPeekView } from './attachment-preview';
import { DocPeekPreview } from './doc-preview';
import { ImagePreviewPeekView } from './image-preview';
import {
@@ -25,6 +26,15 @@ function renderPeekView({ info }: ActivePeekView) {
return ;
}
+ if (info.type === 'attachment' && info.docRef.blockIds?.[0]) {
+ return (
+
+ );
+ }
+
if (info.type === 'image' && info.docRef.blockIds?.[0]) {
return (
{
return ;
}
+ // TODO(@fundon): attachment's controls
+
if (info.type === 'image') {
return null; // image controls are rendered in the image preview
}
diff --git a/packages/frontend/core/src/modules/workbench/constants.tsx b/packages/frontend/core/src/modules/workbench/constants.tsx
index 719d816fd4701..7439354f5e8a8 100644
--- a/packages/frontend/core/src/modules/workbench/constants.tsx
+++ b/packages/frontend/core/src/modules/workbench/constants.tsx
@@ -1,7 +1,9 @@
import {
AllDocsIcon,
+ AttachmentIcon,
DeleteIcon,
EdgelessIcon,
+ ExportToPdfIcon,
PageIcon,
TagIcon,
TodayIcon,
@@ -18,6 +20,8 @@ export const iconNameToIcon = {
journal: ,
tag: ,
trash: ,
+ attachment: ,
+ pdf: ,
} satisfies Record;
export type ViewIconName = keyof typeof iconNameToIcon;
diff --git a/tools/cli/src/webpack/config.ts b/tools/cli/src/webpack/config.ts
index e1bd39b768ea0..012d6b7cafd62 100644
--- a/tools/cli/src/webpack/config.ts
+++ b/tools/cli/src/webpack/config.ts
@@ -111,8 +111,8 @@ export const createConfiguration: (
: 'js/[name].js',
// In some cases webpack will emit files starts with "_" which is reserved in web extension.
chunkFilename: pathData =>
- pathData.chunk?.name === 'worker'
- ? 'js/worker.[contenthash:8].js'
+ pathData.chunk?.name?.endsWith?.('worker')
+ ? 'js/[name].[contenthash:8].js'
: buildFlags.mode === 'production'
? 'js/chunk.[name].[contenthash:8].js'
: 'js/chunk.[name].js',
diff --git a/yarn.lock b/yarn.lock
index f663554b6a165..c65b7a1ab166a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -352,6 +352,7 @@ __metadata:
"@storybook/react": "npm:^8.2.9"
"@storybook/react-vite": "npm:^8.2.9"
"@testing-library/react": "npm:^16.0.0"
+ "@toeverything/pdf-viewer": "npm:^0.1.0"
"@toeverything/theme": "npm:^1.0.16"
"@types/react": "npm:^18.2.75"
"@types/react-dom": "npm:^18.2.24"
@@ -360,6 +361,8 @@ __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"
@@ -372,6 +375,7 @@ __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"
@@ -382,7 +386,7 @@ __metadata:
zod: "npm:^3.22.4"
peerDependencies:
"@blocksuite/affine": "*"
- "@blocksuite/icons": 2.1.67
+ "@blocksuite/icons": "*"
languageName: unknown
linkType: soft
@@ -13420,6 +13424,30 @@ __metadata:
languageName: unknown
linkType: soft
+"@toeverything/pdf-viewer-types@npm:0.1.0":
+ version: 0.1.0
+ resolution: "@toeverything/pdf-viewer-types@npm:0.1.0"
+ checksum: 10/aed8a2e9375e121a663e96147d6a726d4dd7b4917cfa35bac5c422c820125b2bc3e129281cad8c3983d1da12191ba4c6e0e45231123fce5d89091ce18dbc9560
+ languageName: node
+ linkType: hard
+
+"@toeverything/pdf-viewer@npm:^0.1.0":
+ version: 0.1.0
+ resolution: "@toeverything/pdf-viewer@npm:0.1.0"
+ dependencies:
+ "@toeverything/pdf-viewer-types": "npm:0.1.0"
+ "@toeverything/pdfium": "npm:0.1.0"
+ checksum: 10/75e1df49ecce97e667ac9ec1a630ba4492f1b712d4380a9c999f05535e305e4fd2a069d004449477520ea308ec5928f02b51268659f75788ae4715a7b6e14da7
+ languageName: node
+ linkType: hard
+
+"@toeverything/pdfium@npm:0.1.0":
+ version: 0.1.0
+ resolution: "@toeverything/pdfium@npm:0.1.0"
+ checksum: 10/5fee0f76608d27d747b9266096cdd153027e8f3e8774571524f86afd88757833a61472400df5dcf2a6ea2e16bcfe9864b090ef7e483c260998ba3fad418a9ba8
+ languageName: node
+ linkType: hard
+
"@toeverything/theme@npm:^1.0.15, @toeverything/theme@npm:^1.0.16":
version: 1.0.16
resolution: "@toeverything/theme@npm:1.0.16"
@@ -20984,7 +21012,7 @@ __metadata:
languageName: node
linkType: hard
-"filesize@npm:^10.0.12":
+"filesize@npm:^10.0.12, filesize@npm:^10.1.6":
version: 10.1.6
resolution: "filesize@npm:10.1.6"
checksum: 10/e800837c4fc02303f1944d5a4c7b706df1c5cd95d745181852604fb00a1c2d55d2d3921252722bd2f0c86b59c94edaba23fa224776bbf977455d4034e7be1f45