diff --git a/packages/common/infra/src/op/README.md b/packages/common/infra/src/op/README.md index 232dbb04fc6e3..8a1aced1b4a32 100644 --- a/packages/common/infra/src/op/README.md +++ b/packages/common/infra/src/op/README.md @@ -39,7 +39,7 @@ consumer.register('subscribeStatus', (id: number) => { // subscribe const client: OpClient; -client.subscribe('subscribeStatus', 123, { +client.ob$('subscribeStatus', 123).subscribe({ next: status => { ui.setServerStatus(status); }, diff --git a/packages/common/infra/src/op/__tests__/client.spec.ts b/packages/common/infra/src/op/__tests__/client.spec.ts index 2803d0ba5ddc1..ffc24a669060e 100644 --- a/packages/common/infra/src/op/__tests__/client.spec.ts +++ b/packages/common/infra/src/op/__tests__/client.spec.ts @@ -116,7 +116,7 @@ describe('op client', () => { // @ts-expect-error internal api const subscriptions = ctx.producer.obs; - ctx.producer.subscribe('sub', new Uint8Array([1, 2, 3]), ob); + ctx.producer.ob$('sub', new Uint8Array([1, 2, 3])).subscribe(ob); expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(` { @@ -160,7 +160,7 @@ describe('op client', () => { error: vi.fn(), complete: vi.fn(), }; - ctx.producer.subscribe('sub', new Uint8Array([1, 2, 3]), ob); + ctx.producer.ob$('sub', new Uint8Array([1, 2, 3])).subscribe(ob); expect(subscriptions.has('sub:2')).toBe(true); @@ -179,29 +179,23 @@ describe('op client', () => { it('should transfer transferables with subscribe op', async ctx => { const data = new Uint8Array([1, 2, 3]); - const unsubscribe = ctx.producer.subscribe( - 'bin', - transfer(data, [data.buffer]), - { + const sub = ctx.producer + .ob$('bin', transfer(data, [data.buffer])) + .subscribe({ next: vi.fn(), - } - ); + }); expect(data.byteLength).toBe(0); - unsubscribe(); + sub.unsubscribe(); }); it('should unsubscribe subscription op', ctx => { - const unsubscribe = ctx.producer.subscribe( - 'sub', - new Uint8Array([1, 2, 3]), - { - next: vi.fn(), - } - ); + const sub = ctx.producer.ob$('sub', new Uint8Array([1, 2, 3])).subscribe({ + next: vi.fn(), + }); - unsubscribe(); + sub.unsubscribe(); expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(` [ diff --git a/packages/common/infra/src/op/client.ts b/packages/common/infra/src/op/client.ts index bdf66b0d0a003..c469028f96c69 100644 --- a/packages/common/infra/src/op/client.ts +++ b/packages/common/infra/src/op/client.ts @@ -22,7 +22,7 @@ interface PendingCall extends PromiseWithResolvers { timeout: number | NodeJS.Timeout; } -interface OpClientOptions { +export interface OpClientOptions { timeout?: number; } @@ -155,15 +155,11 @@ export class OpClient extends AutoMessageHandler { return promise; } - subscribe, Out extends OpOutput>( + ob$, Out extends OpOutput>( op: Op, - ...args: [ - ...OpInput, - Partial> | ((value: Out) => void), - ] - ): () => void { + ...args: OpInput + ): Observable { const payload = args[0]; - const observer = args[1] as Partial> | ((value: Out) => void); const msg = { type: 'subscribe', @@ -172,24 +168,23 @@ export class OpClient extends AutoMessageHandler { payload, } satisfies SubscribeMessage; - const sub = new Observable(ob => { + const sub$ = new Observable(ob => { this.obs.set(msg.id, ob); - }).subscribe(observer); - sub.add(() => { - this.obs.delete(msg.id); - this.port.postMessage({ - type: 'unsubscribe', - id: msg.id, - } satisfies UnsubscribeMessage); + return () => { + ob.complete(); + this.obs.delete(msg.id); + this.port.postMessage({ + type: 'unsubscribe', + id: msg.id, + } satisfies UnsubscribeMessage); + }; }); const transferables = fetchTransferables(payload); this.port.postMessage(msg, { transfer: transferables }); - return () => { - sub.unsubscribe(); - }; + return sub$; } destroy() { diff --git a/packages/common/infra/src/op/consumer.ts b/packages/common/infra/src/op/consumer.ts index 2f94300e34f27..0b24c4b5ea7c9 100644 --- a/packages/common/infra/src/op/consumer.ts +++ b/packages/common/infra/src/op/consumer.ts @@ -1,14 +1,5 @@ import EventEmitter2 from 'eventemitter2'; -import { - defer, - from, - fromEvent, - Observable, - of, - share, - take, - takeUntil, -} from 'rxjs'; +import { defer, from, fromEvent, Observable, of, take, takeUntil } from 'rxjs'; import { AutoMessageHandler, @@ -172,7 +163,7 @@ export class OpConsumer extends AutoMessageHandler { ob$ = of(ret$); } - return ob$.pipe(share(), takeUntil(fromEvent(signal, 'abort'))); + return ob$.pipe(takeUntil(fromEvent(signal, 'abort'))); }); } diff --git a/packages/common/infra/src/op/message.ts b/packages/common/infra/src/op/message.ts index 852cdc6d29b9f..aa58fd3b0cfc6 100644 --- a/packages/common/infra/src/op/message.ts +++ b/packages/common/infra/src/op/message.ts @@ -95,6 +95,7 @@ export type MessageCommunicapable = Pick< > & { start?(): void; close?(): void; + terminate?(): void; // For Worker }; export function ignoreUnknownEvent(handler: (data: Messages) => void) { @@ -130,6 +131,7 @@ export function fetchTransferables(data: any): Transferable[] | undefined { } export abstract class AutoMessageHandler { + private listening = false; protected abstract handlers: Partial; constructor(protected readonly port: MessageCommunicapable) {} @@ -144,12 +146,21 @@ export abstract class AutoMessageHandler { }); listen() { + if (this.listening) { + return; + } + this.port.addEventListener('message', this.handleMessage); + this.port.addEventListener('messageerror', console.error); this.port.start?.(); + this.listening = true; } close() { this.port.close?.(); + this.port.terminate?.(); // For Worker this.port.removeEventListener('message', this.handleMessage); + this.port.removeEventListener('messageerror', console.error); + this.listening = false; } } diff --git a/packages/frontend/apps/electron/src/main/shared-state-schema.ts b/packages/frontend/apps/electron/src/main/shared-state-schema.ts index 8d78745ca7197..03b13f51577b4 100644 --- a/packages/frontend/apps/electron/src/main/shared-state-schema.ts +++ b/packages/frontend/apps/electron/src/main/shared-state-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 7faace18b16d1..4920b05733926 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -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", diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index e68f12bff3d69..96db1702592bd 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.1", "@toeverything/theme": "^1.0.17", "@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..5d56704dd3193 --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/error.tsx @@ -0,0 +1,131 @@ +import { Button } from '@affine/component'; +import { useI18n } from '@affine/i18n'; +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { ArrowDownBigIcon } from '@blocksuite/icons/rc'; +import clsx from 'clsx'; +import type { PropsWithChildren, ReactElement } from 'react'; +import { Suspense } from 'react'; +import { ErrorBoundary, type FallbackProps } from 'react-error-boundary'; + +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 = () => ( + + + + + + +); + +const FILE_ICONS: Record ReactElement> = { + 'application/pdf': PDFFileIcon, +}; + +interface ErrorBaseProps { + title: string; + subtitle?: string; + icon?: ReactElement; + buttons?: ReactElement[]; +} + +export const ErrorBase = ({ + title, + subtitle, + icon = , + buttons = [], +}: ErrorBaseProps) => { + return ( +
+ {icon} +

{title}

+

{subtitle}

+
{buttons}
+
+ ); +}; + +interface ErrorProps { + model: AttachmentBlockModel; + ext: string; +} + +export const Error = ({ model, ext }: ErrorProps) => { + const t = useI18n(); + const Icon = FILE_ICONS[model.type] ?? FileIcon; + const title = t['com.affine.attachment.preview.error.title'](); + const subtitle = `.${ext} ${t['com.affine.attachment.preview.error.subtitle']()}`; + + return ( + } + title={title} + subtitle={subtitle} + buttons={[ + , + ]} + /> + ); +}; + +const ErrorBoundaryInner = (props: FallbackProps): ReactElement => { + const t = useI18n(); + const title = t['com.affine.attachment.preview.error.title'](); + const subtitle = `${props.error}`; + return ; +}; + +export const AttachmentPreviewErrorBoundary = (props: PropsWithChildren) => { + return ( + + {props.children} + + ); +}; diff --git a/packages/frontend/core/src/components/attachment-viewer/index.tsx b/packages/frontend/core/src/components/attachment-viewer/index.tsx new file mode 100644 index 0000000000000..9ab69a85583c6 --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/index.tsx @@ -0,0 +1,52 @@ +import { ViewBody, ViewHeader } from '@affine/core/modules/workbench'; +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; + +import { AttachmentPreviewErrorBoundary, Error } from './error'; +import { PDFViewer } from './pdf-viewer'; +import * as styles from './styles.css'; +import { Titlebar } from './titlebar'; +import { buildAttachmentProps } from './utils'; + +export type AttachmentViewerProps = { + model: AttachmentBlockModel; +}; + +// In Peek view +export const AttachmentViewer = ({ model }: AttachmentViewerProps) => { + const props = buildAttachmentProps(model); + + return ( +
+ + {model.type.endsWith('pdf') ? ( + + + + ) : ( + + )} +
+ ); +}; + +// In View container +export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => { + const props = buildAttachmentProps(model); + + return ( + <> + + + + + {model.type.endsWith('pdf') ? ( + + + + ) : ( + + )} + + + ); +}; diff --git a/packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx new file mode 100644 index 0000000000000..a60926c7f6fd9 --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx @@ -0,0 +1,212 @@ +import { IconButton, observeResize } from '@affine/component'; +import { + type PDF, + type PDFRendererState, + PDFService, + PDFStatus, +} from '@affine/core/modules/pdf'; +import { + Item, + List, + ListPadding, + ListWithSmallGap, + LoadingSvg, + PDFPageRenderer, + type PDFVirtuosoContext, + type PDFVirtuosoProps, + Scroller, +} from '@affine/core/modules/pdf/views'; +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc'; +import { useLiveData, useService } from '@toeverything/infra'; +import clsx from 'clsx'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; + +import * as styles from './styles.css'; +import { calculatePageNum } from './utils'; + +const THUMBNAIL_WIDTH = 94; + +interface ViewerProps { + model: AttachmentBlockModel; +} + +interface PDFViewerInnerProps { + pdf: PDF; + state: Extract; +} + +const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => { + const [cursor, setCursor] = useState(0); + const [collapsed, setCollapsed] = useState(true); + const [viewportInfo, setViewportInfo] = useState({ width: 0, height: 0 }); + + const viewerRef = useRef(null); + const pagesScrollerRef = useRef(null); + const pagesScrollerHandleRef = useRef(null); + const thumbnailsScrollerHandleRef = useRef(null); + + const onScroll = useCallback(() => { + const el = pagesScrollerRef.current; + if (!el) return; + + const { pageCount } = state.meta; + if (!pageCount) return; + + const cursor = calculatePageNum(el, pageCount); + + setCursor(cursor); + }, [pagesScrollerRef, state]); + + const onPageSelect = useCallback( + (index: number) => { + const scroller = pagesScrollerHandleRef.current; + if (!scroller) return; + + scroller.scrollToIndex({ + index, + align: 'center', + behavior: 'smooth', + }); + }, + [pagesScrollerHandleRef] + ); + + const pageContent = useCallback( + ( + index: number, + _: unknown, + { width, height, onPageSelect, pageClassName }: PDFVirtuosoContext + ) => { + return ( + + ); + }, + [pdf] + ); + + const thumbnailsConfig = useMemo(() => { + const { height: vh } = viewportInfo; + const { pageCount: t, height: h, width: w } = state.meta; + const p = h / (w || 1); + const pw = THUMBNAIL_WIDTH; + const ph = Math.ceil(pw * p); + const height = Math.min(vh - 60 - 24 - 24 - 2 - 8, t * ph + (t - 1) * 12); + return { + context: { + width: pw, + height: ph, + onPageSelect, + pageClassName: styles.pdfThumbnail, + }, + style: { height }, + }; + }, [state, viewportInfo, onPageSelect]); + + useEffect(() => { + const viewer = viewerRef.current; + if (!viewer) return; + return observeResize(viewer, ({ contentRect: { width, height } }) => + setViewportInfo({ width, height }) + ); + }, []); + + return ( +
+ + key={pdf.id} + ref={pagesScrollerHandleRef} + scrollerRef={scroller => { + pagesScrollerRef.current = scroller as HTMLElement; + }} + onScroll={onScroll} + className={styles.virtuoso} + totalCount={state.meta.pageCount} + itemContent={pageContent} + components={{ + Item, + List, + Scroller, + Header: ListPadding, + Footer: ListPadding, + }} + context={{ + width: state.meta.width, + height: state.meta.height, + pageClassName: styles.pdfPage, + }} + /> +
+
+ + key={`${pdf.id}-thumbnail`} + ref={thumbnailsScrollerHandleRef} + className={styles.virtuoso} + totalCount={state.meta.pageCount} + itemContent={pageContent} + components={{ + Item, + Scroller, + List: ListWithSmallGap, + }} + style={thumbnailsConfig.style} + context={thumbnailsConfig.context} + /> +
+
+
+ + {state.meta.pageCount > 0 ? cursor + 1 : 0} + + /{state.meta.pageCount} +
+ : } + onClick={() => setCollapsed(!collapsed)} + /> +
+
+
+ ); +}; + +function PDFViewerStatus({ pdf }: { pdf: PDF }) { + const state = useLiveData(pdf.state$); + + if (state?.status !== PDFStatus.Opened) { + return ; + } + + return ; +} + +export function PDFViewer({ model }: ViewerProps) { + const pdfService = useService(PDFService); + const [pdf, setPdf] = useState(null); + + useEffect(() => { + const { pdf, release } = pdfService.get(model); + setPdf(pdf); + + return release; + }, [model, pdfService, setPdf]); + + if (!pdf) { + return ; + } + + return ; +} diff --git a/packages/frontend/core/src/components/attachment-viewer/styles.css.ts b/packages/frontend/core/src/components/attachment-viewer/styles.css.ts new file mode 100644 index 0000000000000..65525db4af6db --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/styles.css.ts @@ -0,0 +1,183 @@ +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'), + textWrap: 'nowrap', + overflow: 'hidden', +}); + +export const titlebarChild = style({ + overflow: 'hidden', + selectors: { + [`${titlebar} > &`]: { + display: 'flex', + gap: '12px', + alignItems: 'center', + paddingLeft: '12px', + paddingRight: '12px', + }, + '&.zoom:not(.show)': { + display: 'none', + }, + }, +}); + +export const titlebarName = style({ + display: 'flex', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'pre', + wordWrap: 'break-word', +}); + +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 viewer = style({ + position: 'relative', + zIndex: 0, + display: 'flex', + flex: 1, + overflow: 'hidden', + resize: 'both', + selectors: { + '&:before': { + position: 'absolute', + content: '', + top: 0, + right: 0, + bottom: 0, + left: 0, + 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 pdfIndicator = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '0 12px', +}); + +export const pdfPage = style({ + 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))', + overflow: 'hidden', +}); + +export const pdfThumbnails = 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'), + fontSize: '12px', + fontWeight: 500, + lineHeight: '20px', + color: cssVarV2('text/secondary'), +}); + +export const pdfThumbnailsList = style({ + position: 'relative', + display: 'flex', + flexDirection: 'column', + maxHeight: '100%', + overflow: 'hidden', + resize: 'both', + selectors: { + '&.collapsed': { + display: 'none', + }, + '&:not(.collapsed)': { + marginBottom: '8px', + }, + }, +}); + +export const pdfThumbnail = style({ + display: 'flex', + overflow: 'hidden', + // width: '100%', + borderRadius: '4px', + borderWidth: '1px', + borderStyle: 'solid', + borderColor: cssVarV2('layer/insideBorder/border'), + selectors: { + '&.selected': { + borderColor: '#29A3FA', + }, + }, +}); diff --git a/packages/frontend/core/src/components/attachment-viewer/titlebar.tsx b/packages/frontend/core/src/components/attachment-viewer/titlebar.tsx new file mode 100644 index 0000000000000..8f826513d8907 --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/titlebar.tsx @@ -0,0 +1,105 @@ +import { IconButton, Menu, MenuItem } from '@affine/component'; +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 * as styles from './styles.css'; +import { download } from './utils'; + +const items = [ + /* + { + name: 'Rename', + icon: , + action(_model: AttachmentBlockModel) {}, + }, + */ + { + name: 'Download', + icon: , + action: download, + }, +]; + +export const MenuItems = ({ model }: { model: AttachmentBlockModel }) => + items.map(({ name, icon, action }) => ( + { + action(model).catch(console.error); + }} + prefixIcon={icon} + > + {name} + + )); + +export interface TitlebarProps { + model: AttachmentBlockModel; + name: string; + ext: string; + size: string; + zoom?: number; +} + +export const Titlebar = ({ + model, + name, + ext, + size, + zoom = 100, +}: TitlebarProps) => { + const [openMenu, setOpenMenu] = useState(false); + + return ( +
+
+
+
{name}
+ .{ext} +
+
{size}
+ } + onClick={() => { + download(model).catch(console.error); + }} + > + } + rootOptions={{ + open: openMenu, + onOpenChange: setOpenMenu, + }} + contentOptions={{ + side: 'bottom', + align: 'center', + avoidCollisions: false, + }} + > + }> + +
+
+ }> +
{zoom}%
+ }> +
+
+ ); +}; 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..fb0e5c8f8383b --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/utils.ts @@ -0,0 +1,45 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { filesize } from 'filesize'; + +import { downloadBlob } from '../../utils/resource'; + +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 async function download(model: AttachmentBlockModel) { + const blob = await getAttachmentBlob(model); + if (!blob) return; + + await downloadBlob(blob, model.name); +} + +export function buildAttachmentProps(model: AttachmentBlockModel) { + const pieces = model.name.split('.'); + const ext = pieces.pop() || ''; + const name = pieces.join('.'); + const size = filesize(model.size); + return { model, name, ext, size }; +} + +export function calculatePageNum(el: HTMLElement, pageCount: number) { + const { scrollTop, scrollHeight } = el; + const pageHeight = scrollHeight / pageCount; + const n = scrollTop / pageHeight; + const t = n / pageCount; + const index = Math.floor(n + t); + const cursor = Math.min(index, pageCount - 1); + return cursor; +} 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 new file mode 100644 index 0000000000000..5f042fe7a3809 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx @@ -0,0 +1,122 @@ +import { Skeleton } from '@affine/component'; +import { type AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { + type Doc, + DocsService, + FrameworkScope, + useLiveData, + useService, +} from '@toeverything/infra'; +import { type ReactElement, useLayoutEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import { AttachmentViewerView } from '../../../../components/attachment-viewer'; +import { ViewIcon, ViewTitle } from '../../../../modules/workbench'; +import { PageNotFound } from '../../404'; +import * as styles from './index.css'; + +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 [model, setModel] = useState(null); + + useLayoutEffect(() => { + if (!docRecord) { + return; + } + + const { doc, release } = docsService.open(pageId); + + setDoc(doc); + + if (!doc.blockSuiteDoc.ready) { + doc.blockSuiteDoc.load(); + } + doc.setPriorityLoad(10); + + doc + .waitForSyncReady() + .then(() => { + const block = doc.blockSuiteDoc.getBlock(attachmentId); + if (block) { + setModel(block.model as AttachmentBlockModel); + } + }) + .catch(console.error); + + return () => { + release(); + }; + }, [docRecord, docsService, pageId, attachmentId]); + + return { doc, model }; +}; + +export const AttachmentPage = ({ + pageId, + attachmentId, +}: AttachmentPageProps): ReactElement => { + const { doc, model } = useLoadAttachment(pageId, attachmentId); + + if (!doc) { + return ; + } + + if (doc && model) { + return ( + + + + + + ); + } + + return ( +
+ + + + + +
+ ); +}; + +export const Component = () => { + const { pageId, attachmentId } = useParams(); + + if (!pageId || !attachmentId) { + return ; + } + + return ; +}; diff --git a/packages/frontend/core/src/desktop/workbench-router.ts b/packages/frontend/core/src/desktop/workbench-router.ts index 0e810fa39b2d8..8c47547975b91 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/attachments/:attachmentId', + lazy: () => import('./pages/workspace/attachment/index'), + }, { path: '*', lazy: () => import('./pages/404'), diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index 51c9b9501e519..26b7181de52e0 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -19,6 +19,7 @@ import { configureJournalModule } from './journal'; import { configureNavigationModule } from './navigation'; import { configureOpenInApp } from './open-in-app'; import { configureOrganizeModule } from './organize'; +import { configurePDFModule } from './pdf'; import { configurePeekViewModule } from './peek-view'; import { configurePermissionsModule } from './permissions'; import { configureQuickSearchModule } from './quicksearch'; @@ -44,6 +45,7 @@ export function configureCommonModules(framework: Framework) { configureShareDocsModule(framework); configureShareSettingModule(framework); configureTelemetryModule(framework); + configurePDFModule(framework); configurePeekViewModule(framework); configureDocDisplayMetaModule(framework); configureQuickSearchModule(framework); diff --git a/packages/frontend/core/src/modules/pdf/entities/pdf-page.ts b/packages/frontend/core/src/modules/pdf/entities/pdf-page.ts new file mode 100644 index 0000000000000..b1f91b14ea172 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/entities/pdf-page.ts @@ -0,0 +1,39 @@ +import { DebugLogger } from '@affine/debug'; +import { + catchErrorInto, + effect, + Entity, + LiveData, + mapInto, +} from '@toeverything/infra'; +import { map, switchMap } from 'rxjs'; + +import type { RenderPageOpts } from '../renderer'; +import type { PDF } from './pdf'; + +const logger = new DebugLogger('affine:pdf:page:render'); + +export class PDFPage extends Entity<{ pdf: PDF; pageNum: number }> { + readonly pageNum: number = this.props.pageNum; + bitmap$ = new LiveData(null); + error$ = new LiveData(null); + + render = effect( + switchMap((opts: Omit) => + this.props.pdf.renderer.ob$('render', { + ...opts, + pageNum: this.pageNum, + }) + ), + map(data => data.bitmap), + mapInto(this.bitmap$), + catchErrorInto(this.error$, error => { + logger.error('Failed to render page', error); + }) + ); + + constructor() { + super(); + this.disposables.push(() => this.render.unsubscribe); + } +} diff --git a/packages/frontend/core/src/modules/pdf/entities/pdf.ts b/packages/frontend/core/src/modules/pdf/entities/pdf.ts new file mode 100644 index 0000000000000..bcf12d33b77b3 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/entities/pdf.ts @@ -0,0 +1,74 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { Entity, LiveData, ObjectPool } from '@toeverything/infra'; +import { catchError, from, map, of, startWith, switchMap } from 'rxjs'; + +import type { PDFMeta } from '../renderer'; +import { downloadBlobToBuffer, PDFRenderer } from '../renderer'; +import { PDFPage } from './pdf-page'; + +export enum PDFStatus { + IDLE = 0, + Opening, + Opened, + Error, +} + +export type PDFRendererState = + | { + status: PDFStatus.IDLE | PDFStatus.Opening; + } + | { + status: PDFStatus.Opened; + meta: PDFMeta; + } + | { + status: PDFStatus.Error; + error: Error; + }; + +export class PDF extends Entity { + public readonly id: string = this.props.id; + readonly renderer = new PDFRenderer(); + readonly pages = new ObjectPool({ + onDelete: page => page.dispose(), + }); + + readonly state$ = LiveData.from( + // @ts-expect-error type alias + from(downloadBlobToBuffer(this.props)).pipe( + switchMap(buffer => { + return this.renderer.ob$('open', { data: buffer }); + }), + map(meta => ({ status: PDFStatus.Opened, meta })), + // @ts-expect-error type alias + startWith({ status: PDFStatus.Opening }), + catchError((error: Error) => of({ status: PDFStatus.Error, error })) + ), + { status: PDFStatus.IDLE } + ); + + constructor() { + super(); + this.renderer.listen(); + this.disposables.push(() => this.pages.clear()); + } + + page(pageNum: number, size: string) { + const key = `${pageNum}:${size}`; + let rc = this.pages.get(key); + + if (!rc) { + rc = this.pages.put( + key, + this.framework.createEntity(PDFPage, { pdf: this, pageNum }) + ); + } + + return { page: rc.obj, release: rc.release }; + } + + override dispose() { + this.renderer.destroy(); + super.dispose(); + } +} diff --git a/packages/frontend/core/src/modules/pdf/index.ts b/packages/frontend/core/src/modules/pdf/index.ts new file mode 100644 index 0000000000000..998173d9ce585 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/index.ts @@ -0,0 +1,19 @@ +import type { Framework } from '@toeverything/infra'; +import { WorkspaceScope } from '@toeverything/infra'; + +import { PDF } from './entities/pdf'; +import { PDFPage } from './entities/pdf-page'; +import { PDFService } from './services/pdf'; + +export function configurePDFModule(framework: Framework) { + framework + .scope(WorkspaceScope) + .service(PDFService) + .entity(PDF) + .entity(PDFPage); +} + +export { PDF, type PDFRendererState, PDFStatus } from './entities/pdf'; +export { PDFPage } from './entities/pdf-page'; +export { PDFRenderer } from './renderer'; +export { PDFService } from './services/pdf'; diff --git a/packages/frontend/core/src/modules/pdf/renderer/index.ts b/packages/frontend/core/src/modules/pdf/renderer/index.ts new file mode 100644 index 0000000000000..d3e9a83e744aa --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/renderer/index.ts @@ -0,0 +1,3 @@ +export { PDFRenderer } from './renderer'; +export type { PDFMeta, RenderedPage, RenderPageOpts } from './types'; +export { downloadBlobToBuffer } from './utils'; diff --git a/packages/frontend/core/src/modules/pdf/renderer/ops.ts b/packages/frontend/core/src/modules/pdf/renderer/ops.ts new file mode 100644 index 0000000000000..946fa6b2454b3 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/renderer/ops.ts @@ -0,0 +1,8 @@ +import type { OpSchema } from '@toeverything/infra/op'; + +import type { PDFMeta, RenderedPage, RenderPageOpts } from './types'; + +export interface ClientOps extends OpSchema { + open: [{ data: ArrayBuffer }, PDFMeta]; + render: [RenderPageOpts, RenderedPage]; +} diff --git a/packages/frontend/core/src/modules/pdf/renderer/renderer.ts b/packages/frontend/core/src/modules/pdf/renderer/renderer.ts new file mode 100644 index 0000000000000..6ca502772a016 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/renderer/renderer.ts @@ -0,0 +1,28 @@ +import { OpClient } from '@toeverything/infra/op'; + +import type { ClientOps } from './ops'; + +export class PDFRenderer extends OpClient { + private readonly worker: Worker; + + constructor() { + const worker = new Worker( + /* webpackChunkName: "pdf.worker" */ new URL( + './worker.ts', + import.meta.url + ) + ); + super(worker); + + this.worker = worker; + } + + override destroy() { + super.destroy(); + this.worker.terminate(); + } + + [Symbol.dispose]() { + this.destroy(); + } +} diff --git a/packages/frontend/core/src/modules/pdf/renderer/types.ts b/packages/frontend/core/src/modules/pdf/renderer/types.ts new file mode 100644 index 0000000000000..3e79550a1d80f --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/renderer/types.ts @@ -0,0 +1,16 @@ +export type PDFMeta = { + pageCount: number; + width: number; + height: number; +}; + +export type RenderPageOpts = { + pageNum: number; + width: number; + height: number; + scale?: number; +}; + +export type RenderedPage = RenderPageOpts & { + bitmap: ImageBitmap; +}; diff --git a/packages/frontend/core/src/modules/pdf/renderer/utils.ts b/packages/frontend/core/src/modules/pdf/renderer/utils.ts new file mode 100644 index 0000000000000..d2006f8f1eaac --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/renderer/utils.ts @@ -0,0 +1,15 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; + +export async function downloadBlobToBuffer(model: AttachmentBlockModel) { + const sourceId = model.sourceId; + if (!sourceId) { + throw new Error('Attachment not found'); + } + + const blob = await model.doc.blobSync.get(sourceId); + if (!blob) { + throw new Error('Attachment not found'); + } + + return await blob.arrayBuffer(); +} diff --git a/packages/frontend/core/src/modules/pdf/renderer/worker.ts b/packages/frontend/core/src/modules/pdf/renderer/worker.ts new file mode 100644 index 0000000000000..b435c8006ea03 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/renderer/worker.ts @@ -0,0 +1,140 @@ +import { OpConsumer, transfer } from '@toeverything/infra/op'; +import type { Document } from '@toeverything/pdf-viewer'; +import { + createPDFium, + PageRenderingflags, + Runtime, + Viewer, +} from '@toeverything/pdf-viewer'; +import { + BehaviorSubject, + combineLatestWith, + filter, + from, + map, + Observable, + ReplaySubject, + share, + switchMap, +} from 'rxjs'; + +import type { ClientOps } from './ops'; +import type { PDFMeta, RenderPageOpts } from './types'; + +class PDFRendererBackend extends OpConsumer { + private readonly viewer$: Observable = from( + createPDFium().then(pdfium => { + return new Viewer(new Runtime(pdfium)); + }) + ); + + private readonly binary$ = new BehaviorSubject(null); + + private readonly doc$ = this.binary$.pipe( + filter(Boolean), + combineLatestWith(this.viewer$), + switchMap(([buffer, viewer]) => { + return new Observable(observer => { + const doc = viewer.open(buffer); + + if (!doc) { + observer.error(new Error('Document not opened')); + return; + } + + observer.next(doc); + + return () => { + doc.close(); + }; + }); + }), + share({ + connector: () => new ReplaySubject(1), + }) + ); + + private readonly docInfo$: Observable = this.doc$.pipe( + map(doc => { + if (!doc) { + throw new Error('Document not opened'); + } + + const firstPage = doc.page(0); + if (!firstPage) { + throw new Error('Document has no pages'); + } + + return { + pageCount: doc.pageCount(), + width: firstPage.width(), + height: firstPage.height(), + }; + }) + ); + + open({ data }: { data: ArrayBuffer }) { + this.binary$.next(new Uint8Array(data)); + return this.docInfo$; + } + + render(opts: RenderPageOpts) { + return this.doc$.pipe( + combineLatestWith(this.viewer$), + switchMap(([doc, viewer]) => { + if (!doc) { + throw new Error('Document not opened'); + } + + return from(this.renderPage(viewer, doc, opts)); + }), + map(bitmap => { + if (!bitmap) { + throw new Error('Failed to render page'); + } + + return transfer({ ...opts, bitmap }, [bitmap]); + }) + ); + } + + async renderPage(viewer: Viewer, doc: Document, opts: RenderPageOpts) { + const page = doc.page(opts.pageNum); + + if (!page) return; + + const width = Math.ceil(opts.width * (opts.scale ?? 1)); + const height = Math.ceil(opts.height * (opts.scale ?? 1)); + + const bitmap = viewer.createBitmap(width, height, 0); + bitmap.fill(0, 0, width, height); + page.render( + bitmap, + 0, + 0, + width, + height, + 0, + PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT + ); + + const data = new Uint8ClampedArray(bitmap.toUint8Array()); + const imageBitmap = await createImageBitmap( + new ImageData(data, width, height) + ); + + bitmap.close(); + page.close(); + + return imageBitmap; + } + + override listen(): void { + this.register('open', this.open.bind(this)); + this.register('render', this.render.bind(this)); + super.listen(); + } +} + +// @ts-expect-error how could we get correct postMessage signature for worker, exclude `window.postMessage` +new PDFRendererBackend(self).listen(); diff --git a/packages/frontend/core/src/modules/pdf/services/pdf.ts b/packages/frontend/core/src/modules/pdf/services/pdf.ts new file mode 100644 index 0000000000000..141b1731a96d0 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/services/pdf.ts @@ -0,0 +1,31 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { ObjectPool, Service } from '@toeverything/infra'; + +import { PDF } from '../entities/pdf'; + +// One PDF document one worker. + +export class PDFService extends Service { + PDFs = new ObjectPool({ + onDelete: pdf => { + pdf.dispose(); + }, + }); + + constructor() { + super(); + this.disposables.push(() => { + this.PDFs.clear(); + }); + } + + get(model: AttachmentBlockModel) { + let rc = this.PDFs.get(model.id); + + if (!rc) { + rc = this.PDFs.put(model.id, this.framework.createEntity(PDF, model)); + } + + return { pdf: rc.obj, release: rc.release }; + } +} diff --git a/packages/frontend/core/src/modules/pdf/views/components.tsx b/packages/frontend/core/src/modules/pdf/views/components.tsx new file mode 100644 index 0000000000000..abffd86fdcc0a --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/views/components.tsx @@ -0,0 +1,185 @@ +import { Scrollable } from '@affine/component'; +import clsx from 'clsx'; +import { type CSSProperties, forwardRef, memo } from 'react'; +import type { VirtuosoProps } from 'react-virtuoso'; + +import * as styles from './styles.css'; + +export type PDFVirtuosoContext = { + width: number; + height: number; + pageClassName?: string; + onPageSelect?: (index: number) => void; +}; + +export type PDFVirtuosoProps = VirtuosoProps; + +export const Scroller = forwardRef( + ({ context: _, ...props }, ref) => { + return ( + + + + + ); + } +); + +Scroller.displayName = 'pdf-virtuoso-scroller'; + +export const List = forwardRef( + ({ context: _, className, ...props }, ref) => { + return ( +
+ ); + } +); + +List.displayName = 'pdf-virtuoso-list'; + +export const ListWithSmallGap = forwardRef( + ({ context: _, className, ...props }, ref) => { + return ( + + ); + } +); + +ListWithSmallGap.displayName = 'pdf-virtuoso-small-gap-list'; + +export const Item = forwardRef( + ({ context: _, ...props }, ref) => { + return
; + } +); + +Item.displayName = 'pdf-virtuoso-item'; + +export const ListPadding = () => ( +
+); + +export const LoadingSvg = memo(function LoadingSvg({ + style, + className, +}: { + style?: CSSProperties; + className?: string; +}) { + return ( + + + + + + + + + + + + + + + ); +}); diff --git a/packages/frontend/core/src/modules/pdf/views/index.ts b/packages/frontend/core/src/modules/pdf/views/index.ts new file mode 100644 index 0000000000000..09e3135cad0ae --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/views/index.ts @@ -0,0 +1,11 @@ +export { + Item, + List, + ListPadding, + ListWithSmallGap, + LoadingSvg, + type PDFVirtuosoContext, + type PDFVirtuosoProps, + Scroller, +} from './components'; +export { PDFPageRenderer } from './page-renderer'; diff --git a/packages/frontend/core/src/modules/pdf/views/page-renderer.tsx b/packages/frontend/core/src/modules/pdf/views/page-renderer.tsx new file mode 100644 index 0000000000000..80a3dcb6bb840 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/views/page-renderer.tsx @@ -0,0 +1,84 @@ +import { useI18n } from '@affine/i18n'; +import { useLiveData } from '@toeverything/infra'; +import { useEffect, useRef, useState } from 'react'; + +import type { PDF } from '../entities/pdf'; +import type { PDFPage } from '../entities/pdf-page'; +import { LoadingSvg } from './components'; +import * as styles from './styles.css'; + +interface PDFPageProps { + pdf: PDF; + width: number; + height: number; + pageNum: number; + scale?: number; + className?: string; + onSelect?: (pageNum: number) => void; +} + +export const PDFPageRenderer = ({ + pdf, + width, + height, + pageNum, + className, + onSelect, + scale = window.devicePixelRatio, +}: PDFPageProps) => { + const t = useI18n(); + const [pdfPage, setPdfPage] = useState(null); + const canvasRef = useRef(null); + const img = useLiveData(pdfPage?.bitmap$ ?? null); + const error = useLiveData(pdfPage?.error$ ?? null); + const style = { width, aspectRatio: `${width} / ${height}` }; + + useEffect(() => { + const { page, release } = pdf.page(pageNum, `${width}:${height}:${scale}`); + setPdfPage(page); + + return release; + }, [pdf, width, height, pageNum, scale]); + + useEffect(() => { + pdfPage?.render({ width, height, scale }); + + return pdfPage?.render.unsubscribe; + }, [pdfPage, width, height, scale]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + if (!img) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + canvas.width = width * scale; + canvas.height = height * scale; + ctx.drawImage(img, 0, 0); + }, [img, width, height, scale]); + + if (error) { + return ( +
+

+ {t['com.affine.pdf.page.render.error']()} +

+
+ ); + } + + return ( +
onSelect?.(pageNum)} + > + {img === null ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/packages/frontend/core/src/modules/pdf/views/styles.css.ts b/packages/frontend/core/src/modules/pdf/views/styles.css.ts new file mode 100644 index 0000000000000..7ded9648adaf7 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/views/styles.css.ts @@ -0,0 +1,64 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const virtuoso = style({ + width: '100%', +}); + +export const virtuosoList = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: '20px', + selectors: { + '&.small-gap': { + gap: '12px', + }, + }, +}); + +export const virtuosoItem = style({ + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); + +export const pdfPage = style({ + overflow: 'hidden', + 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 pdfPageError = style({ + display: 'flex', + alignSelf: 'center', + justifyContent: 'center', + overflow: 'hidden', + textWrap: 'wrap', + width: '100%', + wordBreak: 'break-word', + fontSize: 14, + lineHeight: '22px', + fontWeight: 400, + color: cssVarV2('text/primary'), +}); + +export const pdfPageCanvas = style({ + width: '100%', +}); + +export const pdfLoading = style({ + display: 'flex', + alignSelf: 'center', + width: '100%', + height: '100%', + maxWidth: '537px', +}); 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 2ce82f61d8801..fb05b24c3f4f3 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 => { @@ -153,6 +166,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..f812144781818 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx @@ -0,0 +1,25 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { useMemo } from 'react'; + +import { AttachmentViewer } from '../../../../components/attachment-viewer'; +import { useEditor } from '../utils'; + +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 model = blocksuiteDoc?.getBlock(blockId)?.model; + if (!model) return null; + return model as AttachmentBlockModel; + }, [blockId, blocksuiteDoc]); + + return model === null ? null : ; +}; diff --git a/packages/frontend/core/src/modules/peek-view/view/image-preview/index.tsx b/packages/frontend/core/src/modules/peek-view/view/image-preview/index.tsx index 55b74a2dec068..681870231b8d9 100644 --- a/packages/frontend/core/src/modules/peek-view/view/image-preview/index.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/image-preview/index.tsx @@ -17,7 +17,6 @@ import { } from '@blocksuite/icons/rc'; import { useService } from '@toeverything/infra'; import clsx from 'clsx'; -import { fileTypeFromBuffer } from 'file-type'; import { useErrorBoundary } from 'foxact/use-error-boundary'; import type { PropsWithChildren, ReactElement } from 'react'; import { @@ -32,6 +31,10 @@ import type { FallbackProps } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary'; import useSWR from 'swr'; +import { + downloadResourceWithUrl, + resourceUrlToBlob, +} from '../../../../utils/resource'; import { PeekViewService } from '../../services/peek-view'; import { useEditor } from '../utils'; import { useZoomControls } from './hooks/use-zoom'; @@ -41,30 +44,8 @@ const filterImageBlock = (block: BlockModel): block is ImageBlockModel => { return block.flavour === 'affine:image'; }; -async function imageUrlToBlob(url: string): Promise { - const buffer = await fetch(url).then(response => { - return 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 image to blob', error); - } - return; -} - async function copyImageToClipboard(url: string) { - const blob = await imageUrlToBlob(url); + const blob = await resourceUrlToBlob(url); if (!blob) { return; } @@ -77,23 +58,6 @@ async function copyImageToClipboard(url: string) { } } -async function saveBufferToFile(url: string, filename: string) { - // given input url may not have correct mime type - const blob = await imageUrlToBlob(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 type ImagePreviewModalProps = { docId: string; blockId: string; @@ -193,7 +157,7 @@ const ImagePreviewModalImpl = ({ const downloadHandler = useAsyncCallback(async () => { const url = imageRef.current?.src; if (url) { - await saveBufferToFile(url, caption || blockModel?.id || 'image'); + await downloadResourceWithUrl(url, caption || blockModel?.id || 'image'); } }, [caption, blockModel?.id]); 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 f2fe712a6090c..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 @@ -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 { @@ -13,6 +14,7 @@ import { type PeekViewModalContainerProps, } from './modal-container'; import { + AttachmentPeekViewControls, DefaultPeekViewControls, DocPeekViewControls, } from './peek-view-controls'; @@ -25,6 +27,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 ; } + 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/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/packages/frontend/core/src/modules/workbench/entities/workbench.ts b/packages/frontend/core/src/modules/workbench/entities/workbench.ts index bd1fe328cea15..932341942a50e 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}/attachments/${blockId}`, options); + } + openCollections(options?: WorkbenchOpenOptions) { this.open('/collection', options); } diff --git a/packages/frontend/core/src/utils/resource.ts b/packages/frontend/core/src/utils/resource.ts new file mode 100644 index 0000000000000..773200176af0f --- /dev/null +++ b/packages/frontend/core/src/utils/resource.ts @@ -0,0 +1,42 @@ +import { fileTypeFromBuffer } from 'file-type'; + +export async function resourceUrlToBlob( + 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 resource to blob', error); + } + return; +} + +export async function downloadBlob(blob: Blob, filename: string) { + 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 downloadResourceWithUrl(url: string, filename: string) { + // given input url may not have correct mime type + const blob = await resourceUrlToBlob(url); + if (!blob) return; + + await downloadBlob(blob, filename); +} diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 06848f105047f..e2de0ff17c5b8 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -19,4 +19,4 @@ "ur": 3, "zh-Hans": 95, "zh-Hant": 92 -} \ No newline at end of file +} diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 53586303f9190..33392f7ebd1e4 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -955,6 +955,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", @@ -1458,5 +1461,8 @@ "com.affine.m.selector.remove-warning.where-tag": "tag", "com.affine.m.selector.remove-warning.where-folder": "folder", "com.affine.m.selector.journal-menu.today-activity": "Today's activity", - "com.affine.m.selector.journal-menu.conflicts": "Duplicate Entries in Today's Journal" + "com.affine.m.selector.journal-menu.conflicts": "Duplicate Entries in Today's Journal", + "com.affine.attachment.preview.error.title": "Unable to preview this file", + "com.affine.attachment.preview.error.subtitle": "file type not supported.", + "com.affine.pdf.page.render.error": "Failed to render page." } 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..8b3ddf2528e07 --- /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('pdf-viewer'); + await expect(attachmentViewer).toBeVisible(); + + await page.waitForTimeout(500); + + const pageCount = attachmentViewer.locator('.page-cursor'); + expect(await pageCount.textContent()).toBe('1'); + const pageTotal = attachmentViewer.locator('.page-count'); + 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('pdf-viewer'); + + await page.waitForTimeout(500); + + await expect(attachmentViewer).toBeVisible(); + + await clickPeekViewControl(page, 1); + + await page.waitForTimeout(500); + + const pageCount = attachmentViewer.locator('.page-cursor'); + expect(await pageCount.textContent()).toBe('1'); + const pageTotal = attachmentViewer.locator('.page-count'); + 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/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 de47078c792ee..17ee6852d465c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -430,6 +430,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.1" "@toeverything/theme": "npm:^1.0.17" "@types/animejs": "npm:^3.1.12" "@types/bytes": "npm:^3.1.4" @@ -445,6 +446,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" @@ -13110,6 +13112,30 @@ __metadata: languageName: unknown linkType: soft +"@toeverything/pdf-viewer-types@npm:0.1.1": + version: 0.1.1 + resolution: "@toeverything/pdf-viewer-types@npm:0.1.1" + checksum: 10/cd7e7785097924778899febaaffecb893451aa9eb0117a5b28047004f788efb6ec1facab74e93d6ac3464033d9a4ad57f09f83c6f45ab3ebf67ceedf2180fd23 + languageName: node + linkType: hard + +"@toeverything/pdf-viewer@npm:^0.1.1": + version: 0.1.1 + resolution: "@toeverything/pdf-viewer@npm:0.1.1" + dependencies: + "@toeverything/pdf-viewer-types": "npm:0.1.1" + "@toeverything/pdfium": "npm:0.1.1" + checksum: 10/7fc4ac3156504467517ed4d99d5ba5ccd3beae0759464a6a9a3a62f2fc09acff44fbee9c2451cc437d97313c51746a631fb0d367b365f19469079588ba7c5ce0 + languageName: node + linkType: hard + +"@toeverything/pdfium@npm:0.1.1": + version: 0.1.1 + resolution: "@toeverything/pdfium@npm:0.1.1" + checksum: 10/3d832eaef3ea4aba142561ed8529e216ed739026e7680e5d4fcd05d56fdd458a06273d6b18f1dbc0af9edcc538fa6fd0246757ebb4957a048a8d69bbf579549a + languageName: node + linkType: hard + "@toeverything/theme@npm:^1.0.15, @toeverything/theme@npm:^1.0.17": version: 1.0.17 resolution: "@toeverything/theme@npm:1.0.17" @@ -20500,7 +20526,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