From f4abe3968972703cf80073d8ed3ce1d92814e601 Mon Sep 17 00:00:00 2001 From: Fangdun Tsai Date: Tue, 12 Nov 2024 19:12:31 +0800 Subject: [PATCH] feat(core): pdf preview (#8569) Co-authored-by: forehalo --- packages/common/infra/src/op/README.md | 2 +- .../infra/src/op/__tests__/client.spec.ts | 28 +-- packages/common/infra/src/op/client.ts | 33 ++- packages/common/infra/src/op/consumer.ts | 13 +- packages/common/infra/src/op/message.ts | 11 + .../electron/src/main/shared-state-schema.ts | 2 + packages/frontend/component/package.json | 1 + packages/frontend/core/package.json | 2 + .../components/attachment-viewer/error.tsx | 131 +++++++++++ .../components/attachment-viewer/index.tsx | 52 +++++ .../attachment-viewer/pdf-viewer.tsx | 212 ++++++++++++++++++ .../attachment-viewer/styles.css.ts | 183 +++++++++++++++ .../components/attachment-viewer/titlebar.tsx | 105 +++++++++ .../src/components/attachment-viewer/utils.ts | 45 ++++ .../pages/workspace/attachment/index.css.ts | 10 + .../pages/workspace/attachment/index.tsx | 122 ++++++++++ .../core/src/desktop/workbench-router.ts | 4 + packages/frontend/core/src/modules/index.ts | 2 + .../core/src/modules/pdf/entities/pdf-page.ts | 39 ++++ .../core/src/modules/pdf/entities/pdf.ts | 74 ++++++ .../frontend/core/src/modules/pdf/index.ts | 19 ++ .../core/src/modules/pdf/renderer/index.ts | 3 + .../core/src/modules/pdf/renderer/ops.ts | 8 + .../core/src/modules/pdf/renderer/renderer.ts | 28 +++ .../core/src/modules/pdf/renderer/types.ts | 16 ++ .../core/src/modules/pdf/renderer/utils.ts | 15 ++ .../core/src/modules/pdf/renderer/worker.ts | 140 ++++++++++++ .../core/src/modules/pdf/services/pdf.ts | 31 +++ .../core/src/modules/pdf/views/components.tsx | 185 +++++++++++++++ .../core/src/modules/pdf/views/index.ts | 11 + .../src/modules/pdf/views/page-renderer.tsx | 84 +++++++ .../core/src/modules/pdf/views/styles.css.ts | 64 ++++++ .../modules/peek-view/entities/peek-view.ts | 21 ++ .../view/attachment-preview/index.tsx | 25 +++ .../peek-view/view/image-preview/index.tsx | 48 +--- .../peek-view/view/peek-view-controls.tsx | 65 ++++++ .../peek-view/view/peek-view-manager.tsx | 15 ++ .../core/src/modules/workbench/constants.tsx | 4 + .../modules/workbench/entities/workbench.ts | 8 + packages/frontend/core/src/utils/resource.ts | 42 ++++ .../i18n/src/i18n-completenesses.json | 2 +- packages/frontend/i18n/src/resources/en.json | 8 +- .../e2e/attachment-preview.spec.ts | 118 ++++++++++ tests/fixtures/lorem-ipsum.pdf | Bin 0 -> 23286 bytes tools/cli/src/webpack/config.ts | 4 +- yarn.lock | 28 ++- 46 files changed, 1968 insertions(+), 95 deletions(-) create mode 100644 packages/frontend/core/src/components/attachment-viewer/error.tsx create mode 100644 packages/frontend/core/src/components/attachment-viewer/index.tsx create mode 100644 packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx create mode 100644 packages/frontend/core/src/components/attachment-viewer/styles.css.ts create mode 100644 packages/frontend/core/src/components/attachment-viewer/titlebar.tsx create mode 100644 packages/frontend/core/src/components/attachment-viewer/utils.ts create mode 100644 packages/frontend/core/src/desktop/pages/workspace/attachment/index.css.ts create mode 100644 packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx create mode 100644 packages/frontend/core/src/modules/pdf/entities/pdf-page.ts create mode 100644 packages/frontend/core/src/modules/pdf/entities/pdf.ts create mode 100644 packages/frontend/core/src/modules/pdf/index.ts create mode 100644 packages/frontend/core/src/modules/pdf/renderer/index.ts create mode 100644 packages/frontend/core/src/modules/pdf/renderer/ops.ts create mode 100644 packages/frontend/core/src/modules/pdf/renderer/renderer.ts create mode 100644 packages/frontend/core/src/modules/pdf/renderer/types.ts create mode 100644 packages/frontend/core/src/modules/pdf/renderer/utils.ts create mode 100644 packages/frontend/core/src/modules/pdf/renderer/worker.ts create mode 100644 packages/frontend/core/src/modules/pdf/services/pdf.ts create mode 100644 packages/frontend/core/src/modules/pdf/views/components.tsx create mode 100644 packages/frontend/core/src/modules/pdf/views/index.ts create mode 100644 packages/frontend/core/src/modules/pdf/views/page-renderer.tsx create mode 100644 packages/frontend/core/src/modules/pdf/views/styles.css.ts create mode 100644 packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx create mode 100644 packages/frontend/core/src/utils/resource.ts create mode 100644 tests/affine-local/e2e/attachment-preview.spec.ts create mode 100644 tests/fixtures/lorem-ipsum.pdf diff --git a/packages/common/infra/src/op/README.md b/packages/common/infra/src/op/README.md index 232dbb04fc..8a1aced1b4 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 2803d0ba5d..ffc24a6690 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 bdf66b0d0a..c469028f96 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 2f94300e34..0b24c4b5ea 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 852cdc6d29..aa58fd3b0c 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 8d78745ca7..03b13f5157 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 7faace18b1..4920b05733 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 e68f12bff3..96db170259 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 0000000000..5d56704dd3 --- /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 0000000000..9ab69a8558 --- /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 0000000000..a60926c7f6 --- /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 0000000000..65525db4af --- /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 0000000000..8f826513d8 --- /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 0000000000..fb0e5c8f83 --- /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 0000000000..d678bbac3c --- /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 0000000000..5f042fe7a3 --- /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 0e810fa39b..8c47547975 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 51c9b9501e..26b7181de5 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 0000000000..b1f91b14ea --- /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 0000000000..bcf12d33b7 --- /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 0000000000..998173d9ce --- /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 0000000000..d3e9a83e74 --- /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 0000000000..946fa6b245 --- /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 0000000000..6ca502772a --- /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 0000000000..3e79550a1d --- /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 0000000000..d2006f8f1e --- /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 0000000000..b435c8006e --- /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 0000000000..141b1731a9 --- /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 0000000000..abffd86fdc --- /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 0000000000..09e3135cad --- /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 0000000000..80a3dcb6bb --- /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 0000000000..7ded9648ad --- /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 2ce82f61d8..fb05b24c3f 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 0000000000..f812144781 --- /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 55b74a2dec..681870231b 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 01fbdfdc52..5bf02a34d4 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 f2fe712a60..ea039b04fa 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 719d816fd4..7439354f5e 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 bd1fe328ce..932341942a 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 0000000000..773200176a --- /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 06848f1050..e2de0ff17c 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 53586303f9..33392f7ebd 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 0000000000..8b3ddf2528 --- /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 0000000000000000000000000000000000000000..784a17c5b5a4a440a439edf56d18953e4b60bca8 GIT binary patch literal 23286 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 - 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 de47078c79..17ee6852d4 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