diff --git a/packages/frontend/component/src/components/attachment-viewer/styles.css.ts b/packages/frontend/component/src/components/attachment-viewer/styles.css.ts
index 193fa68e99391..83faa110b7529 100644
--- a/packages/frontend/component/src/components/attachment-viewer/styles.css.ts
+++ b/packages/frontend/component/src/components/attachment-viewer/styles.css.ts
@@ -103,8 +103,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,12 +130,15 @@ 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',
@@ -135,8 +152,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 +168,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/component/src/components/attachment-viewer/titlebar.tsx
index 0874a8e10ac8f..0acc1fee61ebb 100644
--- a/packages/frontend/component/src/components/attachment-viewer/titlebar.tsx
+++ b/packages/frontend/component/src/components/attachment-viewer/titlebar.tsx
@@ -1,6 +1,6 @@
import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
import {
- EditIcon,
+ //EditIcon,
LocalDataIcon,
MoreHorizontalIcon,
ZoomDownIcon,
@@ -12,22 +12,20 @@ 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 +51,7 @@ export const Titlebar = ({
ext,
size,
zoom = 100,
- isPDF = false,
+ //isPDF = false,
}: TitlebarProps) => {
const [openMenu, setOpenMenu] = useState(false);
@@ -65,7 +63,10 @@ export const Titlebar = ({
.{ext}
{size}
- }>
+ }
+ onClick={() => download(model)}
+ >
}
rootOptions={{
@@ -86,7 +87,8 @@ export const Titlebar = ({
styles.titlebarChild,
'zoom',
{
- show: isPDF,
+ // show: isPDF,
+ show: false,
},
])}
>
diff --git a/packages/frontend/component/src/components/attachment-viewer/utils.ts b/packages/frontend/component/src/components/attachment-viewer/utils.ts
index 2238b3eb09857..d3c6103e74fe4 100644
--- a/packages/frontend/component/src/components/attachment-viewer/utils.ts
+++ b/packages/frontend/component/src/components/attachment-viewer/utils.ts
@@ -1,40 +1,63 @@
-import { fileTypeFromBuffer } from 'file-type';
+import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
-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;
+export async function getAttachmentBlob(model: AttachmentBlockModel) {
+ const sourceId = model.sourceId;
+ if (!sourceId) {
+ return null;
}
- 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());
+ const doc = model.doc;
+ let blob = await doc.blobSync.get(sourceId);
- if (!buffer) {
- console.warn('Could not get blob');
- return;
+ if (blob) {
+ blob = new Blob([blob], { type: model.type });
}
- try {
- const type = await fileTypeFromBuffer(buffer);
- if (!type) {
+
+ return blob;
+}
+
+export function download(model: AttachmentBlockModel) {
+ (async () => {
+ const blob = await getAttachmentBlob(model);
+ if (!blob) {
return;
}
- const blob = new Blob([buffer], { type: type.mime });
- return blob;
- } catch (error) {
- console.error('Error converting attachment to blob', error);
- }
- 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,
+ imageBitmap: ImageBitmap
+) {
+ 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('bitmaprenderer');
+ if (!ctx) return;
+
+ canvas.width = imageBitmap.width;
+ canvas.height = imageBitmap.height;
+ canvas.style.width = '100%';
+ canvas.style.height = '100%';
+
+ ctx.transferFromImageBitmap(imageBitmap);
+
+ item.append(canvas);
}
diff --git a/packages/frontend/component/src/components/attachment-viewer/viewer.tsx b/packages/frontend/component/src/components/attachment-viewer/viewer.tsx
index 78b19d60b0210..1b724a41fcd2c 100644
--- a/packages/frontend/component/src/components/attachment-viewer/viewer.tsx
+++ b/packages/frontend/component/src/components/attachment-viewer/viewer.tsx
@@ -2,7 +2,7 @@ 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 type { ReactElement } from 'react';
import React, {
useCallback,
useEffect,
@@ -10,15 +10,18 @@ import React, {
useRef,
useState,
} from 'react';
-import type { VirtuosoHandle } from 'react-virtuoso';
+import type { VirtuosoHandle, VirtuosoProps } from 'react-virtuoso';
import { Virtuoso } from 'react-virtuoso';
import { IconButton } from '../../ui/button';
import { Scrollable } from '../../ui/scrollbar';
+import { observeResize } from '../../utils';
import * as styles from './styles.css';
-// import { observeResize } from '../../utils';
-import type { MessageData, MessageDataType } from './worker/types';
-import { MessageOp, State } from './worker/types';
+import { getAttachmentBlob, renderItem } from './utils';
+import type { DocInfo, MessageData, MessageDataType } from './worker/types';
+import { MessageOp, MessageState, RenderKind, State } from './worker/types';
+
+type ItemProps = VirtuosoProps;
const Page = React.memo(
({
@@ -34,17 +37,8 @@ const Page = React.memo(
return (
-
-
+ style={{ width: `${width}px`, height: `${height}px` }}
+ >
);
}
);
@@ -68,41 +62,50 @@ const Thumbnail = React.memo(
onSelect: (index: number) => void;
}) => {
return (
- onSelect(index)}>
-
-
+ onSelect(index)}
+ >
);
}
);
Thumbnail.displayName = 'viewer-thumbnail';
-const Scroller = React.forwardRef<
- HTMLDivElement,
- PropsWithChildren>
->(({ style, ...props }, ref) => {
- return (
-
-
-
-
- );
-});
+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 [connected, setConnected] = useState(false);
- const [loaded, setLoaded] = useState(false);
- const [docInfo, setDocInfo] = useState({
+ const [state, setState] = useState(State.Connecting);
+ const [viewportInfo, setViewportInfo] = useState({
+ dpi: window.devicePixelRatio,
+ width: 1,
+ height: 1,
+ });
+ const [docInfo, setDocInfo] = useState({
cursor: 0,
total: 0,
width: 1,
@@ -127,63 +130,26 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => {
});
const post = useCallback(
- (
- type: T,
- data?: MessageDataType[T],
- transfer = []
- ) => {
- workerRef.current?.postMessage(
- {
- state: State.Poll,
- type,
- [type]: data,
- },
- transfer
- );
+ (type: T, data?: MessageDataType[T]) => {
+ workerRef.current?.postMessage({
+ type,
+ [type]: data,
+ state: MessageState.Poll,
+ });
},
[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`
+ (id: number, kind: RenderKind, imageBitmap: ImageBitmap) => {
+ renderItem(
+ (kind === RenderKind.Page ? scrollerRef : thumbnailsScrollerRef)
+ .current,
+ id,
+ imageBitmap
);
- 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]
+ [scrollerRef, thumbnailsScrollerRef]
);
const onScroll = useCallback(() => {
@@ -193,62 +159,74 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => {
const { scrollTop, scrollHeight } = el;
setDocInfo(info => {
- const cursor = Math.ceil(scrollTop / (scrollHeight / info.total));
- // thumbnailsScrollerHandleRef.current?.scrollToIndex(cursor)
- return {
- ...info,
- cursor,
- };
+ const itemHeight = scrollHeight / info.total;
+ const n = scrollTop / itemHeight;
+ const t = n / info.total;
+ const index = Math.floor(n + t);
+ const cursor = Math.min(index, info.total - 1);
+
+ if (cursor === info.cursor) return info;
+ return { ...info, cursor };
});
- // }, [scrollerRef, thumbnailsScrollerHandleRef]);
}, [scrollerRef]);
const onSelect = useCallback(
(index: number) => {
- scrollerHandleRef.current?.scrollToIndex(index);
- setDocInfo(info => ({ ...info, cursor: index }));
+ scrollerHandleRef.current?.scrollToIndex({
+ index,
+ align: 'start',
+ behavior: 'smooth',
+ });
},
[scrollerHandleRef]
);
const updateMainVisibleRange = useMemo(
- () => debounce(setMainVisibleRange, 233, { leading: true, trailing: true }),
+ () => debounce(setMainVisibleRange, 233, { trailing: true }),
[setMainVisibleRange]
);
const updateThumbnailsVisibleRange = useMemo(
- () =>
- debounce(setThumbnailsVisibleRange, 233, {
- leading: true,
- trailing: true,
- }),
+ () => debounce(setThumbnailsVisibleRange, 233, { trailing: true }),
[setThumbnailsVisibleRange]
);
- // useEffect(() => {
- // const el = viewerRef.current;
- // if (!el) return;
+ 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]);
- // return observeResize(el, entry => {
- // console.log(entry);
- // });
- // }, []);
+ useEffect(() => {
+ post(MessageOp.SyncViewportInfo, viewportInfo);
+ }, [viewportInfo, post]);
useEffect(() => {
const { startIndex, endIndex } = mainVisibleRange;
let index = startIndex;
- for (; index < endIndex + 1; index++) {
- post(MessageOp.Render, { index, kind: 'page' });
+ for (; index <= endIndex; index++) {
+ post(MessageOp.Render, { index, kind: RenderKind.Page });
}
}, [mainVisibleRange, post]);
useEffect(() => {
+ if (collapsed) return;
+
const { startIndex, endIndex } = thumbnailsVisibleRange;
+
let index = startIndex;
- for (; index < endIndex + 1; index++) {
- post(MessageOp.Render, { index, kind: 'thumbnail' });
+ for (; index <= endIndex; index++) {
+ post(MessageOp.Render, { index, kind: RenderKind.Thumbnail });
}
- }, [thumbnailsVisibleRange, post]);
+ }, [collapsed, thumbnailsVisibleRange, post]);
useEffect(() => {
workerRef.current = new Worker(
@@ -262,29 +240,30 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => {
const { type, state } = data;
if (type === MessageOp.Init) {
- setConnected(state === State.Ready);
+ setState(
+ state === MessageState.Ready ? State.Connected : State.Connecting
+ );
return;
}
- if (type === MessageOp.Open) {
- setLoaded(state === State.Ready);
+ if (type === MessageOp.Open && state === MessageState.Ready) {
+ setState(State.Loaded);
return;
}
- if (state === State.Poll) return;
+ if (state === MessageState.Poll) {
+ return;
+ }
switch (type) {
- case MessageOp.ReadInfo: {
- const action = data[type];
- setDocInfo(info => ({ ...info, ...action }));
+ case MessageOp.SyncDocInfo: {
+ const updated = data[type];
+ setDocInfo(info => ({ ...info, ...updated }));
+ setState(State.Synced);
break;
}
case MessageOp.Rendered: {
- const { index, imageData, kind } = data[type];
- if (kind === 'page') {
- render(index, imageData);
- } else {
- renderThumbnail(index, imageData);
- }
+ const { index, kind, imageBitmap } = data[type];
+ render(index, kind, imageBitmap);
break;
}
}
@@ -297,60 +276,93 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => {
return () => {
workerRef.current?.terminate();
};
- }, [model, post, render, renderThumbnail]);
+ }, [model, post, render]);
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]);
+ if (state === State.Connected) {
+ getAttachmentBlob(model)
+ .then(blob => {
+ if (!blob) return;
+ setState(State.Loading);
+ post(MessageOp.Open, { blob });
+ })
+ .catch(console.error);
+ return;
+ }
- useEffect(() => {
- if (!loaded) return;
- post(MessageOp.ReadInfo);
- }, [loaded, post]);
+ if (state === State.Loaded) {
+ setState(State.Syncing);
+ post(MessageOp.SyncDocInfo);
+ return;
+ }
+ }, [state, post, model, docInfo]);
- const pageContent = (index: number) => {
- return (
-
- );
- };
+ const pageContent = useCallback(
+ (index: number) => {
+ return (
+
+ );
+ },
+ [docInfo]
+ );
- const thumbnailContent = (index: number) => {
- return (
-
- );
- };
+ const thumbnailContent = useCallback(
+ (index: number) => {
+ return (
+
+ );
+ },
+ [docInfo, onSelect]
+ );
+
+ const mainComponents = useMemo(() => {
+ return {
+ Header: () => ,
+ Footer: () => ,
+ Item: (props: ItemProps) => (
+
+ ),
+ Scroller,
+ };
+ }, []);
- const components = useMemo(() => {
+ 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]);
+
return (
{
])}
ref={viewerRef}
>
-
onScroll={onScroll}
ref={scrollerHandleRef}
scrollerRef={scroller => {
@@ -371,44 +383,37 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => {
}}
className={styles.virtuoso}
rangeChanged={updateMainVisibleRange}
- increaseViewportBy={{
- top: docInfo.height * Math.min(5, docInfo.total),
- bottom: docInfo.height * Math.min(5, docInfo.total),
- }}
+ increaseViewportBy={increaseViewportBy}
totalCount={docInfo.total}
itemContent={pageContent}
- components={components}
+ components={mainComponents}
/>
- {collapsed ? null : (
-
- {
- if (thumbnailsScrollerRef.current) return;
- thumbnailsScrollerRef.current = scroller as HTMLElement;
- }}
- rangeChanged={updateThumbnailsVisibleRange}
- className={styles.virtuoso}
- totalCount={docInfo.total}
- itemContent={thumbnailContent}
- components={components}
- />
-
- )}
+
+
+ style={{
+ height: `${Math.min(viewportInfo.height - 60 - 24 - 24 - 2 - 8, docInfo.total * THUMBNAIL_WIDTH * ((docInfo.height + 12) / docInfo.width))}px`,
+ }}
+ 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.cursor + 1}/{docInfo.total}
+ {docInfo.total > 0 ? docInfo.cursor + 1 : 0}/
+ {docInfo.total}
:
}
- onClick={() => setCollapsed(state => !state)}
+ onClick={() => setCollapsed(!collapsed)}
/>
diff --git a/packages/frontend/component/src/components/attachment-viewer/worker/types.ts b/packages/frontend/component/src/components/attachment-viewer/worker/types.ts
index 4ec6b2a064931..b5f0da736b053 100644
--- a/packages/frontend/component/src/components/attachment-viewer/worker/types.ts
+++ b/packages/frontend/component/src/components/attachment-viewer/worker/types.ts
@@ -1,4 +1,26 @@
+export type DocInfo = {
+ cursor: number;
+ total: number;
+ width: number;
+ height: number;
+};
+
+export type ViewportInfo = {
+ dpi: number;
+ width: number;
+ height: number;
+};
+
export enum State {
+ Connecting = 0,
+ Connected,
+ Loading,
+ Loaded,
+ Syncing,
+ Synced,
+}
+
+export enum MessageState {
Poll,
Ready,
}
@@ -6,28 +28,40 @@ export enum State {
export enum MessageOp {
Init,
Open,
- ReadInfo,
+ SyncDocInfo,
+ SyncViewportInfo,
Render,
Rendered,
}
-export type MessageDataMap = {
+export enum RenderKind {
+ Page,
+ Thumbnail,
+}
+
+export interface 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.Open]: {
+ blob: Blob;
+ };
+ [MessageOp.SyncDocInfo]: Partial;
+ [MessageOp.SyncViewportInfo]: Partial;
+ [MessageOp.Render]: {
+ index: number;
+ kind: RenderKind;
+ };
[MessageOp.Rendered]: {
index: number;
- imageData: ImageData;
- kind: 'page' | 'thumbnail';
+ kind: RenderKind;
+ imageBitmap: ImageBitmap;
};
-};
+}
export type MessageDataType = {
[P in keyof T]: T[P];
};
export type MessageData = {
- state: State;
+ state: MessageState;
type: T;
} & P;
diff --git a/packages/frontend/component/src/components/attachment-viewer/worker/utils.ts b/packages/frontend/component/src/components/attachment-viewer/worker/utils.ts
new file mode 100644
index 0000000000000..495630212a496
--- /dev/null
+++ b/packages/frontend/component/src/components/attachment-viewer/worker/utils.ts
@@ -0,0 +1,20 @@
+export function resizeImageBitmap(
+ imageBitmap: ImageBitmap,
+ options: {
+ resizeWidth: number;
+ resizeHeight: number;
+ }
+) {
+ return createImageBitmap(
+ imageBitmap,
+ 0,
+ 0,
+ imageBitmap.width,
+ imageBitmap.height,
+ {
+ colorSpaceConversion: 'none',
+ resizeQuality: 'pixelated',
+ ...options,
+ }
+ );
+}
diff --git a/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts b/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts
index d6423a6083148..ccefd4e66a3aa 100644
--- a/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts
+++ b/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts
@@ -8,55 +8,35 @@ import {
} from '@toeverything/pdf-viewer';
import type { MessageData, MessageDataType } from './types';
-import { MessageOp, State } from './types';
+import { MessageOp, MessageState, RenderKind } from './types';
+import { resizeImageBitmap } from './utils';
-const logger = new DebugLogger('affine:pdf-worker');
+const logger = new DebugLogger('affine:worker:pdf');
-let dpi = 2;
let inited = false;
let viewer: Viewer | null = null;
let doc: Document | undefined = undefined;
-const cached = new Map();
+const canvas = new OffscreenCanvas(0, 0);
+const ctx = canvas.getContext('2d');
+const cached = new Map();
const docInfo = { cursor: 0, total: 0, width: 1, height: 1 };
+const viewportInfo = { dpi: 2, 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);
+ self.postMessage({ state: MessageState.Ready, type, [type]: data });
}
async function start() {
logger.debug('pdf worker pending');
- self.postMessage({ state: State.Poll, type: MessageOp.Init });
+ self.postMessage({ state: MessageState.Poll, type: MessageOp.Init });
const pdfium = await createPDFium();
viewer = new Viewer(new Runtime(pdfium));
inited = true;
- self.postMessage({ state: State.Ready, type: MessageOp.Init });
+ self.postMessage({ state: MessageState.Ready, type: MessageOp.Init });
logger.debug('pdf worker ready');
}
@@ -69,14 +49,13 @@ async function process({ data }: MessageEvent) {
const { type, state } = data;
- if (state !== State.Poll) return;
+ if (state !== MessageState.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;
@@ -85,17 +64,36 @@ async function process({ data }: MessageEvent) {
break;
}
- case MessageOp.ReadInfo: {
+ case MessageOp.SyncViewportInfo: {
+ const updated = data[type];
+
+ if (updated) {
+ Object.assign(viewportInfo, updated);
+ }
+
+ break;
+ }
+
+ case MessageOp.SyncDocInfo: {
if (!doc) return;
+ const updated = data[type];
+
+ if (updated) {
+ Object.assign(docInfo, updated);
+ }
+
const page = doc.page(0);
+
if (page) {
- docInfo.cursor = 0;
- docInfo.total = doc.pageCount();
- docInfo.height = page.height();
- docInfo.width = page.width();
+ Object.assign(docInfo, {
+ cursor: 0,
+ total: doc.pageCount(),
+ height: Math.ceil(page.height()),
+ width: Math.ceil(page.width()),
+ });
page.close();
- post(MessageOp.ReadInfo, docInfo);
+ post(MessageOp.SyncDocInfo, docInfo);
}
break;
}
@@ -105,24 +103,23 @@ async function process({ data }: MessageEvent) {
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,
+ let imageBitmap = cached.size > 0 ? cached.get(index) : undefined;
+ if (imageBitmap) {
+ if (kind === RenderKind.Thumbnail) {
+ const rw = 94 * viewportInfo.dpi;
+ const rh = (docInfo.height / docInfo.width) * rw;
+ imageBitmap = await resizeImageBitmap(imageBitmap, {
+ resizeWidth: Math.ceil(rw),
+ resizeHeight: Math.ceil(rh),
});
}
- post(MessageOp.Rendered, { index, imageData, kind });
+ post(MessageOp.Rendered, { index, kind, imageBitmap });
return;
}
- const width = Math.ceil(docInfo.width * dpi);
- const height = Math.ceil(docInfo.height * dpi);
+ const width = Math.ceil(docInfo.width * viewportInfo.dpi);
+ const height = Math.ceil(docInfo.height * viewportInfo.dpi);
const page = doc.page(index);
if (page) {
@@ -135,21 +132,31 @@ async function process({ data }: MessageEvent) {
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,
+ if (canvas.width !== width || canvas.height !== height) {
+ canvas.width = width;
+ canvas.height = height;
+ }
+ const imageData = new ImageData(
+ new Uint8ClampedArray(data),
+ width,
+ height
+ );
+ ctx?.clearRect(0, 0, width, height);
+ ctx?.putImageData(imageData, 0, 0);
+ imageBitmap = canvas.transferToImageBitmap();
+
+ cached.set(index, imageBitmap);
+
+ if (kind === RenderKind.Thumbnail) {
+ const rw = 94 * viewportInfo.dpi;
+ const rh = (docInfo.height / docInfo.width) * rw;
+ imageBitmap = await resizeImageBitmap(imageBitmap, {
+ resizeWidth: Math.ceil(rw),
+ resizeHeight: Math.ceil(rh),
});
}
- post(MessageOp.Rendered, { index, imageData, kind });
+ post(MessageOp.Rendered, { index, kind, imageBitmap });
}
break;
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..9d4e46fe6380b 100644
--- a/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx
+++ b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx
@@ -1,3 +1,4 @@
+import { Skeleton } from '@affine/component';
import { AttachmentViewer } from '@affine/component/attachment-viewer';
import {
type AttachmentBlockModel,
@@ -9,7 +10,7 @@ import {
FrameworkScope,
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 {
@@ -19,66 +20,127 @@ import {
ViewTitle,
} from '../../../../modules/workbench';
import { PageNotFound } from '../../404';
+import * as styles from './index.css';
-const useLoadAttachment = (pageId?: string, attachmentId?: string) => {
+enum State {
+ Loading,
+ NotFound,
+ Found,
+}
+
+const useLoadAttachment = () => {
+ const { pageId, attachmentId } = useParams();
const docsService = useService(DocsService);
const [doc, setDoc] = useState(null);
+ const [state, setState] = useState(State.Loading);
const [model, setModel] = useState(null);
useLayoutEffect(() => {
- if (!pageId) return;
+ if (!pageId || !attachmentId) {
+ setState(State.NotFound);
+ return;
+ }
const { doc, release } = docsService.open(pageId);
- 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) {
+ setModel(block.model as AttachmentBlockModel);
+ setState(State.Found);
+ notFound = false;
+ }
}
- 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));
+ setDoc(doc);
return () => {
- disposable.dispose();
+ disposables.forEach(d => d[Symbol.dispose]());
+ release();
};
- }, [doc, attachmentId]);
+ }, [docsService, pageId, attachmentId]);
- return { doc, model };
+ return { doc, model, state };
};
export const AttachmentPage = (): ReactElement => {
- const params = useParams();
- const { doc, model } = useLoadAttachment(params.pageId, params.attachmentId);
+ const { doc, model, state } = useLoadAttachment();
- if (!doc || !model) {
+ if (state === State.NotFound) {
return ;
}
+ if (state === State.Found && doc && model) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+ }
+
return (
- <>
-
-
-
-
-
-
-
-
- >
+
+
+
+
+
+
+
);
};
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 d08fd99202d25..76a4d9ee34f5c 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
@@ -154,3 +154,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 fcfc779a2bb6e..bcd6aafb8bdb2 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);
}