From 901dde96de2acc880b0eed18af7d514409981b64 Mon Sep 17 00:00:00 2001 From: Fangdun Tsai Date: Mon, 21 Oct 2024 20:34:49 +0800 Subject: [PATCH 1/6] feat(core): PDF preview --- .../electron/src/main/shared-state-schema.ts | 2 + packages/frontend/component/package.json | 7 +- .../components/attachment-viewer/error.tsx | 28 ++ .../components/attachment-viewer/index.tsx | 30 ++ .../attachment-viewer/styles.css.ts | 172 ++++++++ .../components/attachment-viewer/titlebar.tsx | 99 +++++ .../src/components/attachment-viewer/utils.ts | 40 ++ .../components/attachment-viewer/viewer.tsx | 417 ++++++++++++++++++ .../attachment-viewer/worker/types.ts | 33 ++ .../attachment-viewer/worker/worker.ts | 164 +++++++ .../pages/workspace/attachment/index.tsx | 87 ++++ .../core/src/desktop/workbench-router.ts | 4 + .../modules/peek-view/entities/peek-view.ts | 21 + .../view/attachment-preview/index.tsx | 43 ++ .../peek-view/view/peek-view-manager.tsx | 12 + .../core/src/modules/workbench/constants.tsx | 4 + tools/cli/src/webpack/config.ts | 4 +- yarn.lock | 32 +- 18 files changed, 1194 insertions(+), 5 deletions(-) create mode 100644 packages/frontend/component/src/components/attachment-viewer/error.tsx create mode 100644 packages/frontend/component/src/components/attachment-viewer/index.tsx create mode 100644 packages/frontend/component/src/components/attachment-viewer/styles.css.ts create mode 100644 packages/frontend/component/src/components/attachment-viewer/titlebar.tsx create mode 100644 packages/frontend/component/src/components/attachment-viewer/utils.ts create mode 100644 packages/frontend/component/src/components/attachment-viewer/viewer.tsx create mode 100644 packages/frontend/component/src/components/attachment-viewer/worker/types.ts create mode 100644 packages/frontend/component/src/components/attachment-viewer/worker/worker.ts create mode 100644 packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx create mode 100644 packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx 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 8b9f0db6149bd..d569a2a614d63 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -14,7 +14,7 @@ }, "peerDependencies": { "@blocksuite/affine": "*", - "@blocksuite/icons": "2.1.67" + "@blocksuite/icons": "*" }, "dependencies": { "@affine/cli": "workspace:*", @@ -24,6 +24,7 @@ "@affine/i18n": "workspace:*", "@atlaskit/pragmatic-drag-and-drop": "^1.2.1", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", + "@blocksuite/icons": "2.1.69", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@radix-ui/react-avatar": "^1.0.4", @@ -40,11 +41,14 @@ "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-visually-hidden": "^1.1.0", + "@toeverything/pdf-viewer": "^0.1.0", "@toeverything/theme": "^1.0.17", "@vanilla-extract/dynamic": "^2.1.0", "check-password-strength": "^2.0.10", "clsx": "^2.1.0", "dayjs": "^1.11.10", + "file-type": "^19.1.0", + "filesize": "^10.1.6", "jotai": "^2.8.0", "lit": "^3.1.2", "lodash-es": "^4.17.21", @@ -57,6 +61,7 @@ "react-paginate": "^8.2.0", "react-router-dom": "^6.22.3", "react-transition-state": "^2.1.1", + "react-virtuoso": "^4.7.8", "sonner": "^1.4.41", "swr": "^2.2.5", "zod": "^3.22.4" diff --git a/packages/frontend/component/src/components/attachment-viewer/error.tsx b/packages/frontend/component/src/components/attachment-viewer/error.tsx new file mode 100644 index 0000000000000..2a9bf9550ce7d --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/error.tsx @@ -0,0 +1,28 @@ +import type { AttachmentBlockModel } from '@blocksuite/blocks'; +import { ArrowDownBigIcon, PageIcon } from '@blocksuite/icons/rc'; +import clsx from 'clsx'; + +import { Button } from '../../ui/button'; +import * as styles from './styles.css'; + +interface ErrorProps { + model: AttachmentBlockModel; + ext: string; + isPDF: boolean; +} + +export const Error = ({ ext }: ErrorProps) => { + return ( +
+ +

Unable to preview this file

+

.{ext} file type not supported.

+
+ + +
+
+ ); +}; diff --git a/packages/frontend/component/src/components/attachment-viewer/index.tsx b/packages/frontend/component/src/components/attachment-viewer/index.tsx new file mode 100644 index 0000000000000..f2ee24c081895 --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/index.tsx @@ -0,0 +1,30 @@ +import type { AttachmentBlockModel } from '@blocksuite/blocks'; +import { filesize } from 'filesize'; +import { useMemo } from 'react'; + +import { Error } from './error'; +import * as styles from './styles.css'; +import { Titlebar } from './titlebar'; +import { Viewer } from './viewer'; + +export type AttachmentViewerProps = { + model: AttachmentBlockModel; +}; + +export const AttachmentViewer = ({ model }: AttachmentViewerProps) => { + const props = useMemo(() => { + const pieces = model.name.split('.'); + const ext = pieces.pop() || ''; + const name = pieces.join('.'); + const isPDF = ext === 'pdf'; + const size = filesize(model.size); + return { model, name, ext, size, isPDF }; + }, [model]); + + return ( +
+ + {props.isPDF ? : } +
+ ); +}; diff --git a/packages/frontend/component/src/components/attachment-viewer/styles.css.ts b/packages/frontend/component/src/components/attachment-viewer/styles.css.ts new file mode 100644 index 0000000000000..193fa68e99391 --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/styles.css.ts @@ -0,0 +1,172 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const viewerContainer = style({ + position: 'relative', + display: 'flex', + flexDirection: 'column', + width: '100%', + height: '100%', +}); + +export const titlebar = style({ + display: 'flex', + justifyContent: 'space-between', + height: '52px', + padding: '10px 8px', + background: cssVarV2('layer/background/primary'), + fontSize: '12px', + fontWeight: 400, + color: cssVarV2('text/secondary'), + borderTopWidth: '0.5px', + borderTopStyle: 'solid', + borderTopColor: cssVarV2('layer/insideBorder/border'), +}); + +export const titlebarChild = style({ + selectors: { + [`${titlebar} > &`]: { + display: 'flex', + gap: '12px', + alignItems: 'center', + paddingLeft: '12px', + paddingRight: '12px', + }, + '&.zoom:not(.show)': { + display: 'none', + }, + }, +}); + +export const titlebarName = style({ + display: 'flex', +}); + +export const body = style({ + display: 'flex', + flex: 1, + position: 'relative', + selectors: { + '&:before': { + position: 'absolute', + // position: 'fixed', + content: '', + top: 0, + right: 0, + bottom: 0, + left: 0, + // width: '100%', + // height: '100%', + // zIndex: -1, + }, + '&:not(.gridding):before': { + backgroundColor: cssVarV2('layer/background/secondary'), + }, + '&.gridding:before': { + opacity: 0.25, + backgroundSize: '20px 20px', + backgroundImage: `linear-gradient(${cssVarV2('button/grabber/default')} 1px, transparent 1px), linear-gradient(to right, ${cssVarV2('button/grabber/default')} 1px, transparent 1px)`, + }, + }, +}); + +export const virtuoso = style({ + width: '100%', +}); + +export const error = style({ + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: '4px', +}); + +export const errorTitle = style({ + fontSize: '15px', + fontWeight: 500, + lineHeight: '24px', + color: cssVarV2('text/primary'), + marginTop: '12px', +}); + +export const errorMessage = style({ + fontSize: '12px', + fontWeight: 500, + lineHeight: '20px', + color: cssVarV2('text/tertiary'), +}); + +export const errorBtns = style({ + display: 'flex', + flexDirection: 'column', + gap: '10px', + marginTop: '28px', +}); + +export const viewerPage = style({ + margin: '20px auto', + maxWidth: 'calc(100% - 40px)', + background: cssVarV2('layer/white'), + boxSizing: 'border-box', + borderWidth: '1px', + borderStyle: 'solid', + borderColor: cssVarV2('layer/insideBorder/border'), + boxShadow: + '0px 4px 20px 0px var(--transparent-black-200, rgba(0, 0, 0, 0.10))', +}); + +export const thumbnails = style({ + position: 'absolute', + boxSizing: 'border-box', + width: '120px', + padding: '12px 0', + right: '30px', + bottom: '30px', + borderRadius: '8px', + borderWidth: '1px', + borderStyle: 'solid', + borderColor: cssVarV2('layer/insideBorder/border'), + backgroundColor: cssVarV2('layer/background/primary'), + boxShadow: cssVarV2('shadow/overlayPanelShadow/2-color'), + fontSize: '12px', + fontWeight: 500, + lineHeight: '20px', + color: cssVarV2('text/secondary'), +}); + +export const thumbnailsPages = style({ + display: 'flex', + flexDirection: 'column', + // gap: '12px', + selectors: { + '&.collapsed': { + display: 'none', + }, + '&:not(.collapsed)': { + marginBottom: '8px', + }, + }, +}); + +export const thumbnailsPage = style({ + margin: '0px 12px 12px', + display: 'flex', + overflow: 'clip', + // width: '100%', + borderRadius: '4px', + borderWidth: '1px', + borderStyle: 'solid', + borderColor: cssVarV2('layer/insideBorder/border'), + selectors: { + '&.selected': { + borderColor: '#29A3FA', + }, + }, +}); + +export const thumbnailsIndicator = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '0 12px', +}); diff --git a/packages/frontend/component/src/components/attachment-viewer/titlebar.tsx b/packages/frontend/component/src/components/attachment-viewer/titlebar.tsx new file mode 100644 index 0000000000000..0874a8e10ac8f --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/titlebar.tsx @@ -0,0 +1,99 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { + EditIcon, + LocalDataIcon, + MoreHorizontalIcon, + ZoomDownIcon, + ZoomUpIcon, +} from '@blocksuite/icons/rc'; +import clsx from 'clsx'; +import { useState } from 'react'; + +import { IconButton } from '../../ui/button'; +import { Menu, MenuItem } from '../../ui/menu'; +import * as styles from './styles.css'; +import { saveBufferToFile } from './utils'; + +const items = [ + { + name: 'Rename', + icon: , + action(_model: AttachmentBlockModel) {}, + }, + { + name: 'Download', + icon: , + action(model: AttachmentBlockModel) { + const { sourceId, name } = model; + if (!sourceId) return; + saveBufferToFile(sourceId, name).catch(console.error); + }, + }, +]; + +export const MenuItems = ({ model }: { model: AttachmentBlockModel }) => + items.map(({ name, icon, action }) => ( + action(model)} prefixIcon={icon}> + {name} + + )); + +export interface TitlebarProps { + model: AttachmentBlockModel; + name: string; + ext: string; + size: string; + isPDF: boolean; + zoom?: number; +} + +export const Titlebar = ({ + model, + name, + ext, + size, + zoom = 100, + isPDF = false, +}: TitlebarProps) => { + const [openMenu, setOpenMenu] = useState(false); + + return ( +
+
+
+
{name}
+ .{ext} +
+
{size}
+ }> + } + rootOptions={{ + open: openMenu, + onOpenChange: setOpenMenu, + }} + contentOptions={{ + side: 'bottom', + align: 'center', + avoidCollisions: false, + }} + > + }> + +
+
+ }> +
{zoom}%
+ }> +
+
+ ); +}; diff --git a/packages/frontend/component/src/components/attachment-viewer/utils.ts b/packages/frontend/component/src/components/attachment-viewer/utils.ts new file mode 100644 index 0000000000000..2238b3eb09857 --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/utils.ts @@ -0,0 +1,40 @@ +import { fileTypeFromBuffer } from 'file-type'; + +export async function saveBufferToFile(url: string, filename: string) { + // given input url may not have correct mime type + const blob = await attachmentUrlToBlob(url); + if (!blob) { + return; + } + + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = filename; + document.body.append(a); + a.click(); + a.remove(); + URL.revokeObjectURL(blobUrl); +} + +export async function attachmentUrlToBlob( + url: string +): Promise { + const buffer = await fetch(url).then(response => response.arrayBuffer()); + + if (!buffer) { + console.warn('Could not get blob'); + return; + } + try { + const type = await fileTypeFromBuffer(buffer); + if (!type) { + return; + } + const blob = new Blob([buffer], { type: type.mime }); + return blob; + } catch (error) { + console.error('Error converting attachment to blob', error); + } + return; +} diff --git a/packages/frontend/component/src/components/attachment-viewer/viewer.tsx b/packages/frontend/component/src/components/attachment-viewer/viewer.tsx new file mode 100644 index 0000000000000..78b19d60b0210 --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/viewer.tsx @@ -0,0 +1,417 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc'; +import clsx from 'clsx'; +import { debounce } from 'lodash-es'; +import type { HTMLAttributes, PropsWithChildren, ReactElement } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { VirtuosoHandle } from 'react-virtuoso'; +import { Virtuoso } from 'react-virtuoso'; + +import { IconButton } from '../../ui/button'; +import { Scrollable } from '../../ui/scrollbar'; +import * as styles from './styles.css'; +// import { observeResize } from '../../utils'; +import type { MessageData, MessageDataType } from './worker/types'; +import { MessageOp, State } from './worker/types'; + +const Page = React.memo( + ({ + width, + height, + className, + }: { + index: number; + width: number; + height: number; + className: string; + }) => { + return ( +
+ +
+ ); + } +); + +Page.displayName = 'viewer-page'; + +const THUMBNAIL_WIDTH = 94; + +const Thumbnail = React.memo( + ({ + index, + width, + height, + className, + onSelect, + }: { + index: number; + width: number; + height: number; + className: string; + onSelect: (index: number) => void; + }) => { + return ( +
onSelect(index)}> + +
+ ); + } +); + +Thumbnail.displayName = 'viewer-thumbnail'; + +const Scroller = React.forwardRef< + HTMLDivElement, + PropsWithChildren> +>(({ style, ...props }, ref) => { + return ( + + + + + ); +}); + +Scroller.displayName = 'viewer-scroller'; + +interface ViewerProps { + model: AttachmentBlockModel; +} + +export const Viewer = ({ model }: ViewerProps): ReactElement => { + const [connected, setConnected] = useState(false); + const [loaded, setLoaded] = useState(false); + const [docInfo, setDocInfo] = useState({ + cursor: 0, + total: 0, + width: 1, + height: 1, + }); + const viewerRef = useRef(null); + const scrollerRef = useRef(null); + const scrollerHandleRef = useRef(null); + const workerRef = useRef(null); + + const [mainVisibleRange, setMainVisibleRange] = useState({ + startIndex: 0, + endIndex: 0, + }); + + const [collapsed, setCollapsed] = useState(true); + const thumbnailsScrollerHandleRef = useRef(null); + const thumbnailsScrollerRef = useRef(null); + const [thumbnailsVisibleRange, setThumbnailsVisibleRange] = useState({ + startIndex: 0, + endIndex: 0, + }); + + const post = useCallback( + ( + type: T, + data?: MessageDataType[T], + transfer = [] + ) => { + workerRef.current?.postMessage( + { + state: State.Poll, + type, + [type]: data, + }, + transfer + ); + }, + [workerRef] + ); + + const render = useCallback( + (id: number, imageData: ImageData) => { + const el = scrollerRef.current; + if (!el) return; + + const canvas: HTMLCanvasElement | null = el.querySelector( + `[data-index="${id}"] canvas` + ); + if (!canvas) return; + if (canvas.dataset.rendered) return; + + // TODO(@fundon): improve + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.putImageData(imageData, 0, 0); + canvas.dataset.rendered = 'true'; + } + }, + [scrollerRef] + ); + + const renderThumbnail = useCallback( + (id: number, imageData: ImageData) => { + const el = thumbnailsScrollerRef.current; + if (!el) return; + + const canvas: HTMLCanvasElement | null = el.querySelector( + `[data-index="${id}"] canvas` + ); + if (!canvas) return; + if (canvas.dataset.rendered) return; + + // TODO(@fundon): improve + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.putImageData(imageData, 0, 0); + canvas.dataset.rendered = 'true'; + } + }, + [thumbnailsScrollerRef] + ); + + const onScroll = useCallback(() => { + const el = scrollerRef.current; + if (!el) return; + + const { scrollTop, scrollHeight } = el; + + setDocInfo(info => { + const cursor = Math.ceil(scrollTop / (scrollHeight / info.total)); + // thumbnailsScrollerHandleRef.current?.scrollToIndex(cursor) + return { + ...info, + cursor, + }; + }); + // }, [scrollerRef, thumbnailsScrollerHandleRef]); + }, [scrollerRef]); + + const onSelect = useCallback( + (index: number) => { + scrollerHandleRef.current?.scrollToIndex(index); + setDocInfo(info => ({ ...info, cursor: index })); + }, + [scrollerHandleRef] + ); + + const updateMainVisibleRange = useMemo( + () => debounce(setMainVisibleRange, 233, { leading: true, trailing: true }), + [setMainVisibleRange] + ); + + const updateThumbnailsVisibleRange = useMemo( + () => + debounce(setThumbnailsVisibleRange, 233, { + leading: true, + trailing: true, + }), + [setThumbnailsVisibleRange] + ); + + // useEffect(() => { + // const el = viewerRef.current; + // if (!el) return; + + // return observeResize(el, entry => { + // console.log(entry); + // }); + // }, []); + + useEffect(() => { + const { startIndex, endIndex } = mainVisibleRange; + let index = startIndex; + for (; index < endIndex + 1; index++) { + post(MessageOp.Render, { index, kind: 'page' }); + } + }, [mainVisibleRange, post]); + + useEffect(() => { + const { startIndex, endIndex } = thumbnailsVisibleRange; + let index = startIndex; + for (; index < endIndex + 1; index++) { + post(MessageOp.Render, { index, kind: 'thumbnail' }); + } + }, [thumbnailsVisibleRange, post]); + + useEffect(() => { + workerRef.current = new Worker( + /* webpackChunkName: "pdf.worker" */ new URL( + './worker/worker.ts', + import.meta.url + ) + ); + + async function process({ data }: MessageEvent) { + const { type, state } = data; + + if (type === MessageOp.Init) { + setConnected(state === State.Ready); + return; + } + if (type === MessageOp.Open) { + setLoaded(state === State.Ready); + return; + } + + if (state === State.Poll) return; + + switch (type) { + case MessageOp.ReadInfo: { + const action = data[type]; + setDocInfo(info => ({ ...info, ...action })); + break; + } + case MessageOp.Rendered: { + const { index, imageData, kind } = data[type]; + if (kind === 'page') { + render(index, imageData); + } else { + renderThumbnail(index, imageData); + } + break; + } + } + } + + workerRef.current.addEventListener('message', event => { + process(event).catch(console.error); + }); + + return () => { + workerRef.current?.terminate(); + }; + }, [model, post, render, renderThumbnail]); + + useEffect(() => { + if (!connected) return; + if (!model.sourceId) return; + + model.doc.blobSync + .get(model.sourceId) + .then(blob => { + if (!blob) return; + post(MessageOp.Open, { blob, dpi: window.devicePixelRatio }); + }) + .catch(console.error); + }, [connected, model, post]); + + useEffect(() => { + if (!loaded) return; + post(MessageOp.ReadInfo); + }, [loaded, post]); + + const pageContent = (index: number) => { + return ( + + ); + }; + + const thumbnailContent = (index: number) => { + return ( + + ); + }; + + const components = useMemo(() => { + return { + Scroller, + }; + }, []); + + return ( +
+ { + if (scrollerRef.current) return; + scrollerRef.current = scroller as HTMLElement; + }} + className={styles.virtuoso} + rangeChanged={updateMainVisibleRange} + increaseViewportBy={{ + top: docInfo.height * Math.min(5, docInfo.total), + bottom: docInfo.height * Math.min(5, docInfo.total), + }} + totalCount={docInfo.total} + itemContent={pageContent} + components={components} + /> +
+ {collapsed ? null : ( +
+ { + if (thumbnailsScrollerRef.current) return; + thumbnailsScrollerRef.current = scroller as HTMLElement; + }} + rangeChanged={updateThumbnailsVisibleRange} + className={styles.virtuoso} + totalCount={docInfo.total} + itemContent={thumbnailContent} + components={components} + /> +
+ )} +
+
+ {docInfo.cursor + 1}/{docInfo.total} +
+ : } + onClick={() => setCollapsed(state => !state)} + /> +
+
+
+ ); +}; diff --git a/packages/frontend/component/src/components/attachment-viewer/worker/types.ts b/packages/frontend/component/src/components/attachment-viewer/worker/types.ts new file mode 100644 index 0000000000000..4ec6b2a064931 --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/worker/types.ts @@ -0,0 +1,33 @@ +export enum State { + Poll, + Ready, +} + +export enum MessageOp { + Init, + Open, + ReadInfo, + Render, + Rendered, +} + +export type MessageDataMap = { + [MessageOp.Init]: undefined; + [MessageOp.Open]: { blob: Blob; dpi: number }; + [MessageOp.ReadInfo]: { total: number; width: number; height: number }; + [MessageOp.Render]: { index: number; kind: 'page' | 'thumbnail' }; + [MessageOp.Rendered]: { + index: number; + imageData: ImageData; + kind: 'page' | 'thumbnail'; + }; +}; + +export type MessageDataType = { + [P in keyof T]: T[P]; +}; + +export type MessageData = { + state: State; + type: T; +} & P; diff --git a/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts b/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts new file mode 100644 index 0000000000000..d6423a6083148 --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts @@ -0,0 +1,164 @@ +import { DebugLogger } from '@affine/debug'; +import type { Document } from '@toeverything/pdf-viewer'; +import { + createPDFium, + PageRenderingflags, + Runtime, + Viewer, +} from '@toeverything/pdf-viewer'; + +import type { MessageData, MessageDataType } from './types'; +import { MessageOp, State } from './types'; + +const logger = new DebugLogger('affine:pdf-worker'); + +let dpi = 2; +let inited = false; +let viewer: Viewer | null = null; +let doc: Document | undefined = undefined; + +const cached = new Map(); +const docInfo = { cursor: 0, total: 0, width: 1, height: 1 }; +const flags = PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT; + +function post(type: T, data?: MessageDataType[T]) { + self.postMessage({ state: State.Ready, type, [type]: data }); +} + +async function resizeImageData( + imageData: ImageData, + options: { + resizeWidth: number; + resizeHeight: number; + } +) { + const { resizeWidth: w, resizeHeight: h } = options; + const bitmap = await createImageBitmap( + imageData, + 0, + 0, + imageData.width, + imageData.height, + options + ); + const canvas = new OffscreenCanvas(w, h); + const ctx = canvas.getContext('2d'); + if (!ctx) return imageData; + ctx.drawImage(bitmap, 0, 0); + return ctx.getImageData(0, 0, w, h); +} + +async function start() { + logger.debug('pdf worker pending'); + self.postMessage({ state: State.Poll, type: MessageOp.Init }); + + const pdfium = await createPDFium(); + viewer = new Viewer(new Runtime(pdfium)); + inited = true; + + self.postMessage({ state: State.Ready, type: MessageOp.Init }); + logger.debug('pdf worker ready'); +} + +async function process({ data }: MessageEvent) { + if (!inited || !viewer) { + await start(); + } + + if (!viewer) return; + + const { type, state } = data; + + if (state !== State.Poll) return; + + switch (type) { + case MessageOp.Open: { + const action = data[type]; + if (!action?.blob) return; + + dpi = action.dpi; + doc = await viewer.openWithBlob(action.blob); + + if (!doc) return; + + post(MessageOp.Open); + break; + } + + case MessageOp.ReadInfo: { + if (!doc) return; + + const page = doc.page(0); + if (page) { + docInfo.cursor = 0; + docInfo.total = doc.pageCount(); + docInfo.height = page.height(); + docInfo.width = page.width(); + page.close(); + post(MessageOp.ReadInfo, docInfo); + } + break; + } + + case MessageOp.Render: { + if (!doc) return; + + const { index, kind } = data[type]; + + let imageData = cached.size > 0 ? cached.get(index) : undefined; + if (imageData) { + if (kind === 'thumbnail') { + const resizeWidth = (94 * dpi) >> 0; + const resizeHeight = + ((docInfo.height / docInfo.width) * resizeWidth) >> 0; + imageData = await resizeImageData(imageData, { + resizeWidth, + resizeHeight, + }); + } + + post(MessageOp.Rendered, { index, imageData, kind }); + return; + } + + const width = Math.ceil(docInfo.width * dpi); + const height = Math.ceil(docInfo.height * dpi); + const page = doc.page(index); + + if (page) { + const bitmap = viewer.createBitmap(width, height, 0); + bitmap.fill(0, 0, width, height); + page.render(bitmap, 0, 0, width, height, 0, flags); + + const data = bitmap.toBytes(); + + bitmap.close(); + page.close(); + + imageData = new ImageData(new Uint8ClampedArray(data), width, height); + + cached.set(index, imageData); + + if (kind === 'thumbnail') { + const resizeWidth = (94 * dpi) >> 0; + const resizeHeight = + ((docInfo.height / docInfo.width) * resizeWidth) >> 0; + imageData = await resizeImageData(imageData, { + resizeWidth, + resizeHeight, + }); + } + + post(MessageOp.Rendered, { index, imageData, kind }); + } + + break; + } + } +} + +self.addEventListener('message', (event: MessageEvent) => { + process(event).catch(console.error); +}); + +start().catch(console.error); diff --git a/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx new file mode 100644 index 0000000000000..4f95a5abca858 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx @@ -0,0 +1,87 @@ +import { AttachmentViewer } from '@affine/component/attachment-viewer'; +import { + type AttachmentBlockModel, + matchFlavours, +} from '@blocksuite/affine/blocks'; +import { + type Doc, + DocsService, + FrameworkScope, + useService, +} from '@toeverything/infra'; +import { type ReactElement, useEffect, useLayoutEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import { + ViewBody, + ViewHeader, + ViewIcon, + ViewTitle, +} from '../../../../modules/workbench'; +import { PageNotFound } from '../../404'; + +const useLoadAttachment = (pageId?: string, attachmentId?: string) => { + const docsService = useService(DocsService); + const [doc, setDoc] = useState(null); + const [model, setModel] = useState(null); + + useLayoutEffect(() => { + if (!pageId) return; + + const { doc, release } = docsService.open(pageId); + + if (!doc.blockSuiteDoc.ready) { + doc.blockSuiteDoc.load(); + } + + setDoc(doc); + + return () => { + release(); + }; + }, [docsService, pageId]); + + useEffect(() => { + if (!doc) return; + if (!attachmentId) return; + + const disposable = doc.blockSuiteDoc.slots.blockUpdated + .filter(({ type, id }) => type === 'add' && id === attachmentId) + // @ts-expect-error allow + .filter(({ model }) => matchFlavours(model, ['affine:attachment'])) + // @ts-expect-error allow + .once(({ model }) => setModel(model as AttachmentBlockModel)); + + return () => { + disposable.dispose(); + }; + }, [doc, attachmentId]); + + return { doc, model }; +}; + +export const AttachmentPage = (): ReactElement => { + const params = useParams(); + const { doc, model } = useLoadAttachment(params.pageId, params.attachmentId); + + if (!doc || !model) { + return ; + } + + return ( + <> + + + + + + + + + + ); +}; + +export const Component = () => { + return ; +}; diff --git a/packages/frontend/core/src/desktop/workbench-router.ts b/packages/frontend/core/src/desktop/workbench-router.ts index 0e810fa39b2d8..189ea393dd844 100644 --- a/packages/frontend/core/src/desktop/workbench-router.ts +++ b/packages/frontend/core/src/desktop/workbench-router.ts @@ -29,6 +29,10 @@ export const workbenchRoutes = [ path: '/:pageId', lazy: () => import('./pages/workspace/detail-page/detail-page'), }, + { + path: '/:pageId/:attachmentId', + lazy: () => import('./pages/workspace/attachment/index'), + }, { path: '*', lazy: () => import('./pages/404'), diff --git a/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts b/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts index 9dfe4935d6280..e6f7a5b0635bb 100644 --- a/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts +++ b/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts @@ -1,5 +1,6 @@ import type { BlockComponent, EditorHost } from '@blocksuite/affine/block-std'; import type { + AttachmentBlockModel, DocMode, EmbedLinkedDocModel, EmbedSyncedDocModel, @@ -51,6 +52,11 @@ export type ImagePeekViewInfo = { docRef: DocReferenceInfo; }; +export type AttachmentPeekViewInfo = { + type: 'attachment'; + docRef: DocReferenceInfo; +}; + export type AIChatBlockPeekViewInfo = { type: 'ai-chat-block'; docRef: DocReferenceInfo; @@ -68,6 +74,7 @@ export type ActivePeekView = { info: | DocPeekViewInfo | ImagePeekViewInfo + | AttachmentPeekViewInfo | CustomTemplatePeekViewInfo | AIChatBlockPeekViewInfo; }; @@ -90,6 +97,12 @@ const isImageBlockModel = ( return blockModel.flavour === 'affine:image'; }; +const isAttachmentBlockModel = ( + blockModel: BlockModel +): blockModel is AttachmentBlockModel => { + return blockModel.flavour === 'affine:attachment'; +}; + const isSurfaceRefModel = ( blockModel: BlockModel ): blockModel is SurfaceRefBlockModel => { @@ -162,6 +175,14 @@ function resolvePeekInfoFromPeekTarget( }, }; } + } else if (isAttachmentBlockModel(blockModel)) { + return { + type: 'attachment', + docRef: { + docId: blockModel.doc.id, + blockIds: [blockModel.id], + }, + }; } else if (isImageBlockModel(blockModel)) { return { type: 'image', diff --git a/packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx b/packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx new file mode 100644 index 0000000000000..1ea2cb240ab21 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx @@ -0,0 +1,43 @@ +import { AttachmentViewer } from '@affine/component/attachment-viewer'; +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { type PropsWithChildren, Suspense, useMemo } from 'react'; +import { ErrorBoundary, type FallbackProps } from 'react-error-boundary'; + +import { useEditor } from '../utils'; + +const ErrorLogger = (props: FallbackProps) => { + console.error('image preview modal error', props.error); + return null; +}; + +export const AttachmentPreviewErrorBoundary = (props: PropsWithChildren) => { + return ( + {props.children} + ); +}; + +export type AttachmentPreviewModalProps = { + docId: string; + blockId: string; +}; + +export const AttachmentPreviewPeekView = ({ + docId, + blockId, +}: AttachmentPreviewModalProps) => { + const { doc } = useEditor(docId); + const blocksuiteDoc = doc?.blockSuiteDoc; + const model = useMemo(() => { + const block = blocksuiteDoc?.getBlock(blockId); + if (block?.model) { + return block.model as AttachmentBlockModel; + } + return null; + }, [blockId, blocksuiteDoc]); + + return ( + + {model ? : null} + + ); +}; diff --git a/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx index f2fe712a6090c..ed9faa91e46c5 100644 --- a/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx @@ -6,6 +6,7 @@ import { useEffect, useMemo } from 'react'; import type { ActivePeekView } from '../entities/peek-view'; import { PeekViewService } from '../services/peek-view'; +import { AttachmentPreviewPeekView } from './attachment-preview'; import { DocPeekPreview } from './doc-preview'; import { ImagePreviewPeekView } from './image-preview'; import { @@ -25,6 +26,15 @@ function renderPeekView({ info }: ActivePeekView) { return ; } + if (info.type === 'attachment' && info.docRef.blockIds?.[0]) { + return ( + + ); + } + if (info.type === 'image' && info.docRef.blockIds?.[0]) { return ( { return ; } + // TODO(@fundon): attachment's controls + if (info.type === 'image') { return null; // image controls are rendered in the image preview } diff --git a/packages/frontend/core/src/modules/workbench/constants.tsx b/packages/frontend/core/src/modules/workbench/constants.tsx index 719d816fd4701..7439354f5e8a8 100644 --- a/packages/frontend/core/src/modules/workbench/constants.tsx +++ b/packages/frontend/core/src/modules/workbench/constants.tsx @@ -1,7 +1,9 @@ import { AllDocsIcon, + AttachmentIcon, DeleteIcon, EdgelessIcon, + ExportToPdfIcon, PageIcon, TagIcon, TodayIcon, @@ -18,6 +20,8 @@ export const iconNameToIcon = { journal: , tag: , trash: , + attachment: , + pdf: , } satisfies Record; export type ViewIconName = keyof typeof iconNameToIcon; diff --git a/tools/cli/src/webpack/config.ts b/tools/cli/src/webpack/config.ts index e1bd39b768ea0..012d6b7cafd62 100644 --- a/tools/cli/src/webpack/config.ts +++ b/tools/cli/src/webpack/config.ts @@ -111,8 +111,8 @@ export const createConfiguration: ( : 'js/[name].js', // In some cases webpack will emit files starts with "_" which is reserved in web extension. chunkFilename: pathData => - pathData.chunk?.name === 'worker' - ? 'js/worker.[contenthash:8].js' + pathData.chunk?.name?.endsWith?.('worker') + ? 'js/[name].[contenthash:8].js' : buildFlags.mode === 'production' ? 'js/chunk.[name].[contenthash:8].js' : 'js/chunk.[name].js', diff --git a/yarn.lock b/yarn.lock index 5106b3f516eae..2d8f3cc04b9e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -352,6 +352,7 @@ __metadata: "@storybook/react": "npm:^8.2.9" "@storybook/react-vite": "npm:^8.2.9" "@testing-library/react": "npm:^16.0.0" + "@toeverything/pdf-viewer": "npm:^0.1.0" "@toeverything/theme": "npm:^1.0.17" "@types/react": "npm:^18.2.75" "@types/react-dom": "npm:^18.2.24" @@ -360,6 +361,8 @@ __metadata: check-password-strength: "npm:^2.0.10" clsx: "npm:^2.1.0" dayjs: "npm:^1.11.10" + file-type: "npm:^19.1.0" + filesize: "npm:^10.1.6" jotai: "npm:^2.8.0" lit: "npm:^3.1.2" lodash-es: "npm:^4.17.21" @@ -372,6 +375,7 @@ __metadata: react-paginate: "npm:^8.2.0" react-router-dom: "npm:^6.22.3" react-transition-state: "npm:^2.1.1" + react-virtuoso: "npm:^4.7.8" sonner: "npm:^1.4.41" storybook: "npm:^8.2.9" swr: "npm:^2.2.5" @@ -382,7 +386,7 @@ __metadata: zod: "npm:^3.22.4" peerDependencies: "@blocksuite/affine": "*" - "@blocksuite/icons": 2.1.67 + "@blocksuite/icons": "*" languageName: unknown linkType: soft @@ -13110,6 +13114,30 @@ __metadata: languageName: unknown linkType: soft +"@toeverything/pdf-viewer-types@npm:0.1.0": + version: 0.1.0 + resolution: "@toeverything/pdf-viewer-types@npm:0.1.0" + checksum: 10/aed8a2e9375e121a663e96147d6a726d4dd7b4917cfa35bac5c422c820125b2bc3e129281cad8c3983d1da12191ba4c6e0e45231123fce5d89091ce18dbc9560 + languageName: node + linkType: hard + +"@toeverything/pdf-viewer@npm:^0.1.0": + version: 0.1.0 + resolution: "@toeverything/pdf-viewer@npm:0.1.0" + dependencies: + "@toeverything/pdf-viewer-types": "npm:0.1.0" + "@toeverything/pdfium": "npm:0.1.0" + checksum: 10/75e1df49ecce97e667ac9ec1a630ba4492f1b712d4380a9c999f05535e305e4fd2a069d004449477520ea308ec5928f02b51268659f75788ae4715a7b6e14da7 + languageName: node + linkType: hard + +"@toeverything/pdfium@npm:0.1.0": + version: 0.1.0 + resolution: "@toeverything/pdfium@npm:0.1.0" + checksum: 10/5fee0f76608d27d747b9266096cdd153027e8f3e8774571524f86afd88757833a61472400df5dcf2a6ea2e16bcfe9864b090ef7e483c260998ba3fad418a9ba8 + languageName: node + linkType: hard + "@toeverything/theme@npm:^1.0.15, @toeverything/theme@npm:^1.0.17": version: 1.0.17 resolution: "@toeverything/theme@npm:1.0.17" @@ -20500,7 +20528,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 From 690df2da29e372f40e50867b898022f26427504a Mon Sep 17 00:00:00 2001 From: Fangdun Tsai Date: Mon, 28 Oct 2024 14:21:29 +0800 Subject: [PATCH 2/6] chore: improve pdf preview --- packages/frontend/component/package.json | 3 - .../components/attachment-viewer/error.tsx | 28 -- .../components/attachment-viewer/index.tsx | 30 -- .../components/attachment-viewer/viewer.tsx | 417 ----------------- .../attachment-viewer/worker/types.ts | 33 -- .../attachment-viewer/worker/worker.ts | 164 ------- packages/frontend/core/package.json | 2 + .../components/attachment-viewer/error.tsx | 131 ++++++ .../components/attachment-viewer/index.tsx | 52 +++ .../attachment-viewer/styles.css.ts | 36 +- .../components/attachment-viewer/titlebar.tsx | 33 +- .../src/components/attachment-viewer/utils.ts | 64 +++ .../components/attachment-viewer/viewer.tsx | 426 ++++++++++++++++++ .../attachment-viewer/worker/types.ts | 69 +++ .../attachment-viewer/worker/utils.ts | 13 + .../attachment-viewer/worker/worker.ts | 223 +++++++++ .../pages/workspace/attachment/index.css.ts | 10 + .../pages/workspace/attachment/index.tsx | 131 ++++-- .../core/src/desktop/workbench-router.ts | 2 +- .../view/attachment-preview/index.tsx | 30 +- .../peek-view/view/image-preview/index.tsx | 48 +- .../peek-view/view/peek-view-controls.tsx | 65 +++ .../peek-view/view/peek-view-manager.tsx | 5 +- .../modules/workbench/entities/workbench.ts | 8 + .../utils.ts => core/src/utils/resource.ts} | 40 +- packages/frontend/i18n/src/resources/en.json | 7 +- .../e2e/attachment-preview.spec.ts | 118 +++++ tests/fixtures/lorem-ipsum.pdf | Bin 0 -> 23286 bytes yarn.lock | 5 +- 29 files changed, 1358 insertions(+), 835 deletions(-) delete mode 100644 packages/frontend/component/src/components/attachment-viewer/error.tsx delete mode 100644 packages/frontend/component/src/components/attachment-viewer/index.tsx delete mode 100644 packages/frontend/component/src/components/attachment-viewer/viewer.tsx delete mode 100644 packages/frontend/component/src/components/attachment-viewer/worker/types.ts delete mode 100644 packages/frontend/component/src/components/attachment-viewer/worker/worker.ts create mode 100644 packages/frontend/core/src/components/attachment-viewer/error.tsx create mode 100644 packages/frontend/core/src/components/attachment-viewer/index.tsx rename packages/frontend/{component => core}/src/components/attachment-viewer/styles.css.ts (88%) rename packages/frontend/{component => core}/src/components/attachment-viewer/titlebar.tsx (78%) create mode 100644 packages/frontend/core/src/components/attachment-viewer/utils.ts create mode 100644 packages/frontend/core/src/components/attachment-viewer/viewer.tsx create mode 100644 packages/frontend/core/src/components/attachment-viewer/worker/types.ts create mode 100644 packages/frontend/core/src/components/attachment-viewer/worker/utils.ts create mode 100644 packages/frontend/core/src/components/attachment-viewer/worker/worker.ts create mode 100644 packages/frontend/core/src/desktop/pages/workspace/attachment/index.css.ts rename packages/frontend/{component/src/components/attachment-viewer/utils.ts => core/src/utils/resource.ts} (67%) create mode 100644 tests/affine-local/e2e/attachment-preview.spec.ts create mode 100644 tests/fixtures/lorem-ipsum.pdf diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index d569a2a614d63..c41267f53a8e2 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -47,8 +47,6 @@ "check-password-strength": "^2.0.10", "clsx": "^2.1.0", "dayjs": "^1.11.10", - "file-type": "^19.1.0", - "filesize": "^10.1.6", "jotai": "^2.8.0", "lit": "^3.1.2", "lodash-es": "^4.17.21", @@ -61,7 +59,6 @@ "react-paginate": "^8.2.0", "react-router-dom": "^6.22.3", "react-transition-state": "^2.1.1", - "react-virtuoso": "^4.7.8", "sonner": "^1.4.41", "swr": "^2.2.5", "zod": "^3.22.4" diff --git a/packages/frontend/component/src/components/attachment-viewer/error.tsx b/packages/frontend/component/src/components/attachment-viewer/error.tsx deleted file mode 100644 index 2a9bf9550ce7d..0000000000000 --- a/packages/frontend/component/src/components/attachment-viewer/error.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { AttachmentBlockModel } from '@blocksuite/blocks'; -import { ArrowDownBigIcon, PageIcon } from '@blocksuite/icons/rc'; -import clsx from 'clsx'; - -import { Button } from '../../ui/button'; -import * as styles from './styles.css'; - -interface ErrorProps { - model: AttachmentBlockModel; - ext: string; - isPDF: boolean; -} - -export const Error = ({ ext }: ErrorProps) => { - return ( -
- -

Unable to preview this file

-

.{ext} file type not supported.

-
- - -
-
- ); -}; diff --git a/packages/frontend/component/src/components/attachment-viewer/index.tsx b/packages/frontend/component/src/components/attachment-viewer/index.tsx deleted file mode 100644 index f2ee24c081895..0000000000000 --- a/packages/frontend/component/src/components/attachment-viewer/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { AttachmentBlockModel } from '@blocksuite/blocks'; -import { filesize } from 'filesize'; -import { useMemo } from 'react'; - -import { Error } from './error'; -import * as styles from './styles.css'; -import { Titlebar } from './titlebar'; -import { Viewer } from './viewer'; - -export type AttachmentViewerProps = { - model: AttachmentBlockModel; -}; - -export const AttachmentViewer = ({ model }: AttachmentViewerProps) => { - const props = useMemo(() => { - const pieces = model.name.split('.'); - const ext = pieces.pop() || ''; - const name = pieces.join('.'); - const isPDF = ext === 'pdf'; - const size = filesize(model.size); - return { model, name, ext, size, isPDF }; - }, [model]); - - return ( -
- - {props.isPDF ? : } -
- ); -}; diff --git a/packages/frontend/component/src/components/attachment-viewer/viewer.tsx b/packages/frontend/component/src/components/attachment-viewer/viewer.tsx deleted file mode 100644 index 78b19d60b0210..0000000000000 --- a/packages/frontend/component/src/components/attachment-viewer/viewer.tsx +++ /dev/null @@ -1,417 +0,0 @@ -import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc'; -import clsx from 'clsx'; -import { debounce } from 'lodash-es'; -import type { HTMLAttributes, PropsWithChildren, ReactElement } from 'react'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import type { VirtuosoHandle } from 'react-virtuoso'; -import { Virtuoso } from 'react-virtuoso'; - -import { IconButton } from '../../ui/button'; -import { Scrollable } from '../../ui/scrollbar'; -import * as styles from './styles.css'; -// import { observeResize } from '../../utils'; -import type { MessageData, MessageDataType } from './worker/types'; -import { MessageOp, State } from './worker/types'; - -const Page = React.memo( - ({ - width, - height, - className, - }: { - index: number; - width: number; - height: number; - className: string; - }) => { - return ( -
- -
- ); - } -); - -Page.displayName = 'viewer-page'; - -const THUMBNAIL_WIDTH = 94; - -const Thumbnail = React.memo( - ({ - index, - width, - height, - className, - onSelect, - }: { - index: number; - width: number; - height: number; - className: string; - onSelect: (index: number) => void; - }) => { - return ( -
onSelect(index)}> - -
- ); - } -); - -Thumbnail.displayName = 'viewer-thumbnail'; - -const Scroller = React.forwardRef< - HTMLDivElement, - PropsWithChildren> ->(({ style, ...props }, ref) => { - return ( - - - - - ); -}); - -Scroller.displayName = 'viewer-scroller'; - -interface ViewerProps { - model: AttachmentBlockModel; -} - -export const Viewer = ({ model }: ViewerProps): ReactElement => { - const [connected, setConnected] = useState(false); - const [loaded, setLoaded] = useState(false); - const [docInfo, setDocInfo] = useState({ - cursor: 0, - total: 0, - width: 1, - height: 1, - }); - const viewerRef = useRef(null); - const scrollerRef = useRef(null); - const scrollerHandleRef = useRef(null); - const workerRef = useRef(null); - - const [mainVisibleRange, setMainVisibleRange] = useState({ - startIndex: 0, - endIndex: 0, - }); - - const [collapsed, setCollapsed] = useState(true); - const thumbnailsScrollerHandleRef = useRef(null); - const thumbnailsScrollerRef = useRef(null); - const [thumbnailsVisibleRange, setThumbnailsVisibleRange] = useState({ - startIndex: 0, - endIndex: 0, - }); - - const post = useCallback( - ( - type: T, - data?: MessageDataType[T], - transfer = [] - ) => { - workerRef.current?.postMessage( - { - state: State.Poll, - type, - [type]: data, - }, - transfer - ); - }, - [workerRef] - ); - - const render = useCallback( - (id: number, imageData: ImageData) => { - const el = scrollerRef.current; - if (!el) return; - - const canvas: HTMLCanvasElement | null = el.querySelector( - `[data-index="${id}"] canvas` - ); - if (!canvas) return; - if (canvas.dataset.rendered) return; - - // TODO(@fundon): improve - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.putImageData(imageData, 0, 0); - canvas.dataset.rendered = 'true'; - } - }, - [scrollerRef] - ); - - const renderThumbnail = useCallback( - (id: number, imageData: ImageData) => { - const el = thumbnailsScrollerRef.current; - if (!el) return; - - const canvas: HTMLCanvasElement | null = el.querySelector( - `[data-index="${id}"] canvas` - ); - if (!canvas) return; - if (canvas.dataset.rendered) return; - - // TODO(@fundon): improve - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.putImageData(imageData, 0, 0); - canvas.dataset.rendered = 'true'; - } - }, - [thumbnailsScrollerRef] - ); - - const onScroll = useCallback(() => { - const el = scrollerRef.current; - if (!el) return; - - const { scrollTop, scrollHeight } = el; - - setDocInfo(info => { - const cursor = Math.ceil(scrollTop / (scrollHeight / info.total)); - // thumbnailsScrollerHandleRef.current?.scrollToIndex(cursor) - return { - ...info, - cursor, - }; - }); - // }, [scrollerRef, thumbnailsScrollerHandleRef]); - }, [scrollerRef]); - - const onSelect = useCallback( - (index: number) => { - scrollerHandleRef.current?.scrollToIndex(index); - setDocInfo(info => ({ ...info, cursor: index })); - }, - [scrollerHandleRef] - ); - - const updateMainVisibleRange = useMemo( - () => debounce(setMainVisibleRange, 233, { leading: true, trailing: true }), - [setMainVisibleRange] - ); - - const updateThumbnailsVisibleRange = useMemo( - () => - debounce(setThumbnailsVisibleRange, 233, { - leading: true, - trailing: true, - }), - [setThumbnailsVisibleRange] - ); - - // useEffect(() => { - // const el = viewerRef.current; - // if (!el) return; - - // return observeResize(el, entry => { - // console.log(entry); - // }); - // }, []); - - useEffect(() => { - const { startIndex, endIndex } = mainVisibleRange; - let index = startIndex; - for (; index < endIndex + 1; index++) { - post(MessageOp.Render, { index, kind: 'page' }); - } - }, [mainVisibleRange, post]); - - useEffect(() => { - const { startIndex, endIndex } = thumbnailsVisibleRange; - let index = startIndex; - for (; index < endIndex + 1; index++) { - post(MessageOp.Render, { index, kind: 'thumbnail' }); - } - }, [thumbnailsVisibleRange, post]); - - useEffect(() => { - workerRef.current = new Worker( - /* webpackChunkName: "pdf.worker" */ new URL( - './worker/worker.ts', - import.meta.url - ) - ); - - async function process({ data }: MessageEvent) { - const { type, state } = data; - - if (type === MessageOp.Init) { - setConnected(state === State.Ready); - return; - } - if (type === MessageOp.Open) { - setLoaded(state === State.Ready); - return; - } - - if (state === State.Poll) return; - - switch (type) { - case MessageOp.ReadInfo: { - const action = data[type]; - setDocInfo(info => ({ ...info, ...action })); - break; - } - case MessageOp.Rendered: { - const { index, imageData, kind } = data[type]; - if (kind === 'page') { - render(index, imageData); - } else { - renderThumbnail(index, imageData); - } - break; - } - } - } - - workerRef.current.addEventListener('message', event => { - process(event).catch(console.error); - }); - - return () => { - workerRef.current?.terminate(); - }; - }, [model, post, render, renderThumbnail]); - - useEffect(() => { - if (!connected) return; - if (!model.sourceId) return; - - model.doc.blobSync - .get(model.sourceId) - .then(blob => { - if (!blob) return; - post(MessageOp.Open, { blob, dpi: window.devicePixelRatio }); - }) - .catch(console.error); - }, [connected, model, post]); - - useEffect(() => { - if (!loaded) return; - post(MessageOp.ReadInfo); - }, [loaded, post]); - - const pageContent = (index: number) => { - return ( - - ); - }; - - const thumbnailContent = (index: number) => { - return ( - - ); - }; - - const components = useMemo(() => { - return { - Scroller, - }; - }, []); - - return ( -
- { - if (scrollerRef.current) return; - scrollerRef.current = scroller as HTMLElement; - }} - className={styles.virtuoso} - rangeChanged={updateMainVisibleRange} - increaseViewportBy={{ - top: docInfo.height * Math.min(5, docInfo.total), - bottom: docInfo.height * Math.min(5, docInfo.total), - }} - totalCount={docInfo.total} - itemContent={pageContent} - components={components} - /> -
- {collapsed ? null : ( -
- { - if (thumbnailsScrollerRef.current) return; - thumbnailsScrollerRef.current = scroller as HTMLElement; - }} - rangeChanged={updateThumbnailsVisibleRange} - className={styles.virtuoso} - totalCount={docInfo.total} - itemContent={thumbnailContent} - components={components} - /> -
- )} -
-
- {docInfo.cursor + 1}/{docInfo.total} -
- : } - onClick={() => setCollapsed(state => !state)} - /> -
-
-
- ); -}; diff --git a/packages/frontend/component/src/components/attachment-viewer/worker/types.ts b/packages/frontend/component/src/components/attachment-viewer/worker/types.ts deleted file mode 100644 index 4ec6b2a064931..0000000000000 --- a/packages/frontend/component/src/components/attachment-viewer/worker/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -export enum State { - Poll, - Ready, -} - -export enum MessageOp { - Init, - Open, - ReadInfo, - Render, - Rendered, -} - -export type MessageDataMap = { - [MessageOp.Init]: undefined; - [MessageOp.Open]: { blob: Blob; dpi: number }; - [MessageOp.ReadInfo]: { total: number; width: number; height: number }; - [MessageOp.Render]: { index: number; kind: 'page' | 'thumbnail' }; - [MessageOp.Rendered]: { - index: number; - imageData: ImageData; - kind: 'page' | 'thumbnail'; - }; -}; - -export type MessageDataType = { - [P in keyof T]: T[P]; -}; - -export type MessageData = { - state: State; - type: T; -} & P; diff --git a/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts b/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts deleted file mode 100644 index d6423a6083148..0000000000000 --- a/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import type { Document } from '@toeverything/pdf-viewer'; -import { - createPDFium, - PageRenderingflags, - Runtime, - Viewer, -} from '@toeverything/pdf-viewer'; - -import type { MessageData, MessageDataType } from './types'; -import { MessageOp, State } from './types'; - -const logger = new DebugLogger('affine:pdf-worker'); - -let dpi = 2; -let inited = false; -let viewer: Viewer | null = null; -let doc: Document | undefined = undefined; - -const cached = new Map(); -const docInfo = { cursor: 0, total: 0, width: 1, height: 1 }; -const flags = PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT; - -function post(type: T, data?: MessageDataType[T]) { - self.postMessage({ state: State.Ready, type, [type]: data }); -} - -async function resizeImageData( - imageData: ImageData, - options: { - resizeWidth: number; - resizeHeight: number; - } -) { - const { resizeWidth: w, resizeHeight: h } = options; - const bitmap = await createImageBitmap( - imageData, - 0, - 0, - imageData.width, - imageData.height, - options - ); - const canvas = new OffscreenCanvas(w, h); - const ctx = canvas.getContext('2d'); - if (!ctx) return imageData; - ctx.drawImage(bitmap, 0, 0); - return ctx.getImageData(0, 0, w, h); -} - -async function start() { - logger.debug('pdf worker pending'); - self.postMessage({ state: State.Poll, type: MessageOp.Init }); - - const pdfium = await createPDFium(); - viewer = new Viewer(new Runtime(pdfium)); - inited = true; - - self.postMessage({ state: State.Ready, type: MessageOp.Init }); - logger.debug('pdf worker ready'); -} - -async function process({ data }: MessageEvent) { - if (!inited || !viewer) { - await start(); - } - - if (!viewer) return; - - const { type, state } = data; - - if (state !== State.Poll) return; - - switch (type) { - case MessageOp.Open: { - const action = data[type]; - if (!action?.blob) return; - - dpi = action.dpi; - doc = await viewer.openWithBlob(action.blob); - - if (!doc) return; - - post(MessageOp.Open); - break; - } - - case MessageOp.ReadInfo: { - if (!doc) return; - - const page = doc.page(0); - if (page) { - docInfo.cursor = 0; - docInfo.total = doc.pageCount(); - docInfo.height = page.height(); - docInfo.width = page.width(); - page.close(); - post(MessageOp.ReadInfo, docInfo); - } - break; - } - - case MessageOp.Render: { - if (!doc) return; - - const { index, kind } = data[type]; - - let imageData = cached.size > 0 ? cached.get(index) : undefined; - if (imageData) { - if (kind === 'thumbnail') { - const resizeWidth = (94 * dpi) >> 0; - const resizeHeight = - ((docInfo.height / docInfo.width) * resizeWidth) >> 0; - imageData = await resizeImageData(imageData, { - resizeWidth, - resizeHeight, - }); - } - - post(MessageOp.Rendered, { index, imageData, kind }); - return; - } - - const width = Math.ceil(docInfo.width * dpi); - const height = Math.ceil(docInfo.height * dpi); - const page = doc.page(index); - - if (page) { - const bitmap = viewer.createBitmap(width, height, 0); - bitmap.fill(0, 0, width, height); - page.render(bitmap, 0, 0, width, height, 0, flags); - - const data = bitmap.toBytes(); - - bitmap.close(); - page.close(); - - imageData = new ImageData(new Uint8ClampedArray(data), width, height); - - cached.set(index, imageData); - - if (kind === 'thumbnail') { - const resizeWidth = (94 * dpi) >> 0; - const resizeHeight = - ((docInfo.height / docInfo.width) * resizeWidth) >> 0; - imageData = await resizeImageData(imageData, { - resizeWidth, - resizeHeight, - }); - } - - post(MessageOp.Rendered, { index, imageData, kind }); - } - - break; - } - } -} - -self.addEventListener('message', (event: MessageEvent) => { - process(event).catch(console.error); -}); - -start().catch(console.error); diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 8cccecef247d6..470087621d68c 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -36,6 +36,7 @@ "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-toolbar": "^1.0.4", "@sentry/react": "^8.0.0", + "@toeverything/pdf-viewer": "^0.1.0", "@toeverything/theme": "^1.0.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..92944cdb5cfec --- /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..e336e03ede264 --- /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 * as styles from './styles.css'; +import { Titlebar } from './titlebar'; +import { buildAttachmentProps } from './utils'; +import { Viewer } from './viewer'; + +export type AttachmentViewerProps = { + model: AttachmentBlockModel; +}; + +// In Peek view +export const AttachmentViewer = ({ model }: AttachmentViewerProps) => { + const props = buildAttachmentProps(model); + + return ( +
+ + {props.isPDF ? ( + + + + ) : ( + + )} +
+ ); +}; + +// In View container +export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => { + const props = buildAttachmentProps(model); + + return ( + <> + + + + + {props.isPDF ? ( + + + + ) : ( + + )} + + + ); +}; diff --git a/packages/frontend/component/src/components/attachment-viewer/styles.css.ts b/packages/frontend/core/src/components/attachment-viewer/styles.css.ts similarity index 88% rename from packages/frontend/component/src/components/attachment-viewer/styles.css.ts rename to packages/frontend/core/src/components/attachment-viewer/styles.css.ts index 193fa68e99391..0ba25f051447c 100644 --- a/packages/frontend/component/src/components/attachment-viewer/styles.css.ts +++ b/packages/frontend/core/src/components/attachment-viewer/styles.css.ts @@ -43,21 +43,19 @@ export const titlebarName = style({ }); export const body = style({ + position: 'relative', + zIndex: 0, display: 'flex', flex: 1, - position: 'relative', selectors: { '&:before': { position: 'absolute', - // position: 'fixed', content: '', top: 0, right: 0, bottom: 0, left: 0, - // width: '100%', - // height: '100%', - // zIndex: -1, + zIndex: -1, }, '&:not(.gridding):before': { backgroundColor: cssVarV2('layer/background/secondary'), @@ -103,8 +101,22 @@ export const errorBtns = style({ marginTop: '28px', }); -export const viewerPage = style({ +export const mainItemWrapper = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', margin: '20px auto', + selectors: { + '&:first-of-type': { + marginTop: 0, + }, + '&:last-of-type': { + marginBottom: 0, + }, + }, +}); + +export const viewerPage = style({ maxWidth: 'calc(100% - 40px)', background: cssVarV2('layer/white'), boxSizing: 'border-box', @@ -116,18 +128,20 @@ export const viewerPage = style({ }); export const thumbnails = style({ + display: 'flex', + flexDirection: 'column', position: 'absolute', boxSizing: 'border-box', width: '120px', padding: '12px 0', right: '30px', bottom: '30px', + maxHeight: 'calc(100% - 60px)', borderRadius: '8px', borderWidth: '1px', borderStyle: 'solid', borderColor: cssVarV2('layer/insideBorder/border'), backgroundColor: cssVarV2('layer/background/primary'), - boxShadow: cssVarV2('shadow/overlayPanelShadow/2-color'), fontSize: '12px', fontWeight: 500, lineHeight: '20px', @@ -135,8 +149,11 @@ export const thumbnails = style({ }); export const thumbnailsPages = style({ + position: 'relative', display: 'flex', flexDirection: 'column', + maxHeight: '100%', + overflow: 'hidden', // gap: '12px', selectors: { '&.collapsed': { @@ -148,8 +165,11 @@ export const thumbnailsPages = style({ }, }); -export const thumbnailsPage = style({ +export const thumbnailsItemWrapper = style({ margin: '0px 12px 12px', +}); + +export const thumbnailsPage = style({ display: 'flex', overflow: 'clip', // width: '100%', diff --git a/packages/frontend/component/src/components/attachment-viewer/titlebar.tsx b/packages/frontend/core/src/components/attachment-viewer/titlebar.tsx similarity index 78% rename from packages/frontend/component/src/components/attachment-viewer/titlebar.tsx rename to packages/frontend/core/src/components/attachment-viewer/titlebar.tsx index 0874a8e10ac8f..d694062322877 100644 --- a/packages/frontend/component/src/components/attachment-viewer/titlebar.tsx +++ b/packages/frontend/core/src/components/attachment-viewer/titlebar.tsx @@ -1,6 +1,7 @@ +import { IconButton, Menu, MenuItem } from '@affine/component'; import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; import { - EditIcon, + //EditIcon, LocalDataIcon, MoreHorizontalIcon, ZoomDownIcon, @@ -9,31 +10,33 @@ import { import clsx from 'clsx'; import { useState } from 'react'; -import { IconButton } from '../../ui/button'; -import { Menu, MenuItem } from '../../ui/menu'; import * as styles from './styles.css'; -import { saveBufferToFile } from './utils'; +import { download } from './utils'; const items = [ + /* { name: 'Rename', icon: , action(_model: AttachmentBlockModel) {}, }, + */ { name: 'Download', icon: , - action(model: AttachmentBlockModel) { - const { sourceId, name } = model; - if (!sourceId) return; - saveBufferToFile(sourceId, name).catch(console.error); - }, + action: download, }, ]; export const MenuItems = ({ model }: { model: AttachmentBlockModel }) => items.map(({ name, icon, action }) => ( - action(model)} prefixIcon={icon}> + { + action(model).catch(console.error); + }} + prefixIcon={icon} + > {name} )); @@ -53,7 +56,6 @@ export const Titlebar = ({ ext, size, zoom = 100, - isPDF = false, }: TitlebarProps) => { const [openMenu, setOpenMenu] = useState(false); @@ -65,7 +67,12 @@ export const Titlebar = ({ .{ext}
{size}
- }> + } + onClick={() => { + download(model).catch(console.error); + }} + > } rootOptions={{ @@ -86,7 +93,7 @@ export const Titlebar = ({ styles.titlebarChild, 'zoom', { - show: isPDF, + show: false, }, ])} > diff --git a/packages/frontend/core/src/components/attachment-viewer/utils.ts b/packages/frontend/core/src/components/attachment-viewer/utils.ts new file mode 100644 index 0000000000000..0a4675490d2c8 --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/utils.ts @@ -0,0 +1,64 @@ +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 renderItem( + scroller: HTMLElement | null, + className: string, + id: number, + imageData: ImageData +) { + if (!scroller) return; + + const item = scroller.querySelector( + `[data-index="${id}"] > div.${className}` + ); + if (!item) return; + if (item.firstElementChild) return; + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + canvas.width = imageData.width; + canvas.height = imageData.height; + canvas.style.width = '100%'; + canvas.style.height = '100%'; + + ctx.putImageData(imageData, 0, 0); + + item.append(canvas); +} + +export function buildAttachmentProps(model: AttachmentBlockModel) { + const isPDF = model.type.endsWith('pdf'); + const pieces = model.name.split('.'); + const ext = pieces.pop() || ''; + const name = pieces.join('.'); + const size = filesize(model.size); + return { model, name, ext, size, isPDF }; +} diff --git a/packages/frontend/core/src/components/attachment-viewer/viewer.tsx b/packages/frontend/core/src/components/attachment-viewer/viewer.tsx new file mode 100644 index 0000000000000..b0bab7fe10b52 --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/viewer.tsx @@ -0,0 +1,426 @@ +import { IconButton, observeResize, Scrollable } from '@affine/component'; +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc'; +import clsx from 'clsx'; +import { debounce } from 'lodash-es'; +import type { ReactElement } from 'react'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useErrorBoundary } from 'react-error-boundary'; +import type { VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; +import { Virtuoso } from 'react-virtuoso'; + +import * as styles from './styles.css'; +import { getAttachmentBlob, renderItem } from './utils'; +import type { DocInfo, MessageData, MessageDataType } from './worker/types'; +import { MessageOp, RenderKind, State } from './worker/types'; + +type ItemProps = VirtuosoProps; + +const Page = React.memo( + ({ + width, + height, + className, + }: { + index: number; + width: number; + height: number; + className: string; + }) => { + return ( +
+ ); + } +); + +Page.displayName = 'viewer-page'; + +const THUMBNAIL_WIDTH = 94; + +const Thumbnail = React.memo( + ({ + index, + width, + height, + className, + onSelect, + }: { + index: number; + width: number; + height: number; + className: string; + onSelect: (index: number) => void; + }) => { + return ( +
onSelect(index)} + >
+ ); + } +); + +Thumbnail.displayName = 'viewer-thumbnail'; + +const Scroller = React.forwardRef( + ({ ...props }, ref) => { + return ( + + + + + ); + } +); + +Scroller.displayName = 'viewer-scroller'; + +const Item = React.forwardRef( + ({ ...props }, ref) => { + return
; + } +); + +Item.displayName = 'viewer-item'; + +interface ViewerProps { + model: AttachmentBlockModel; +} + +export const Viewer = ({ model }: ViewerProps): ReactElement => { + const { showBoundary } = useErrorBoundary(); + const [state, setState] = useState(State.Connecting); + const [viewportInfo, setViewportInfo] = useState({ + dpi: window.devicePixelRatio, + width: 1, + height: 1, + }); + const [docInfo, setDocInfo] = useState({ + total: 0, + width: 1, + height: 1, + }); + const [cursor, setCursor] = useState(0); + const viewerRef = useRef(null); + const scrollerRef = useRef(null); + const scrollerHandleRef = useRef(null); + const workerRef = useRef(null); + + const [mainVisibleRange, setMainVisibleRange] = useState({ + startIndex: 0, + endIndex: 0, + }); + + const [collapsed, setCollapsed] = useState(true); + const thumbnailsScrollerHandleRef = useRef(null); + const thumbnailsScrollerRef = useRef(null); + const [thumbnailsVisibleRange, setThumbnailsVisibleRange] = useState({ + startIndex: 0, + endIndex: 0, + }); + + const post = useCallback( + ( + type: T, + data?: MessageDataType[T], + transfers?: Transferable[] + ) => { + const message = { type, [type]: data }; + if (transfers?.length) { + workerRef.current?.postMessage(message, transfers); + return; + } + workerRef.current?.postMessage(message); + }, + [workerRef] + ); + + const render = useCallback( + (id: number, kind: RenderKind, imageData: ImageData) => { + const isPage = kind === RenderKind.Page; + const container = isPage ? scrollerRef : thumbnailsScrollerRef; + const name = isPage ? 'page' : 'thumbnail'; + renderItem(container.current, `pdf-${name}`, id, imageData); + }, + [scrollerRef, thumbnailsScrollerRef] + ); + + const onScroll = useCallback(() => { + const el = scrollerRef.current; + if (!el) return; + + const { total } = docInfo; + if (!total) return; + + const { scrollTop, scrollHeight } = el; + const itemHeight = scrollHeight / total; + const n = scrollTop / itemHeight; + const t = n / total; + const index = Math.floor(n + t); + const cursor = Math.min(index, total - 1); + + setCursor(cursor); + }, [scrollerRef, docInfo]); + + const onSelect = useCallback( + (index: number) => { + scrollerHandleRef.current?.scrollToIndex({ + index, + align: 'start', + behavior: 'smooth', + }); + }, + [scrollerHandleRef] + ); + + const updateMainVisibleRange = useMemo( + () => debounce(setMainVisibleRange, 233, { trailing: true }), + [setMainVisibleRange] + ); + + const updateThumbnailsVisibleRange = useMemo( + () => debounce(setThumbnailsVisibleRange, 233, { trailing: true }), + [setThumbnailsVisibleRange] + ); + + useEffect(() => { + const el = viewerRef.current; + if (!el) return; + + return observeResize(el, entry => { + const rect = entry.contentRect; + setViewportInfo(info => ({ + ...info, + width: rect.width, + height: rect.height, + })); + }); + }, [viewerRef]); + + useEffect(() => { + post(MessageOp.Render, { + range: mainVisibleRange, + kind: RenderKind.Page, + scale: 1 * viewportInfo.dpi, + }); + }, [viewportInfo, mainVisibleRange, post]); + + useEffect(() => { + if (collapsed) return; + + post(MessageOp.Render, { + range: thumbnailsVisibleRange, + kind: RenderKind.Thumbnail, + scale: (THUMBNAIL_WIDTH / docInfo.width) * viewportInfo.dpi, + }); + }, [collapsed, docInfo, viewportInfo, thumbnailsVisibleRange, post]); + + useLayoutEffect(() => { + workerRef.current = new Worker( + /* webpackChunkName: "pdf.worker" */ new URL( + './worker/worker.ts', + import.meta.url + ) + ); + + async function process({ data }: MessageEvent) { + const { type } = data; + + switch (type) { + case MessageOp.Init: { + setState(State.Connecting); + break; + } + + case MessageOp.Inited: { + setState(State.Connected); + break; + } + + case MessageOp.Opened: { + const info = data[type]; + setDocInfo(o => ({ ...o, ...info })); + setState(State.Opened); + break; + } + + case MessageOp.Rendered: { + const { index, kind, imageData } = data[type]; + render(index, kind, imageData); + break; + } + } + } + + workerRef.current.addEventListener('message', event => { + process(event).catch(console.error); + }); + + return () => { + workerRef.current?.terminate(); + }; + }, [model, post, render]); + + useEffect(() => { + if (!model.sourceId) return; + if (state !== State.Connected) return; + + getAttachmentBlob(model) + .then(blob => { + if (!blob) return; + return blob.arrayBuffer(); + }) + .then(buffer => { + if (!buffer) return; + setState(State.Opening); + post(MessageOp.Open, buffer, [buffer]); + }) + .catch(showBoundary); + }, [showBoundary, state, post, model, docInfo]); + + const pageContent = useCallback( + (index: number) => { + return ( + + ); + }, + [docInfo] + ); + + const thumbnailContent = useCallback( + (index: number) => { + return ( + + ); + }, + [cursor, docInfo, onSelect] + ); + + const mainComponents = useMemo(() => { + return { + Header: () =>
, + Footer: () =>
, + Item: (props: ItemProps) => ( + + ), + Scroller, + }; + }, []); + + const thumbnailsComponents = useMemo(() => { + return { + Item: (props: ItemProps) => ( + + ), + Scroller, + }; + }, []); + + const increaseViewportBy = useMemo(() => { + const size = Math.min(5, docInfo.total); + const itemHeight = docInfo.height + 20; + const height = Math.ceil(size * itemHeight); + return { + top: height, + bottom: height, + }; + }, [docInfo]); + + const mainStyle = useMemo(() => { + const { height: vh } = viewportInfo; + const { total: t, height: h, width: w } = docInfo; + const height = Math.min( + vh - 60 - 24 - 24 - 2 - 8, + t * THUMBNAIL_WIDTH * (h / w) + (t - 1) * 12 + ); + return { + height: `${height}px`, + }; + }, [docInfo, viewportInfo]); + + return ( +
+ + onScroll={onScroll} + ref={scrollerHandleRef} + scrollerRef={scroller => { + if (scrollerRef.current) return; + scrollerRef.current = scroller as HTMLElement; + }} + className={styles.virtuoso} + rangeChanged={updateMainVisibleRange} + increaseViewportBy={increaseViewportBy} + totalCount={docInfo.total} + itemContent={pageContent} + components={mainComponents} + /> +
+
+ + style={mainStyle} + ref={thumbnailsScrollerHandleRef} + scrollerRef={scroller => { + if (thumbnailsScrollerRef.current) return; + thumbnailsScrollerRef.current = scroller as HTMLElement; + }} + rangeChanged={updateThumbnailsVisibleRange} + className={styles.virtuoso} + totalCount={docInfo.total} + itemContent={thumbnailContent} + components={thumbnailsComponents} + /> +
+
+
+ + {docInfo.total > 0 ? cursor + 1 : 0} + + /{docInfo.total} +
+ : } + onClick={() => setCollapsed(!collapsed)} + /> +
+
+
+ ); +}; diff --git a/packages/frontend/core/src/components/attachment-viewer/worker/types.ts b/packages/frontend/core/src/components/attachment-viewer/worker/types.ts new file mode 100644 index 0000000000000..5a259dca391aa --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/worker/types.ts @@ -0,0 +1,69 @@ +export enum State { + Connecting = 0, + Connected, + Opening, + Opened, + Failed, +} + +export type DocInfo = { + total: number; + width: number; + height: number; +}; + +export type ViewportInfo = { + // TODO(@fundon): zoom & scale + dpi: number; + width: number; + height: number; +}; + +export enum MessageState { + Poll, + Ready, +} + +export enum MessageOp { + Init, + Inited, + Open, + Opened, + Render, + Rendered, +} + +export enum RenderKind { + Page, + Thumbnail, +} + +export type Range = { + startIndex: number; + endIndex: number; +}; + +export interface MessageDataMap { + [MessageOp.Init]: undefined; + [MessageOp.Inited]: undefined; + [MessageOp.Open]: ArrayBuffer; + [MessageOp.Opened]: DocInfo; + [MessageOp.Render]: { + range: Range; + kind: RenderKind; + scale: number; + }; + [MessageOp.Rendered]: { + index: number; + kind: RenderKind; + imageData: ImageData; + }; +} + +export type MessageDataType = { + [P in keyof T]: T[P]; +}; + +export type MessageData = { + type: T; +} & P; diff --git a/packages/frontend/core/src/components/attachment-viewer/worker/utils.ts b/packages/frontend/core/src/components/attachment-viewer/worker/utils.ts new file mode 100644 index 0000000000000..e2f107df19fee --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/worker/utils.ts @@ -0,0 +1,13 @@ +export function resizeImageBitmap( + imageData: ImageData, + options: { + resizeWidth: number; + resizeHeight: number; + } +) { + return createImageBitmap(imageData, 0, 0, imageData.width, imageData.height, { + colorSpaceConversion: 'none', + resizeQuality: 'pixelated', + ...options, + }); +} diff --git a/packages/frontend/core/src/components/attachment-viewer/worker/worker.ts b/packages/frontend/core/src/components/attachment-viewer/worker/worker.ts new file mode 100644 index 0000000000000..e0d2e73003c7d --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/worker/worker.ts @@ -0,0 +1,223 @@ +import { DebugLogger } from '@affine/debug'; +import type { Document } from '@toeverything/pdf-viewer'; +import { + createPDFium, + PageRenderingflags, + Runtime, + Viewer, +} from '@toeverything/pdf-viewer'; + +import type { MessageData, MessageDataType } from './types'; +import { MessageOp, RenderKind } from './types'; + +const logger = new DebugLogger('affine:worker:pdf'); + +let inited = false; +let viewer: Viewer | null = null; +let doc: Document | undefined = undefined; + +// Caches images with the range. +const cached = new Map(); +const docInfo = { total: 0, width: 1, height: 1 }; +const flags = PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT; +const ranges = { + [`${RenderKind.Page}:startIndex`]: 0, + [`${RenderKind.Page}:endIndex`]: 0, + [`${RenderKind.Thumbnail}:startIndex`]: 0, + [`${RenderKind.Thumbnail}:endIndex`]: 0, +}; + +function post(type: T, data?: MessageDataType[T]) { + const message = { type, [type]: data }; + self.postMessage(message); +} + +function renderToImageData(index: number, scale: number) { + if (!viewer || !doc) return; + + const page = doc.page(index); + + if (!page) return; + + const width = Math.ceil(docInfo.width * scale); + const height = Math.ceil(docInfo.height * scale); + + const bitmap = viewer.createBitmap(width, height, 0); + bitmap.fill(0, 0, width, height); + page.render(bitmap, 0, 0, width, height, 0, flags); + + const data = new Uint8ClampedArray(bitmap.toBytes()); + + bitmap.close(); + page.close(); + + return new ImageData(data, width, height); +} + +function createJob(index: number, kind: RenderKind, scale: number) { + return () => runJob(index, kind, scale); +} + +async function runJob(index: number, kind: RenderKind, scale: number) { + const key = `${kind}:${index}`; + + let imageData = cached.size > 0 ? cached.get(key) : undefined; + + if (!imageData) { + try { + imageData = renderToImageData(index, scale); + } catch (err) { + console.error(err); + } + + if (!imageData) return; + + cached.set(key, imageData); + } + + post(MessageOp.Rendered, { index, kind, imageData }); +} + +function clearOut(kind: RenderKind, startIndex: number, endIndex: number) { + const oldStartIndex = ranges[`${kind}:startIndex`]; + const oldEndIndex = ranges[`${kind}:endIndex`]; + let i = 0; + let l = 0; + + if (oldEndIndex < startIndex || oldStartIndex > endIndex) { + i = oldStartIndex; + l = oldEndIndex; + } else { + const oldMid = Math.ceil((oldStartIndex + oldEndIndex) / 2); + const mid = Math.ceil((startIndex + endIndex) / 2); + const diff = Math.abs(mid - oldMid); + + if (mid > oldMid) { + i = oldStartIndex; + l = i + diff; + } else if (mid < oldMid) { + i = endIndex; + l = i + diff; + } + } + + for (; i < l + 1; i++) { + cached.delete(`${kind}:${i}`); + } + + ranges[`${kind}:startIndex`] = startIndex; + ranges[`${kind}:endIndex`] = endIndex; +} + +async function start() { + inited = true; + + logger.debug('pdf worker pending'); + self.postMessage({ type: MessageOp.Init }); + + const pdfium = await createPDFium(); + viewer = new Viewer(new Runtime(pdfium)); + + self.postMessage({ type: MessageOp.Inited }); + logger.debug('pdf worker ready'); +} + +async function process({ data }: MessageEvent) { + if (!inited) { + await start(); + } + + if (!viewer) return; + + const { type } = data; + + switch (type) { + case MessageOp.Open: { + const buffer = data[type]; + if (!buffer) return; + + doc = viewer.open(new Uint8Array(buffer)); + + if (!doc) return; + + const page = doc.page(0); + + if (!page) return; + + Object.assign(docInfo, { + total: doc.pageCount(), + height: Math.ceil(page.height()), + width: Math.ceil(page.width()), + }); + page.close(); + post(MessageOp.Opened, docInfo); + + break; + } + + case MessageOp.Render: { + if (!doc) return; + + const { + kind, + scale, + range: { startIndex, endIndex }, + } = data[type]; + + if (startIndex > endIndex || startIndex < 0) return; + + const { total } = docInfo; + const queue: (() => Promise)[] = []; + + if (startIndex === 0) { + for (let n = startIndex; n <= endIndex; n++) { + const b = createJob(n, kind, scale); + queue.push(b); + } + } else if (endIndex + 1 === total) { + for (let n = endIndex; n >= startIndex; n--) { + const a = createJob(n, kind, scale); + queue.push(a); + } + } else { + const mid = Math.floor((startIndex + endIndex) / 2); + const m = createJob(mid, kind, scale); + queue.push(m); + + let n = 1; + const s = Math.max(endIndex - mid, mid - startIndex); + for (; n <= s; n++) { + const j = Math.min(mid + n, endIndex); + const i = Math.max(mid - (j - mid), 0); + const a = createJob(j, kind, scale); + const b = createJob(i, kind, scale); + const ab = () => Promise.all([a(), b()]); + queue.push(ab); + } + } + + queueMicrotask(() => { + (async () => { + for (const q of queue) { + await q(); + } + })() + .catch(console.error) + .finally(() => { + clearOut(kind, startIndex, endIndex); + }); + }); + + break; + } + } +} + +self.addEventListener('message', (event: MessageEvent) => { + process(event).catch(console.error); +}); + +start().catch(error => { + inited = false; + console.log(error); +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/attachment/index.css.ts b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.css.ts new file mode 100644 index 0000000000000..d678bbac3c077 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.css.ts @@ -0,0 +1,10 @@ +import { style } from '@vanilla-extract/css'; + +export const attachmentSkeletonStyle = style({ + margin: '20px', +}); + +export const attachmentSkeletonItemStyle = style({ + marginTop: '20px', + marginBottom: '20px', +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx index 4f95a5abca858..5f042fe7a3809 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx @@ -1,87 +1,122 @@ -import { AttachmentViewer } from '@affine/component/attachment-viewer'; -import { - type AttachmentBlockModel, - matchFlavours, -} from '@blocksuite/affine/blocks'; +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, useEffect, useLayoutEffect, useState } from 'react'; +import { type ReactElement, useLayoutEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { - ViewBody, - ViewHeader, - ViewIcon, - ViewTitle, -} from '../../../../modules/workbench'; +import { AttachmentViewerView } from '../../../../components/attachment-viewer'; +import { ViewIcon, ViewTitle } from '../../../../modules/workbench'; import { PageNotFound } from '../../404'; +import * as styles from './index.css'; -const useLoadAttachment = (pageId?: string, attachmentId?: string) => { +type AttachmentPageProps = { + pageId: string; + attachmentId: string; +}; + +const useLoadAttachment = (pageId: string, attachmentId: string) => { const docsService = useService(DocsService); + const docRecord = useLiveData(docsService.list.doc$(pageId)); const [doc, setDoc] = useState(null); const [model, setModel] = useState(null); useLayoutEffect(() => { - if (!pageId) return; + if (!docRecord) { + return; + } const { doc, release } = docsService.open(pageId); + setDoc(doc); + if (!doc.blockSuiteDoc.ready) { doc.blockSuiteDoc.load(); } + doc.setPriorityLoad(10); - setDoc(doc); + doc + .waitForSyncReady() + .then(() => { + const block = doc.blockSuiteDoc.getBlock(attachmentId); + if (block) { + setModel(block.model as AttachmentBlockModel); + } + }) + .catch(console.error); return () => { release(); }; - }, [docsService, pageId]); - - useEffect(() => { - if (!doc) return; - if (!attachmentId) return; - - const disposable = doc.blockSuiteDoc.slots.blockUpdated - .filter(({ type, id }) => type === 'add' && id === attachmentId) - // @ts-expect-error allow - .filter(({ model }) => matchFlavours(model, ['affine:attachment'])) - // @ts-expect-error allow - .once(({ model }) => setModel(model as AttachmentBlockModel)); - - return () => { - disposable.dispose(); - }; - }, [doc, attachmentId]); + }, [docRecord, docsService, pageId, attachmentId]); return { doc, model }; }; -export const AttachmentPage = (): ReactElement => { - const params = useParams(); - const { doc, model } = useLoadAttachment(params.pageId, params.attachmentId); +export const AttachmentPage = ({ + pageId, + attachmentId, +}: AttachmentPageProps): ReactElement => { + const { doc, model } = useLoadAttachment(pageId, attachmentId); - if (!doc || !model) { - return ; + if (!doc) { + return ; + } + + if (doc && model) { + return ( + + + + + + ); } return ( - <> - - - - - - - - - +
+ + + + + +
); }; export const Component = () => { - return ; + 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 189ea393dd844..8c47547975b91 100644 --- a/packages/frontend/core/src/desktop/workbench-router.ts +++ b/packages/frontend/core/src/desktop/workbench-router.ts @@ -30,7 +30,7 @@ export const workbenchRoutes = [ lazy: () => import('./pages/workspace/detail-page/detail-page'), }, { - path: '/:pageId/:attachmentId', + path: '/:pageId/attachments/:attachmentId', lazy: () => import('./pages/workspace/attachment/index'), }, { diff --git a/packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx b/packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx index 1ea2cb240ab21..f812144781818 100644 --- a/packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx @@ -1,21 +1,9 @@ -import { AttachmentViewer } from '@affine/component/attachment-viewer'; import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import { type PropsWithChildren, Suspense, useMemo } from 'react'; -import { ErrorBoundary, type FallbackProps } from 'react-error-boundary'; +import { useMemo } from 'react'; +import { AttachmentViewer } from '../../../../components/attachment-viewer'; import { useEditor } from '../utils'; -const ErrorLogger = (props: FallbackProps) => { - console.error('image preview modal error', props.error); - return null; -}; - -export const AttachmentPreviewErrorBoundary = (props: PropsWithChildren) => { - return ( - {props.children} - ); -}; - export type AttachmentPreviewModalProps = { docId: string; blockId: string; @@ -28,16 +16,10 @@ export const AttachmentPreviewPeekView = ({ const { doc } = useEditor(docId); const blocksuiteDoc = doc?.blockSuiteDoc; const model = useMemo(() => { - const block = blocksuiteDoc?.getBlock(blockId); - if (block?.model) { - return block.model as AttachmentBlockModel; - } - return null; + const model = blocksuiteDoc?.getBlock(blockId)?.model; + if (!model) return null; + return model as AttachmentBlockModel; }, [blockId, blocksuiteDoc]); - return ( - - {model ? : null} - - ); + 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 ed9faa91e46c5..ea039b04fabc5 100644 --- a/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx @@ -14,6 +14,7 @@ import { type PeekViewModalContainerProps, } from './modal-container'; import { + AttachmentPeekViewControls, DefaultPeekViewControls, DocPeekViewControls, } from './peek-view-controls'; @@ -57,7 +58,9 @@ const renderControls = ({ info }: ActivePeekView) => { return ; } - // TODO(@fundon): attachment's controls + if (info.type === 'attachment') { + return ; + } if (info.type === 'image') { return null; // image controls are rendered in the image preview diff --git a/packages/frontend/core/src/modules/workbench/entities/workbench.ts b/packages/frontend/core/src/modules/workbench/entities/workbench.ts index bd1fe328cea15..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/component/src/components/attachment-viewer/utils.ts b/packages/frontend/core/src/utils/resource.ts similarity index 67% rename from packages/frontend/component/src/components/attachment-viewer/utils.ts rename to packages/frontend/core/src/utils/resource.ts index 2238b3eb09857..773200176af0f 100644 --- a/packages/frontend/component/src/components/attachment-viewer/utils.ts +++ b/packages/frontend/core/src/utils/resource.ts @@ -1,23 +1,6 @@ import { fileTypeFromBuffer } from 'file-type'; -export async function saveBufferToFile(url: string, filename: string) { - // given input url may not have correct mime type - const blob = await attachmentUrlToBlob(url); - if (!blob) { - return; - } - - const blobUrl = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = blobUrl; - a.download = filename; - document.body.append(a); - a.click(); - a.remove(); - URL.revokeObjectURL(blobUrl); -} - -export async function attachmentUrlToBlob( +export async function resourceUrlToBlob( url: string ): Promise { const buffer = await fetch(url).then(response => response.arrayBuffer()); @@ -34,7 +17,26 @@ export async function attachmentUrlToBlob( const blob = new Blob([buffer], { type: type.mime }); return blob; } catch (error) { - console.error('Error converting attachment to blob', 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/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 53586303f9190..3fa3a54596b29 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,7 @@ "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." } diff --git a/tests/affine-local/e2e/attachment-preview.spec.ts b/tests/affine-local/e2e/attachment-preview.spec.ts new file mode 100644 index 0000000000000..092d393912de0 --- /dev/null +++ b/tests/affine-local/e2e/attachment-preview.spec.ts @@ -0,0 +1,118 @@ +/* eslint-disable unicorn/prefer-dom-node-dataset */ +import path from 'node:path'; + +import { test } from '@affine-test/kit/playwright'; +import { openHomePage } from '@affine-test/kit/utils/load-page'; +import { + clickNewPageButton, + getBlockSuiteEditorTitle, + waitForEditorLoad, +} from '@affine-test/kit/utils/page-logic'; +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +async function clickPeekViewControl(page: Page, n = 0) { + await page.getByTestId('peek-view-control').nth(n).click(); + await page.waitForTimeout(500); +} + +async function insertAttachment(page: Page, filepath: string) { + await page.evaluate(() => { + // Force fallback to input[type=file] in tests + // See https://github.com/microsoft/playwright/issues/8850 + // @ts-expect-error allow + window.showOpenFilePicker = undefined; + }); + + const fileChooser = page.waitForEvent('filechooser'); + + // open slash menu + await page.keyboard.type('/attachment', { delay: 50 }); + await page.keyboard.press('Enter'); + + await (await fileChooser).setFiles(filepath); +} + +test('attachment preview should be shown', async ({ page }) => { + await openHomePage(page); + await waitForEditorLoad(page); + await clickNewPageButton(page); + const title = getBlockSuiteEditorTitle(page); + await title.click(); + await page.keyboard.press('Enter'); + + await insertAttachment( + page, + path.join(__dirname, '../../fixtures/lorem-ipsum.pdf') + ); + + await page.locator('affine-attachment').first().dblclick(); + + const attachmentViewer = page.getByTestId('attachment-viewer'); + await expect(attachmentViewer).toBeVisible(); + + await page.waitForTimeout(500); + + const pageCount = attachmentViewer.locator('.page-count'); + expect(await pageCount.textContent()).toBe('1'); + const pageTotal = attachmentViewer.locator('.page-total'); + expect(await pageTotal.textContent()).toBe('3'); + + const thumbnails = attachmentViewer.locator('.thumbnails'); + await thumbnails.locator('button').click(); + + await page.waitForTimeout(500); + + expect( + await thumbnails + .getByTestId('virtuoso-item-list') + .locator('[data-item-index]') + .count() + ).toBe(3); + + await clickPeekViewControl(page); + await expect(attachmentViewer).not.toBeVisible(); +}); + +test('attachment preview can be expanded', async ({ page }) => { + await openHomePage(page); + await waitForEditorLoad(page); + await clickNewPageButton(page); + const title = getBlockSuiteEditorTitle(page); + await title.click(); + await page.keyboard.press('Enter'); + + await insertAttachment( + page, + path.join(__dirname, '../../fixtures/lorem-ipsum.pdf') + ); + + await page.locator('affine-attachment').first().dblclick(); + + const attachmentViewer = page.getByTestId('attachment-viewer'); + + await page.waitForTimeout(500); + + await expect(attachmentViewer).toBeVisible(); + + await clickPeekViewControl(page, 1); + + await page.waitForTimeout(500); + + const pageCount = attachmentViewer.locator('.page-count'); + expect(await pageCount.textContent()).toBe('1'); + const pageTotal = attachmentViewer.locator('.page-total'); + expect(await pageTotal.textContent()).toBe('3'); + + const thumbnails = attachmentViewer.locator('.thumbnails'); + await thumbnails.locator('button').click(); + + await page.waitForTimeout(500); + + expect( + await thumbnails + .getByTestId('virtuoso-item-list') + .locator('[data-item-index]') + .count() + ).toBe(3); +}); diff --git a/tests/fixtures/lorem-ipsum.pdf b/tests/fixtures/lorem-ipsum.pdf new file mode 100644 index 0000000000000000000000000000000000000000..784a17c5b5a4a440a439edf56d18953e4b60bca8 GIT binary patch literal 23286 zcmc$_b9`k>*Y6wK>7-+JY&+@Lwr$(CI=0!dZQHhO8#lf8v(NLK^S<|-^VhvApEXy_ zQB|{Qjhd_S8?(MeBqJb1MMup5Mbx){vw!;gHhZdX0Ez*R7SCGG42qKzk4DJU!okq) z>(fHl!BD_Z-`c%HF}wP}dU51+YME)oND=$#YS8u4CAz zH@RgA=?&orGCXs5O1DAYx~Iw+f124c_LY%V!1Ei|{QUY4L(zC*G@UYNA|>T4c1NlD zjU#8bJdQW@ilk`^!BRo2@&yIc7x?|V-C*{@{KBP=Qe=%o>Y*!N_s<{uByqSGxldN@ zT2$ZTcY3;Kl~VGZPwMAVl*4{^;mMSuKhT|UT;t0b#!+Ttcer{l`VQ+6( zL-^qitOw)bBu<-E^F^S)*W@f|;(0Tpzc(%S#U(RyywFJUpv=SeQ>30mNT@cVqpao< zBpe?k#u8LI&KwoayO^4b$&(O!<7cE2=T@>SN4i>>lWQwFGE{HQD1?nLucv7!D(vV# za5V#WI`e<#jq@Ct>MMV6KfE?adfE{?l_}~~B4?b8%vqNgYK9wQT|sBYTS*2E6TXaB zlq|3*`KeqZ*3LdBjwled$ib#i6+oFrgB}Grp`e=1D99@17Z(frYXRYqAaUD5!tutq_6rvzwFr^*{}_n!4tRb<9riwD7*zTpKAtef~$#8Of$|3^;<(K?(9nvJ0Dlt zeyxQm`tixBW76aJg2P`MJi5CHc+5m(sR)dTy+Ki;Xv(;Cn^PWta7D?9#IK^=9b(RQ z9!i~-TUaiOzP|#5*zX$#VG7Zm3E)B5?}oT#=__=17fCOWhB9A^pBI~W*+eBbS8iq5 z*M`;y9Z3SDFOj3z6dU0^{8R0Z+;d zBr~Y1%d}(OiMoZFr27;Quv6W@C2l?`^~&$HIkJ$!JbzPKc^Vg#pYB5Qsd(b6gwuldNdrx5Sy@(#`o8ojD-<%8OG#jCai~-N}CKP%h z?Iu4gP76bhi~}UyNTOGkv3m%n$;@`WU@&Ufi5GuEWkTpj})AdE5MkxCoWj z;W7+P?pW}1=N(bG<77-ieU2u2Zv*G^_uXk9rcr?8M~k#UPC{Q7phrcAwqesNdqNFJr>() zrqB4K)6En!SN)j*!hHs1h?$luzH59suCf)ho9}%p_tI8&Kk}W&ksTdmb6}GKRY++S zmCL1tR67hNCGO09=W2rMb4ooXHcr0yu$q#hHez4+m3|UVe%T1qN?q=0J~h=Sq5AH0 zH!@e)1ziD?^7>Fh56~cz)eg4rVYdhH%e<)=kTet9n%FpS(asU07YB{EcTP|b$^s*G zFI?Bo@c9N;JH0sm2v($of_8VPbRVQS2|HaA27&4@WqAh(JD z*n)C%LiG*q3>Ey3?6lB4O(Xgrs9gET`eVATX(UIbPO#{1$3+#I)HMB+jILJUk&xm} zXojG4J$$P)fa|`2Ni8TflI5|n7Zdpwr_*utkNhFRN=m$perI7&n8ADQGhnNMeYlH? zqy54#~6EFQ>H zLom_`PSZM)8^m^#%PE3@l<1_KLks9|mZd{R+Y$IqINz0&V8E!9f6eM1jD5u5^s`cV z9gd=bbLu!iY0Ful;0{JZI=d(y6RAnG3yrN!WFzY6s{aKF*Y^N=E?4vCfhcvEAB1Mj zt-(!&9hA))@NXVX;kh3)rFWsKCc%c%7wK)>H^{50NG4 zCCvDNTZhenR3lB0V_Cv+haM|1ur8}fbzZl&&PT?I^DCRlieuKX%Fw#pJTuCjOh~&0 z#pt}06twEQ0|VQt&Sb)MkE<6+Rwi?HmUvm;PQ>{G6-E5DGLEc+eu%ykvR(vBC`ajL ziZU>CDI=pqF?k!jFhtBPDB22d00W`_=9R<=`xqLCl|F6jfYIfBsqdsd4Gb@F2Zm?a zzUiQp@`MRE54roEA-;t9ZxYprc3OSW@K=qh&nj5)&Uboq+2Y2)r9i$D`pU?EeT@I;!<+S9v zY}Rm!sen8kFr}D@jYAWTy$~42FlFah&0{C4-0(29@uU z2F|O{Usd&%w=Yu!g-OsK4L2G#s&2^JjM1)M6>NZQ{6aihLx0zY#0BGU48ZSSAnHG{ zB~7~|?rXk1TSVGfQ$4iLfyS5Wd?11EE>tf#U2S`mLTKfLI#1!I&8~kUkk5cV<^&|J zPW}drlI|<-zwwMd$tl4AQavXFUFE4aT#{Y!(9)y5uya$G_RC=EIx)s_Dj9sK+GZ0k zT&W==yNL^pCeY4j4bu;U(-y+)FrOy3w?=1xe&?uc1IiG6y3^XExet3*YJ;Unw$@Vj zab(1R(<6qr?&r9vlsYzsz1CO{!AmPsh()E4i^s*;EnR$@{x@)2ZWz|&`YXKkdTjJp z7e#2bYh>6@Ikdv2SrC~59pixRL;h3G0b27pQ8~D6 z4BNfccu;RgQ~lT`;B4Qh$nsXG#hm#7H*9A%&`WS!N7AA9JC*Gk95y1xD(KdojE?C}y9gYqOwil6*7HCHZ^OyOc?+b8u~1 z?NQUGepo8$uNfI~4WZ-6hu!V%;dKo7w$i0zyM|fRzaLG*HgNt99TtKVxT};3j#K97 zZteDT<`{ruRcS{w3eC9f5J6}f2=EZ)>!S-AQx9m$^SQ`QdIz3mr>%jtccL~MO-J;k zmKllk{ZXZ_=!b?8{+znFiNN;P@9j?|V`MR*#*eKk#R8`6Fh6Llu=JAh3mXLy%MWfN$$(|u1t{w1FlrfzTh=Ec| z_~{8F{5-W|3@Cq@zhm*O7rCmn7qXRW)2jFG?pP-ud&Mt_;RYHaf{BkGldzAW;U(i8 zVdOp!kgY%`lBv389T(4i^0?LfcT|C6ZlmhC;bAIZh7_cI=49q%NMMIMYmWL^l-rud zrMT=ZjU^b#A*o2P6$d12kA~J>^v4K@G^IVngw&PSCr^fNfIF0-mBGI>^H=JR(x&?l z#jW6K^ChumbdCQy+8J6o;L(3&%i+<;8QNPr+UXnG<1zki;J3DN_{z7(`=i*u3P>6n znCkLbyWpwQei@k9n5gMl@mLw@s9D)Gp}vIrzgqiK`yYKTV`r@|Z|H!h{?(*_5FU+! zp^F2aCLRsHwS~2vyp68DA>N-bfZv`DkLh2H+SB8){*~nB{)!8}y6{y3ibg?!4v+co z5(*0Rcr5>v_;<5(e>(L~yHa0$rs4e)TUdPs7I-uQhEAsXhH}Du|Bf!0>FEFa_~SoA zj*LWS1^H#v;K_ED_9-L+Jfb@cH%Po04iOX(*-%j*8K9s5VkZGmp>K#}N(KsDfWpB9 z`hwq}{r&V7A#V_52D)Vh1ozh?%pot!nQk{y-v?DUmk%2kmyIty7h3>i-uys%98>^( z7;{v}Tu}QMw60Qi$`IQx ze0s+au<-u?1Ar2oXXf-G{=N?sFJIe_PX7rqa+g3xA4Erwngf|zMzGsgY4Py;fTDFc zIf-~L2SarNy^S$5qE%)im1sLJ0whNxnP|&6&jZ}BT|qPzVE>VHcJ?V<0)X~giuTwc zagIeOz2GR(wBLOytrG-%-!k)rHTJY+zqNoydduSztv0aSqZ{I)k5GYL4lSBCjF6Gv?T z&pCS@+Y-D4@&^wv zQ3iHIxs>R6n*kQxKuFQx4%twQzSOiplsd3jJ`6U%8+ZT#J|YOfiNMq%K&C%IYIwPq z0HAp3mH?gn&1?YXeBGu%r2Lp|z%)RVyI^ggVSI#tLIrh;(*kbznnL(pA)w^)mPDiG z!>8~qAYiINxbpMLuzmyX=MRlWi2eCIMO=hN?q8C_FT-_5=z!4zq3QpU4L*g(2=)%> z8Gx7!G^-1{1km8KT*ZL}A>1>%CE$XR4*tDsbz7_nl^d?H=leG16#!+Rc269{&mjol zX(1H|%xeL@Xq6v;i1_9)1o=S4f~nDP`Iya-A~Cjmz+k~Sy2)s$1L(WKsuA0IO1cYr zVzl$g`pHTX-w&9L5Ey)){R;Hvs|!^~%6Ob0nGw?>srr<3qIA@&lB$81bk8`j;0C+U zcd%@{n$R@pt(Yo68sQfsT76D?VYi8|UE0VuzFqpkciUg-dEj?Id1HCwY)0V@bdp3v zs)3mM!Q&_5{jm6J97ED=((Z71TxpDX!fBE_k#ziGl5>(abry*> zzPH#%;ad}sZ1>M@dEdM$r5vH{x*o1TQNh&Q1icWwRJ~9={~e}&S)#UB-`GYZ>EfTq zRWn2d_!xY%1+Ftqb&_@LD~2ny*upINud{K-{dc%W?RSudM2717r2AO=82hy&a$r5Q zI!_F8=mimR5v>tXw~PnPle?3v%$25EpKW9b&*WC*9ti{qMhSokQ6&mpH#Jkx;4F+-S?hwoy1|JVsv1jV<=)I zF%B@Hr&^^Br|zd7G7i?6s6p3*sO_sA)@#uwq+EO?#$*<_cE{&p=&A)uueBl&3nS#8o9z1y!-E zJE?lU^G!)?9r$x8Ow;zT$>bK zyj+|?sPqH{`1@K#qK0OMgu^3I#ZjGwhK2hD=Y=H&-35~b#gkMhRcrTzBGe-A3b{~m zF;U!qIN23H&a8_E@1E^I?OsqbXe8F2t=QG?7mrR>L~D1e+LGg7;$m+ie1;S2b!) z{`DboFB0)1jmTdWy4I{_!&ulbYeaSo@!)PRb z9IiZYp+aM|S;|?nyJh>n<21?=&r)RhYE)=6(hzggDr{p;He0r;L&v1{@?e0knNN1R zooYfI&Z6G}#o~P3sJ7f}HHkoL`d)8zCu~QWXf5`1Q`J-Xd4235;Jm%yq2Sed%L&4H zZT-0ARwYGGRcm>wVoS4pf9xjrrmecE{)sO} zRO&(diF@OD#>(L8V&|r%_bgx}a6dRV&Nr+!@1aK-^KxFxe%-b0vfjzl+>@40V{dJ5 zfpd}Tm`UM4;ex@QD93q@xkZ*DmU!l#$k>7EUE4wH_pN(=cjVGz8HT*(ClFSAw(Fh2 zC)~?S7|vzaFwc@>lJ~5KhAFi$wVZ{uh0un4W>a?w_swIU*$nInY$~3#PmjBZhm_RX z5o7VmmORuo*AC)=Rxn03plNriP~pgrpNV{mhHzjweN|$<-a;WJr7?rUuiZTT0HlEeP&<+ zhl9ObPgBQbV@r=2#zMH)3pY7(7KuMElmhhCn&p$016}R7=WiRSX z)`l2I1vT;0d?q|sO80_J7Ee+L)d`&lmkUouJV&g%Rp0erjYcHR?gn;}eKb5KOqXVP zIls0W>kao^nNOdl@Q7^`b+ElGJXjtY9PQ0In_d?F4%;a0;Cb+U&w16q8;m(VlYWv; z$cp4~_JDl5aUr%eI@o*+xPASv>idtd{i932G_WW?KcB9>p#k1sVpR@L^B>Jj_n#77 z3Xkr;5wF5_x~~62y)rPcGX0;`Yk@MjhP?7B56h!bRZk{;k5D3@;b_PhKuz!#0kLlY zq8=VZNVr^?&iYQ5>~wCCU@3|(yKK19$v`>HoE&d)znX@MXI7d|&+5m<<*S$W#ih;X zA=|F%=j|FW--^16oGCRZemn_Td?1O`#3P;2F?PQET^{A!vleh)1Z0v+qFcfp-_v@Tu&wKo+Bso>)L|fDVdS;b<2mz9!jXQZke($gQLFH&VNDlr4$j^kkK;Z0JuvZG?2V zB|w`rks%^`BjBe{N*>?z-4KNCZxH-^fd1G4jZM*Mj1d&jVMwBEOQLI}D4=mpe$*vy z%rx|HRoFTlx>l^k-yd>>#G!YGxN@YaV)q83Tl@gBzN3=91#(Bql8S`^G;?g_^Qj^= zqn_m`8in;X`Sb!@q6`y=usw-mMjw#7fYIf&-WcR;;{_*Ag&W?0(8FyJdM3%lN(>;g z@_C2I(c&QGdN6{2+{A!55~7m-)Kkd+sTvY|lU>~fWNflcD-I+H#sDP)H&hGo=z&G@ zx(8(!2_4&`!aWku++1ZRFu@13#M>OZMudy8Oz3=}cs7Q+a0G?nKi(;1hxR?k3++2# zYFNv+SiLG*_?K?HgkBZYt!cG2U(pLvDc$lJFuU}*8J034TqTHaE%B!!JP8e;{^Ljr zQ2P0nP@9naev%Ra2f_l#A0X;EUmX#P4(H=m#mAg}8MdEk?1)l3^efM6$HlIWAc#kENp?9Hor98CQB=N#j@c;eZq8ta>9QC z+voR4Gbd$6lkGnDdbRd!GWi=TIvvShV;GWX^$D#aS+ph^IA&Ne;((WNNCFbY6)M<~ zGsGHDKpL)%(Ec0<1}!7J8V27PlS_)8ub2z^LGgi`F0(Q^8V3J9&f?kKQt7P4UEEv6ZnI?@&0e9U`tSxIq4ahA z6XF}|hF{|jb_i&@(i`JO?$6h2{VR`G+w~>noQUfsWZs)N9uFXqOXy%W)|aw~3Md2a zAbFf*M)n1LiV*tOF!_->qZYbj3E@KUxZb|x#H%I- ze*jg6^5kg=ezZuP6$bv~UG4$>ANtLB_~-)km&o4}9BE+7uyiqwvmk0{Gj?s9ZxcnhAvp{RWq#Do8~h?tfTGAZH}>90a5#*gKLMXE7gRkQRG$~B4`5v zPhf%;mtaHBX@IRTFcb(8FbzNua7ApsXJXxrWN0yL_B@&Si#yO}k@OpCuP12EmWOGm zL^=_kUg&ObpNSvm;QH1hNG=fLqRcg-i{&>lnWiVA+!`i z^QV$lpK`4s_>S5f8|x53QBv4(pi_ZparC|h7%JX`B8+mr(`lnR>~lWYB1CLH;b}St zjCUXET#6#(b$=Qec(*XHDRIUCT{P_3E(bdOF`(N1bXw;zOz1AYY7k6zG!8u#V4?fj_S;NG zvRFz%n>>jSROT-meN|;MB<=ZP?_#`FA z#m0rHv?R+KR4nsWc1VEal1PDo`iutz!AYMQU>Xm(r} zx3oG;9zPsS-<{nzxh=cRyKNq6&R|Y0PG{w3GP%VU#hl8raNk zmFJ}356YM{ja5xwrEXK?*$VLr@#T@{0SjdaSx2f4 zuo=(rNLi+RPteyDG1Dh+F zTc1jtIxbi)05hX9%Uh0EJS?x(D^}k(pe$QeSvGna8e1osHO^fWX;!w!a4mMsd1bwO zg2M*?3~q*Vgku?)9dM7DMDKKbLlF+k3(gy=UbwD7?jN2NJ*%5PE@7Bo9#q}YIP@45 zTo2rp+pC!@Se@@;TB%>VFFLJ-WJaDLDa)MV-sW2M&w$=+bhl!+im}?aD!1}(;%K>D zH($4wm`g@uww>-f2%RKhlSGnQVLomMt{7B^D!{SC@pg4{9pS{}dTq04qi<_)S-)Ah z{dKE#;kglf!q9IVGf9`l(vjH_*b(gE{b+wZdcAtO$g%8IV{=n69Q9WI=K8_$)(WHo zhz_U?>Hw++b^~+@BxL#P zg>8jS4RxJ8fu70kWTp5~-$MTfov^+vC5w~gnLq1mQ?u>n;Ox%sj`^;)q>VVKxQX5KlhuZH(<*iO(^nly=n*RrA^x0pDB<*-loKHq|#y4gz=ghtl+= z^mR5|J9&HfJ-QqE8?_(ou~@^)9=C5hZ>4f1rUiB86_;!G_TUDxH7`JAI!3(qIXG|f zEBMaQ-qe@WP^2YPZd^2m8{2y4dZM<%q58u$v4aJ=1=^D&2e*ff395-yWT#qPs#W^< z4K@=!4T1~d?&6X&Z2?q1y z^S-5zWz$;nEeF#p#pY=X)eDN{ulDa}_utKk=Pfp~TX;Ki%=PaE4kVe=lq}sERqL8- zoy5+4sS4E-EnVvstL|4CvMnQ5#~YNby@30oEhhr=jYGz&nZ`)Pb=az&$ZpX z*JZ5bv^75$t%ffuxFFppm|d6{&zRO*?QjjVJhV4I&|fP(kZ&aI^Tc<50fT{;z~}nk z_)B+;KRq1e6!BXIx5Kk|8NaS461E7Jq*fKi7W(Gp=4phfO9dl*UzNHlezl-`z)_ z@~mxbG%caey51YzjITBtt(~+`HLbgoJWkxU3OWa0Pz8m1&I6Z&Gr>#ZF1(w+b;3Eg zI$>YJ9bY`LT_3tfJw;`H$X$)LW`=1qd6m0bTsP$`wm+(9t86Ba%ziCxC2(V(RK7{R zHd0MuWqLlkPhDaI&w{ySj%Ip4=|4m7esBFuzcXIdS=;JOa-|8@Qfz1TYCnv8OnYc7 zw#J1YcNg|!F~p9P8k62kon+5R(x`@iR!Xj%VP zuIbn6UtE*I2BS|ejN|tM-DuVaD^zp+wistz)Yqw8Qc3VO6BoprXZMoQR#7pAAK1M< zT(SMS)h~0!_9b_G+DCyG*T&77V70K8sJd0la)`6uqZ11zlDwYAguuaoAYD_mbr3GMW zR4~Yahuxeh493r^3Z!iC#BonmPc4g*KvuNmeNJEC-su`=zA#_SuYqtMLt?>``+7^3 z@CnN?4`U573xXl>H6pDJWtZ|NXG3s{oY8 zRa2&9hjcLV3UZ+od3#b?e#kQmbYPt{Qu%VA;k?bP!jnEq$TN&_OJZY%f18+R8 zsf;leU09@?`;?xKfQ(ygAc}p*2lto|h)VOo7GTH^Bf`A{8`n9=#&&{Oiu>?Mdbr5d zpTUiMP7qgtWfG8U+5C#rQLNuSQ_!V_e*)eCa7-^<;S?Rz-4LB&D7Q&w=p!GOruH6h z3Y2CR*al%u(#f0l#rRtLBI>J%;Z=E}3P@J*9 zcv-e^c$sO9PCbNeWUZE!grDFU(&MF4+0U#lTNlPGq zCTbjuYkVtFPbIMd8!GMp{{4B$pQw&vf=&Te?}DaozQ+GUnIkNCh{SQ^TMCT7Qpyy7 z@LOa9lKZbjd;GM+$u*i>PRUz-sS!T>lHtO1^cH$obPN2_{?&Wt>h?+DmyOtcM=93P zB0eKyIa!6@1tS=8SS?9PEvwG+_R~xN~zu zfv>wb3VvemzX_vR3)%_gqCXH4syf`oDb~083qS5501A=nELB>-Jm%2Hz}SnWF*0Bl zSs~?ejJn;A4%bG~CW+BdKo5zH4Kalo57>umMYn<{n#pCx#|!Eb1ft8mfT? z-~CaX!n}u%a~ubzBeinGk22idv~+vp8|wk@3)e>r0zSWPckQIPA#lm(swDdpHzSc- z9qjaoTisX7MtjHDY99)TGji1V242rmWU%|)Y%UXVE0%!P9Gn<$*F1S_m5A+lFSCLy z-TJ@|o-IEaA)E39$XlF(g$ZRV6PY!tllUXCR!SayJZjVi?J5)3`!p30-q}Q#GrY*C zntGu8=iPGbg+npL*eXGcTRUNL?=jxP_f_Tx@SD#)`-}8oVPOJ-hB3kn{-5po`+Iv! zmys@(#Tv)G1JX6PdK=QcPz%bqW-{XFxJS8A zk@mEO3y^~ql>zgDy^u%N3$WO`1fX=hN$>z(y6-Qf$fmE{HRD39u--XN+arZcEnWQz zICdJ{8rHE`rtF7?=R?EhTZ}XhsW<);!QJ#LHPTra%~3(5dSGH;V~Ys2A#<10^w6h6 zA#!CauJvzts(O8uNDwlAX?;i%w_^s8+&M9;wCPy4ZjzxZfqJtefuLq`!T zFdjKv&Bt~NDvoa~CqoLyjk|iVRM@Y80ZRtdldIC}dKi1iZi9tXw|9gpGn0H$S9yC$Yov^nL=0w#NK}DdL*9T5vv!TD)I_~u{&447lCl^MC z0f(t}3TT9&CPT^fOEY)tmz^IXJy#}*A2xuCC_#BKOXn?Okme0X``mDI1+9UAX#$Zr zbjn4uZ#v|)JA~c_(_S8=9(ND$Y7Ve7d{*XxFQ<(fAp%9MOpp!)%UE?@0&DNypaeb< z%EmBs(xY+PAoylTQH6<5)lZtTIx-Rl<(55B%?#O4Bl5GXu~$l*N2kML4cmJ3DKk>1 z-WyO6JPApT<96wo^UxZU9f7=<^=5vEJ0Mbomt2s|(}ORM)A+C2P) z(``SX>C!XAH|Zb~>~n2t#5MrNn_z4rGt@kW-kHvx<%OdiJh%oilU;h7stSA351h@7 z0vP?iXfhnGows#M8}kFo4kilo%;V17GDCqC4y6po^G3}6)N`>-6-VZk7b%{@lr%y% zYF;ySLabT>lNSb^$subq#!!5JZZZjSU9vUm-if@SITLH5iH`pJT$H3_A%gjFX;rP8 zq3lRy4z61$l#D?!c9B^9$f%YLYW>=U*{VcrpTV)zP1!3WhnuTKSVvD5!~-*o`rDL@ zwNb3TIdW+0l(4RHhxiehrp+Cv42`Yf^mUP|^;Hg98`}U&aYG>j7`q_@O8jp>UjRmHaUP~dl?mYWW2-%`;nK~5dF9wowbpCC)63xZw zb4sGvAbTB01?D6wg=sKv%8uV{=L%ygIVVJdwc9zW!L*H!dI=Bi+`xdTF79 z4cVc6cJ<*Abl_Y#@&~_YEAR5S&Sb3EX;Z4ORs83?8N3zxqOR+;C~B$^@RXG&@DNlv z363Luq44X)@OVERClk~`IY8%&>u0z{CaMlVW>^=$%wg*C?3hln-nCw95slZZ z#dFOzKKjY>lcH(=7UX;y=U$s4psKY1M;n4G^GS%rqmF5-;i~*R)8PjT)x#$S)hrtY zgY7|arM!8$W|C?x41Py-U!~`5&S*gcQw!{`Jx!y~bm!6r^?`AACMB*59}8p~{c))gxLwC0Q!eMy@@Xx_wxj12aUCf&~dX}Y{Jr#H;#Ybw>}6CGTWj?|?f zu7_tZ5=lg69^T8P$|Obx0v6Yx4(kpsADYan`l{F9ApW$hCi&LgZZKN_+MSH+`SeWd zbQSr-2WVw=-OI>5z=&i6t!uIAS!dv2o|BppUKKKUnDWVUBJ}FXcU`yG7%h927|{b` zkL8~86IX3A{W0eDwCHu=weLPaFO)U26FRVxdV)F~kuy1qmuAyD{pZrVCA$9=RHFM|NF}KMhE)0^z5n5&{{KRi{*X-npi2L{EfB1<^#4<& z5UDt7waWf&?Uh2xnR%)v(c;wi>;+&YmlQ+cNK<1ClpmXeQbZJW*uy*6ZAsL97Y)cF zl)y2_-Y7`8rGT*9_IxBm1e&}dlvL~_=&}e|H9PHC5&X z0_K`I)V!9SN`B-vgR}t@Pt8qPmR=I=lU&@olw9?P!uheb+6RQxv6Oo((|z}2x14dl zu_*it@5+{ze0c%UH)@`NDG==yy9sb4KxHT;fH1CMH^P6q1wJ82D%c2D;!ja?vG ziGCfRz(*B|EudT5gouxoTOQ&{H9JD=Zjje-dH|i_&~Lv&lGgQ|9^_1QfLq*Mf475J z^^Sm(9C;q?${Z0shq)h{X2RxXEIM~*Gu*1I8SSO-j49WtDRgEYI?Hp9(r|4%y9(J- zGwR4%-r|94EmwhVcikXJeCLekYp&A&L>Vr%F+omAtE8-^5igSpYFKBAcr$*fohFIQYwnhe5CnSNm+6Qsg!W zYq9DsXL`byq1W=PXpfByXLQLn0!XiEu7OF-iw?Nv%`9>R!!&Tn3~q0a*Bzb%8yJYZ zi++e6^$w-$pa-^8)QN?5#&fRt2LyweeX>;br>!L8j-jd1^8AbitJnAlU?%4}DN30? zhVfp=Z6fh#`gaXR>4sLNRFMzqwK;hedwA!Q?(agI_&wmf>8e;i z-;0CEnDBvlFC#2Yo+5+F22^!pI_K8KSu!#F;kCYXKZGuPJ}#wV1#q%=soe4AnI{rA z42odIeSW)^1+Y(zl){3`UEPG;;e!5e=9GU{U;ki+|5tO$-)P|%q4+QOk?vpE;Xm}_ z|7lM7Q~#ge_Wy_Y<9|PQ+W+sV%|9Tf;}>E11xt#X8rXkfntwmk_-iJXgQEMJ$<+MM zDf6F4A^-L#zpjI>g|+eDP^taDXwrXAvTXnI$3NG`qft>+`?CEFj(#Qn$0GQD&We8z z)nE80jl83t!(Y^@f}Nw`-x+^a(f`im{qy3Z5HYlHGITK2*OfAK{Ng?T)V;y zI9S_3(f#Sf7r`oM^|iyy)at9RlDhhG)|R?f|F#I3+Sxnso9No%F?@lc61x97p`)k8 zqfs_Ba4`Af9(op5D0-$pSNpGjUwL2FzmNYl_mAb@c}z@SUlxj)krj{mYayS7{)=d( zWyE9t!T^|981Y!XTKHr8r=>roSy|}*veDxGsr7aIs`n@LS9yARs6Tc8yH=SPSiVqQ z2E70Lq-SIPtKOgM(*J4a>;2WcuOllH1JobupR4{=>#HUM!&hxuT0DlY+u^Uguk;uG zOUL>J^8S06|8ns^_x-;|@85&)_n=7WT7C`B|I46R=o){)#(%Kfui@kSB8{Qcso0q4 z@TeGB>G0^-SXsWf2^N+wd|34Bjx*KgwKBFa{OSq~uf6^sj+u?=tLmQy|C~_Ke~AGa zeq9?8LsMfD2Pl>=u|Om5U}&lIRqhW*q5Id3@I^4wsQg{z>npMRT|@+r_0LtEjQ<*U zJVwSZyQHqm-)CQLVuJdshhP7>4ZocC#~X70;fcQt|9Il>y^LSIr)Bwa+do?ynQ6cL z`rmGT{Br33*zY*525qCS?8<$3Vkq7~sTzAhokCR9_8cQ@fskwS8yio0|tU8KW z>Ad*BjW>W^6F>>8izznLf-J(pzXGaek`oV*6O_2c1W0dy5&M&aL&8_I1(@oa*sKJx zJj6G@`u7iaqDo6X;<%X_oJSZu8xcB0KjokD3G;eh+8&N;9d znrz-lcID5ID}z?*nq?b{(=8-QQX7hzS(?4|pHw8ui3x{!ebTGvrOxSc~N-Y?!|dlajy=zciNAvbI_9QH1@S&PkK=oJiM^48=ZA& z-dtMO$C)z5wyN40yk5ffwp^#2E_`bd>09=YaJS`PaQ8=k6 zqaRG4D^D}2-gDj$dowNUl#NZ*ZAwzli_6=~_D|gOm@2rGt&Odt`*!dLva_5npiz|x zbiHXM4A}|6a_TN3amja;r3ACJ47I1Jkz55}ENqmpee&-j$T@4e!G9Yc zFJzu~jS<@~9Sy|Qu zop%z#a%e>bWmJmyR#-|on=Zj-YUb-sHUxpMpITtnO#@9_#4C5 za9@Tz=g*pgu6dSr}=9IP*f);pHT#L4dA>h*QA~ zuR-f^_N&+DRQ2?Xh^*#V9$Z$&n|pmy4sZ`lQm8Fqn$J6ty>CB(FU6fTVaiLLG<&Vz zocU;^E>0^+D|avch|fHpJo%ikS*g-};`Q&$`MsTm!4(;W9)*@=ZO6!EM4$RA?Sjq| zihY=NqLYj>Fg{OzG(H%CJthjRXd*zx4JE$C4_uL_x9AuuUa?nAF*7lX-Q%6=%-jOy!F;ptyHT$>Y;>%zL$pHh@d@pQk0NbrE`;j>YF>8j z{Vlgw(-4zTYx8e&$NKKw26K+_V5rfpEn2O-X2r4gIP-_JH*7HZQWrIyzDeYvadIO) z!Zdn;P(nB>kZ24nS@WOf0LHK`fulFG$dbz4y1+q@rz7UrrqBi~tMrOi1!s#&rDsNL z-X@OqH)m3XhB$UIo3}kY{=sOkL&VFHnG(RBapqHf`L6(Y((gW|BE6Zt{kM3!UPG`9 zO!OqAP~<)XyoOV3Q=n5sQ-)K$*%aBoc_VMr6!H|xO#_+E7fmi;6Pp^u_0z6k+S_dM z%``KQPrlPD1N zqw%Jlcg!CYPHs{^lWclv?bWm;Q;jJ2p{#Jnl;US;sy!$$fQ^w#w65`q(t#6SZQ>J! zGzV!5&>6v=G4|cx4nz(UW;Vv@HbStUVI9yth{u!H<@GnkWLEmYaIt;D=N1x=%Cq`M zb@`feXE+-^XIjtO_vuCccF12q(S%K;g=+y)383j=1CCQo6@|7L9xP$LKqgoUU>U%g z0GdFWU|cXQKyDZU7*V;7BTu1)1{6Yc#&yib-|U6+PT>u|*2IK^yU?Ry%WQ6OJB9h@Q|*Sw znxl@-=PW}X|TT)@|y;}jbPxnay(%s-a z7KX^(s@1-WAU5W<%Rpg1cG!}pd@c; z`Hp;QmWp;%S7Qta&_(sC?LH0_>Wn+|kky6((7n)(n)2e${FmfJxxp9Q9>_$p2H^#>7R(>WwiFMEXyOI$# z+U)(C_kE^+)bh*r!J7GA9#;&9JZa}iJZK53)mX~UA4u-C8}+vc)fe$8Ggm$h2|tdb zq+h5oFY2ziHG5pfG)#WZ*C9aTs^F}#Y~t3K1UOslvw#1vxnRip;o3wg&9g^WHCC8@ zCR%+T&F6_?Ub=q|p}jP%aIFY_neT<|@^fQ&;Y0E|iM;gf*9h>NXAgsV^Rc$Xr_IX% z2Ejs+@l+z5{#^^4Im9>nXPnE$p15Pcnxg*n;tJaOu0e9lH>uQA-0WhMbWOksq%#_3 z6^_E3*Z{2pQfao38cU89+7()$$m7%qUSwJmATY_UY)CXlK!0MLDG-=cpZ_$@|0}=7 z53{yKekvBQIx~?-`Yz8Xrvum1QBmGZ1gU`^i7jr;CttbhQmCwI@2uS8eG|m`I?>~@ z+N<+7E6NQLFL9UHpb(7>V&^3Qkk2#(}fsZ*kz9xpCv0zy!G@BDn~-c=t76 zYd0OUyNv?97Nb?3XiImWWCaI)3A54&(;Q4-5uYCzb&dw`aJg;(&wPm@+O^;o z1i2eNf){EkMQfER`F;fcC-7>I2#GIP3yO5F6J;c;sRznuZYI7Pefm$NtnoHEy4)kF zP?Z>QW74p}0EV#k2V@@>``qZpJsd}#KP3$ZUxl0;4pV1)|H#^fC4r*zE3oRat`-+`vtNz*T!Ms;d+OX-oj* z0sY6)~^plVKr(zDy%i?b*n}Q`B65*G151Sg_A@~W%H%sF~kbWq>%>`tY&(R() zHSJKv-&J1NCCB1R1H1xSjs{_h%+Zk5VtIY!ur*1AiS3LK));#~VCi$gmc{+g3V0u8 z@Z3$K+Zl(nFbrtEihP*~jcHQnf1=Ik7rRjk@ht$`kOJ;=&&OXKPoVxF3gwSUNyM6o zc^Wzx+uM`a-~680YUy~-?3HQ$2oDEm?**OC)n~1(#^?Kci2vRnSMfcI3@1u~M4D31 zkdMyQox-jNdbivTF5%np4-u02{5ZS}kUuG2cE}mgXemDjr2omJSgs#nz)@>ib%Grq zqj?0LFeaXh|G3glZfBdnYPN%-NH9a)#{wk}&4Z|40ah=B;PAr2#551-H=>i|!Qamz zHJIh0>s{H}o)q0b%S^X#w=ajtZ(l-jXy+(`Q$FQkc621SAKr;IA$qLDrQ+AnJJcE; z5)AqPu&c|vfo+6_Qeq1g81cXD%6`hF2dGwvjRaHE86j3HVj?u6@zacf}*?R(V8hEgE_I4N2 z^hoA-Dthk{KPvJdFfusYtEje+HGhF|t~K{FG2BD*?f zS#{1sP9S$B7lZuJFXDBjS_%vV`OF|hL1__WK3to8{TZpwcq)l*S+Z|rtg-$j6-u5w zm{sj{S33)vgn=6*ZThLPn8wIcY-n-y{f)oY5$jqG)o%_xG_nviD);@3Ey-)I8`KsUj_c+xfVoUiyZx;2cQbp0_vWPfYi}&M^f^shkd{ejH zW(`HB`w>ze25c=CGmjFR_}}a!wHd~K*+$u;4>=zKt(cMtq_V-|w}ZtbLWYfVW@W4Z zZFjA@B+IQYk3nM{la+B05VagH@VO|auww7oXFN*lR!Kyz--i7Q%HvY)s)d(X;}d4~ z-Kl^9ND-vIVTEjfPNaLidaoXzY&P4==UEJ3-7cU!_-$i3Ivi31Of@ULL!8!yfl%ar z=yM4em%ZC}$&L;~hoR3W_YK1aQmoQ~l@pf1O&?YBLByV#^Nyk&XHtYIi8`^HI(jx_`u5=a?ukEx-U*DYp;RaW#wsf3vwD@P9q7cOc`)S2dp7!Yt*X5gNS(r1(#vA->6Nva4&cr z_fG?CnW9wMPr#3<0d9XZBe-vfW%hNm+0gN@UaW2yN>U8$#!tNHK*t$t1v9nNjk!X^ zt5uRb(imy{*(daf6MIP`m%qieYzW1_x4i6#FO(wJqL6v(HbU8Z;k|T+oViEyM~9Ng z=%?!+rQykO^OIU@kx@&*fs=tMc9jyV@3gmO7v;YhY+Z>r89kps3hc;cT^bP0bRVVJ$@!?e*P-|+vZ0}lGlMQ%_Kt?k}T~F7_(=ykb@U$7&zK~zwu8i z4?R~vM;XVrGQtX8_qcvfGeV&Lv+YZV5ZNm+WThHkd>J*ga;_=4vzM0GiT zh9m@Ia80XAjIl7;15E|1_EFE6-oF9f0$O)-6iYyK$AUvwv^I|?KUNI3SU0P4ux)E! zR2WVp+3%BQYFkArbu{Xi+B6ZsS&XnU;mFGC_OcVQUn6pFA9IKqmr6@*$UU`WT-Uuz zn@LYZfX5j(s>s=m0lId^M@Jj8wVk~DWRk&|K(=3zaMVuKq#`&xGu(afiV(F|7VmhM zn`8elcK2QSipjKN>XhJ;G;XDreHc_wQ4v~QrrR>#Z#coP-&%G#c0OvX*Lgab??D5t z%M6j3jsvGdpJ|L0mvfJgLT)(HfR@O<+O6>MX6O5DT?Fw32UMLMieEs0@E+h%26hB2 z?w5qtS2|c;H0;aJ(aax`CjfHMO67SGdOKS-Y;>hYw5bIqCk^R!)zeJH$Cuh&jw&!u zuaCOSSY`5HFGv+6LX6@JoaBg0J@1+*>wK|Y1HVqK^yK`w@-@w%!qX!h;)kA#tr^V%AJ4bifSX7|IS$$E~;U5&7h(kB-DtimZxQtzwl?V#+l+qPWu z+uc1}iDRJzWnW^rGFa@g@v!6V3a!wrk;jgY?7I$xBNHgO9__ zrEe1&GPvn<6Uw^42ee%nT~2+ckUt0LTOeVh_V_` z1i)U)r;m(F#3rfmP@?>7DZ9pJNg-carliNcoK#xqv}U*OVy16>*mp>CugluNXW7~K z8vkv{mi{)2e{qGN=Q-<*5`}u(1gaN7g5%E;5%~xbo1_`)RhkQ2???bqZv&}o*!7qd z?j#}_=<1d3`NQc3gWy=aD|jj|@Z_Jf@Acgu!|LdQn4HOK1|PKoe08pybc{)Ztknbu zkZo$QAiHYQxvweXmZlX#%SDond%H34uNLGxelLnCp-49A_*n0p+XGp*2Sy4JQcsW+sCDhCwg0kHQ7%m`C8dS9J~HgVvK<)q z6b&`1zbxBK^f#|u)l@poavLt%+V2Omo`1-V~CQIs2eVMp^@B;qP zM>!7+dJW&b!;3GomA#&Gw5f_wQ+4vh6P|wAu9~37<5}{dnn~Uc^(9TM9&~7lGtx!k z`Ky_t)!HW#o-Bv9-RPps=TB(Tv6szk6%FntV_I&qz9uK3%f>sXmlYe%9PJ}YXL{f! zA^y7#`R+RLuFb)tN_9k#hHCW-D~%{?rDfmplat!dX1y@39rB{SN$Cx)*8~zfrJ4iS zi#;0Q?P^0R;qCT2N3KO0d~cvRbH$_1yl-f8=ZfvU92lol!fO84CgwP6&=FFJpiINt z(`iU}uAe5x&z$?2-%4$qd*CL~rfiZ1Yy&jH+1W}Oj_N$C66v1xuE%Sv?x@$H-ckS&PST z|G0%a!u~@sp6zYWq!ry~9ouQ!YC;0?r>f6uu4^$J-w3QBVO5k=9%`r|>1(`UCB$M> zc(gjAUCyEhm~eN{tqRE|@>p0jRXt#ow9AqVoe5S%wMK#T7jNG6p|~z%l#&Lo&c&Bc z7_uLHLM6<4*k`pAnGurZr;~$ro|n%z?5GSb-6iFQFe5eJvr5HzGnG!7`aMY7y&6(* zB&T7FHZZ8_{H@pBp5&18s%#a2rI@EpwUfe(fyv+8R!UEF9&ny-B+2ME))&5js-#y9 z!Q>m*5UN`;0RJ7g{DfDx7JPXQmp&0RvI%_$12o&7c zD3l9xuG|%>F14!rV6EB~IhA?i;+yR%7=?80r@GnUi25JS8W%V+)9Q0l?w!!xw^Q1oZ z+qU@@1`eJPB(GpE3Nd-B&CKv%4AR}ZP0xcAfqDV`Y*;hjz1|=Fv*BP#J+V?EYI3-2@N-s>}8l0O!lp#Lp7G2j}@NY%OWk%j`jc0A?ATTpTscZ)g z*KSP!Bi!P39I@TL;G+63no^+`K1F9SftzUBVI?k>;ox~@6%73rc4=WyxMq~YDlq(B zPwkGdVxQ5u_PwB@WKU74L(zHpA=5E-V9MOiU6vH6BhS6LprmWWmTwo3B|g<_ErLYl z87tHDGK)vX`EQ!r(ka!l8m$;BR;4 z4I53+-=>O&a8{n@V5FgVb-5X!pPwOHN-1gQy*x5;Wq}&J%glpZK*^l z$F|N!htF&{BWU7!f3j3q|T9XK|&N7o^oWYeDpG*yk=Y^SCUoey#V`pJfIc+3A6!aQ(qh8f zApTO$50!2Uo!Dr{FJyW)c-B2b&%DZHB0z0s=&dn#^S%hGP_C?57-aN|^x~=Pe4a#x z%?%SpHIfIW%sVpdm}ZdD@NTxn`_hMP-q&@DD+Rw6W_)<0r9AIm zO1PFAL$cRE5*Nc)cRyR!LaVGfQGT4Phu7MQFYUdxo?iKIXPiy3m6hH|Jnc?AK1Akq zq}cgxtZqd36mL_0;Ki5rpHfOg5(}#=Qp)cqil#rl4g16_%JYs&q8xFZ!d@~drU2^M zJouP|*&9&x{9KCbt9Q5{6`tX<^>NALIT7LegZs!C z`N7z5k#=W`jl?ZHCq4zh@7DX@5?0}l=fr(uZ-EmdRcJ=$ictzP^F~9A8CWa$PH1*_ z+K>U}9o1~iIn~K4<^LO-U~;qi}l*wvCAb)`y*L0t0j5K|M0N- zQTK+l$KWVBH_ngC+6iQT$Iab|2PfrlMO(RB;+%Q8oy;vYbwT>pu5R{dXAlG~2mu*$ z%emV-T7jU#a&R2hHaF%0X}g=dV!S~(kFQhRj2DK7L`WWI+lv!lfVjV#jk)0lJq_SS zQTT4j#jPL?1w(})UXoBKwJIylpoB-yj-nqNO0N(I0^W#4TONh zVQ`QQ=+7ARyD;GNU6AviF$fGHf>U68-~Ni>+TVZd&c Date: Wed, 6 Nov 2024 14:17:11 +0800 Subject: [PATCH 3/6] feat: pdf module --- packages/frontend/component/package.json | 1 - packages/frontend/core/package.json | 2 +- .../src/components/attachment-viewer/utils.ts | 44 +++- .../components/attachment-viewer/viewer.tsx | 246 ++++++++++-------- .../attachment-viewer/worker/utils.ts | 13 - .../attachment-viewer/worker/worker.ts | 223 ---------------- packages/frontend/core/src/modules/index.ts | 2 + .../core/src/modules/pdf/entities/channel.ts | 60 +++++ .../core/src/modules/pdf/entities/pdf.ts | 37 +++ .../core/src/modules/pdf/entities/worker.ts | 113 ++++++++ .../frontend/core/src/modules/pdf/index.ts | 16 ++ .../core/src/modules/pdf/services/pdf.ts | 16 ++ .../worker => modules/pdf/workers}/types.ts | 39 ++- .../core/src/modules/pdf/workers/utils.ts | 39 +++ .../core/src/modules/pdf/workers/worker.ts | 188 +++++++++++++ .../i18n/src/i18n-completenesses.json | 2 +- yarn.lock | 31 ++- 17 files changed, 678 insertions(+), 394 deletions(-) delete mode 100644 packages/frontend/core/src/components/attachment-viewer/worker/utils.ts delete mode 100644 packages/frontend/core/src/components/attachment-viewer/worker/worker.ts create mode 100644 packages/frontend/core/src/modules/pdf/entities/channel.ts create mode 100644 packages/frontend/core/src/modules/pdf/entities/pdf.ts create mode 100644 packages/frontend/core/src/modules/pdf/entities/worker.ts create mode 100644 packages/frontend/core/src/modules/pdf/index.ts create mode 100644 packages/frontend/core/src/modules/pdf/services/pdf.ts rename packages/frontend/core/src/{components/attachment-viewer/worker => modules/pdf/workers}/types.ts (65%) create mode 100644 packages/frontend/core/src/modules/pdf/workers/utils.ts create mode 100644 packages/frontend/core/src/modules/pdf/workers/worker.ts diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index c41267f53a8e2..52c8d712d55ca 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -41,7 +41,6 @@ "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-visually-hidden": "^1.1.0", - "@toeverything/pdf-viewer": "^0.1.0", "@toeverything/theme": "^1.0.17", "@vanilla-extract/dynamic": "^2.1.0", "check-password-strength": "^2.0.10", diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 470087621d68c..97de929bc2f48 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -36,7 +36,7 @@ "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-toolbar": "^1.0.4", "@sentry/react": "^8.0.0", - "@toeverything/pdf-viewer": "^0.1.0", + "@toeverything/pdf-viewer": "^0.1.1", "@toeverything/theme": "^1.0.17", "@vanilla-extract/dynamic": "^2.1.0", "animejs": "^3.2.2", diff --git a/packages/frontend/core/src/components/attachment-viewer/utils.ts b/packages/frontend/core/src/components/attachment-viewer/utils.ts index 0a4675490d2c8..bda4b1000fcad 100644 --- a/packages/frontend/core/src/components/attachment-viewer/utils.ts +++ b/packages/frontend/core/src/components/attachment-viewer/utils.ts @@ -30,7 +30,9 @@ export function renderItem( scroller: HTMLElement | null, className: string, id: number, - imageData: ImageData + width: number, + height: number, + buffer: Uint8ClampedArray ) { if (!scroller) return; @@ -44,8 +46,10 @@ export function renderItem( const ctx = canvas.getContext('2d'); if (!ctx) return; - canvas.width = imageData.width; - canvas.height = imageData.height; + const imageData = new ImageData(buffer, width, height); + + canvas.width = width; + canvas.height = height; canvas.style.width = '100%'; canvas.style.height = '100%'; @@ -62,3 +66,37 @@ export function buildAttachmentProps(model: AttachmentBlockModel) { const size = filesize(model.size); return { model, name, ext, size, isPDF }; } + +export function genSeq(start: number, end: number, total: number) { + start = Math.max(start, 0); + end = Math.min(end, Math.max(total - 1, 0)); + let diff = end - start; + + if (diff < 0) return []; + + if (diff === 0) return [start]; + + if (start === 0) + return Array.from({ length: diff }) + .fill(start) + .map((n, i) => n + i); + + if (end === total - 1) + return Array.from({ length: diff }) + .fill(end) + .map((n, i) => n - i); + + diff = Math.ceil(diff / 2); + const mid = start + diff; + + return Array.from<[number, number]>({ length: diff }) + .fill([mid, mid]) + .map(([s, e], i) => [s - i, e + i]) + .reduce((a, [s, e]) => { + s = Math.max(start, s); + e = Math.min(end, e); + if (!a.includes(s)) a.push(s); + if (!a.includes(e)) a.push(e); + return a; + }, []); +} diff --git a/packages/frontend/core/src/components/attachment-viewer/viewer.tsx b/packages/frontend/core/src/components/attachment-viewer/viewer.tsx index b0bab7fe10b52..3040d04a9e15e 100644 --- a/packages/frontend/core/src/components/attachment-viewer/viewer.tsx +++ b/packages/frontend/core/src/components/attachment-viewer/viewer.tsx @@ -1,6 +1,13 @@ import { IconButton, observeResize, Scrollable } from '@affine/component'; +import { + type PDFChannel, + PDFService, + type PDFWorker, +} from '@affine/core/modules/pdf'; +import { MessageOp, RenderKind } from '@affine/core/modules/pdf/workers/types'; import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc'; +import { LiveData, useLiveData, useService } from '@toeverything/infra'; import clsx from 'clsx'; import { debounce } from 'lodash-es'; import type { ReactElement } from 'react'; @@ -17,9 +24,7 @@ import type { VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso'; import * as styles from './styles.css'; -import { getAttachmentBlob, renderItem } from './utils'; -import type { DocInfo, MessageData, MessageDataType } from './worker/types'; -import { MessageOp, RenderKind, State } from './worker/types'; +import { genSeq, getAttachmentBlob, renderItem } from './utils'; type ItemProps = VirtuosoProps; @@ -100,27 +105,37 @@ interface ViewerProps { export const Viewer = ({ model }: ViewerProps): ReactElement => { const { showBoundary } = useErrorBoundary(); - const [state, setState] = useState(State.Connecting); + const service = useService(PDFService); + const [worker, setWorker] = useState(null); + const docInfo = useLiveData( + useMemo( + () => + worker + ? worker.docInfo$ + : new LiveData({ + total: 0, + width: 1, + height: 1, + }), + [worker] + ) + ); + const [channel, setChannel] = useState(null); + const [cursor, setCursor] = useState(0); const [viewportInfo, setViewportInfo] = useState({ dpi: window.devicePixelRatio, width: 1, height: 1, }); - const [docInfo, setDocInfo] = useState({ - total: 0, - width: 1, - height: 1, - }); - const [cursor, setCursor] = useState(0); const viewerRef = useRef(null); const scrollerRef = useRef(null); const scrollerHandleRef = useRef(null); - const workerRef = useRef(null); const [mainVisibleRange, setMainVisibleRange] = useState({ startIndex: 0, endIndex: 0, }); + const mainCaches = useMemo>(() => new Set(), []); const [collapsed, setCollapsed] = useState(true); const thumbnailsScrollerHandleRef = useRef(null); @@ -129,33 +144,56 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { startIndex: 0, endIndex: 0, }); - - const post = useCallback( - ( - type: T, - data?: MessageDataType[T], - transfers?: Transferable[] - ) => { - const message = { type, [type]: data }; - if (transfers?.length) { - workerRef.current?.postMessage(message, transfers); - return; - } - workerRef.current?.postMessage(message); - }, - [workerRef] - ); + const thumbnailsCaches = useMemo>(() => new Set(), []); const render = useCallback( - (id: number, kind: RenderKind, imageData: ImageData) => { + ( + id: number, + kind: RenderKind, + width: number, + height: number, + buffer: Uint8ClampedArray + ) => { const isPage = kind === RenderKind.Page; const container = isPage ? scrollerRef : thumbnailsScrollerRef; const name = isPage ? 'page' : 'thumbnail'; - renderItem(container.current, `pdf-${name}`, id, imageData); + renderItem(container.current, `pdf-${name}`, id, width, height, buffer); }, [scrollerRef, thumbnailsScrollerRef] ); + const postQueue = useCallback( + (caches: Set, start: number, end: number, kind: RenderKind) => { + if (!channel) return; + + const scale = + viewportInfo.dpi * + (kind === RenderKind.Thumbnail ? THUMBNAIL_WIDTH / docInfo.width : 1); + const seq = new Set(genSeq(start, end, docInfo.total)); + + // fixes doc with only one page + if (seq.size === 1) { + channel.post(MessageOp.Render, { + index: 0, + scale, + kind, + }); + } else { + seq.difference(caches).forEach(index => { + channel.post(MessageOp.Render, { + index, + scale, + kind, + }); + }); + } + + caches.clear(); + seq.forEach(index => caches.add(index)); + }, + [docInfo, viewportInfo, channel] + ); + const onScroll = useCallback(() => { const el = scrollerRef.current; if (!el) return; @@ -209,85 +247,15 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { }, [viewerRef]); useEffect(() => { - post(MessageOp.Render, { - range: mainVisibleRange, - kind: RenderKind.Page, - scale: 1 * viewportInfo.dpi, - }); - }, [viewportInfo, mainVisibleRange, post]); + const { startIndex, endIndex } = mainVisibleRange; + postQueue(mainCaches, startIndex, endIndex, RenderKind.Page); + }, [postQueue, mainVisibleRange, mainCaches]); useEffect(() => { if (collapsed) return; - - post(MessageOp.Render, { - range: thumbnailsVisibleRange, - kind: RenderKind.Thumbnail, - scale: (THUMBNAIL_WIDTH / docInfo.width) * viewportInfo.dpi, - }); - }, [collapsed, docInfo, viewportInfo, thumbnailsVisibleRange, post]); - - useLayoutEffect(() => { - workerRef.current = new Worker( - /* webpackChunkName: "pdf.worker" */ new URL( - './worker/worker.ts', - import.meta.url - ) - ); - - async function process({ data }: MessageEvent) { - const { type } = data; - - switch (type) { - case MessageOp.Init: { - setState(State.Connecting); - break; - } - - case MessageOp.Inited: { - setState(State.Connected); - break; - } - - case MessageOp.Opened: { - const info = data[type]; - setDocInfo(o => ({ ...o, ...info })); - setState(State.Opened); - break; - } - - case MessageOp.Rendered: { - const { index, kind, imageData } = data[type]; - render(index, kind, imageData); - break; - } - } - } - - workerRef.current.addEventListener('message', event => { - process(event).catch(console.error); - }); - - return () => { - workerRef.current?.terminate(); - }; - }, [model, post, render]); - - useEffect(() => { - if (!model.sourceId) return; - if (state !== State.Connected) return; - - getAttachmentBlob(model) - .then(blob => { - if (!blob) return; - return blob.arrayBuffer(); - }) - .then(buffer => { - if (!buffer) return; - setState(State.Opening); - post(MessageOp.Open, buffer, [buffer]); - }) - .catch(showBoundary); - }, [showBoundary, state, post, model, docInfo]); + const { startIndex, endIndex } = thumbnailsVisibleRange; + postQueue(thumbnailsCaches, startIndex, endIndex, RenderKind.Thumbnail); + }, [postQueue, thumbnailsVisibleRange, thumbnailsCaches, collapsed]); const pageContent = useCallback( (index: number) => { @@ -348,10 +316,7 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { const size = Math.min(5, docInfo.total); const itemHeight = docInfo.height + 20; const height = Math.ceil(size * itemHeight); - return { - top: height, - bottom: height, - }; + return { top: height, bottom: height }; }, [docInfo]); const mainStyle = useMemo(() => { @@ -361,11 +326,64 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { vh - 60 - 24 - 24 - 2 - 8, t * THUMBNAIL_WIDTH * (h / w) + (t - 1) * 12 ); - return { - height: `${height}px`, - }; + return { height: `${height}px` }; }, [docInfo, viewportInfo]); + useEffect(() => { + scrollerHandleRef.current?.scrollToIndex({ + index: 0, + align: 'start', + }); + thumbnailsScrollerHandleRef.current?.scrollToIndex({ + index: 0, + align: 'start', + }); + setCursor(0); + mainCaches.clear(); + thumbnailsCaches.clear(); + setMainVisibleRange({ startIndex: 0, endIndex: 0 }); + setThumbnailsVisibleRange({ startIndex: 0, endIndex: 0 }); + }, [channel, mainCaches, thumbnailsCaches]); + + useLayoutEffect(() => { + const { worker, release } = service.get(model.id); + + const disposables = worker.on({ + ready: () => { + if (worker.docInfo$.value.total) { + return; + } + + getAttachmentBlob(model) + .then(blob => { + if (!blob) return; + return blob.arrayBuffer(); + }) + .then(buffer => { + if (!buffer) return; + worker.open(buffer); + }) + .catch(showBoundary); + }, + }); + + const channel = worker.channel(); + channel + .on(({ index, kind, height, width, buffer }) => + render(index, kind, width, height, buffer) + ) + .start(); + + setChannel(channel); + setWorker(worker); + + return () => { + channel.dispose(); + disposables[Symbol.dispose](); + release(); + }; + }, [showBoundary, render, service, model]); + return (
{ ref={viewerRef} > + key={model.id} onScroll={onScroll} ref={scrollerHandleRef} scrollerRef={scroller => { - if (scrollerRef.current) return; scrollerRef.current = scroller as HTMLElement; }} className={styles.virtuoso} @@ -395,10 +413,10 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => {
+ key={model.id} style={mainStyle} ref={thumbnailsScrollerHandleRef} scrollerRef={scroller => { - if (thumbnailsScrollerRef.current) return; thumbnailsScrollerRef.current = scroller as HTMLElement; }} rangeChanged={updateThumbnailsVisibleRange} diff --git a/packages/frontend/core/src/components/attachment-viewer/worker/utils.ts b/packages/frontend/core/src/components/attachment-viewer/worker/utils.ts deleted file mode 100644 index e2f107df19fee..0000000000000 --- a/packages/frontend/core/src/components/attachment-viewer/worker/utils.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function resizeImageBitmap( - imageData: ImageData, - options: { - resizeWidth: number; - resizeHeight: number; - } -) { - return createImageBitmap(imageData, 0, 0, imageData.width, imageData.height, { - colorSpaceConversion: 'none', - resizeQuality: 'pixelated', - ...options, - }); -} diff --git a/packages/frontend/core/src/components/attachment-viewer/worker/worker.ts b/packages/frontend/core/src/components/attachment-viewer/worker/worker.ts deleted file mode 100644 index e0d2e73003c7d..0000000000000 --- a/packages/frontend/core/src/components/attachment-viewer/worker/worker.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import type { Document } from '@toeverything/pdf-viewer'; -import { - createPDFium, - PageRenderingflags, - Runtime, - Viewer, -} from '@toeverything/pdf-viewer'; - -import type { MessageData, MessageDataType } from './types'; -import { MessageOp, RenderKind } from './types'; - -const logger = new DebugLogger('affine:worker:pdf'); - -let inited = false; -let viewer: Viewer | null = null; -let doc: Document | undefined = undefined; - -// Caches images with the range. -const cached = new Map(); -const docInfo = { total: 0, width: 1, height: 1 }; -const flags = PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT; -const ranges = { - [`${RenderKind.Page}:startIndex`]: 0, - [`${RenderKind.Page}:endIndex`]: 0, - [`${RenderKind.Thumbnail}:startIndex`]: 0, - [`${RenderKind.Thumbnail}:endIndex`]: 0, -}; - -function post(type: T, data?: MessageDataType[T]) { - const message = { type, [type]: data }; - self.postMessage(message); -} - -function renderToImageData(index: number, scale: number) { - if (!viewer || !doc) return; - - const page = doc.page(index); - - if (!page) return; - - const width = Math.ceil(docInfo.width * scale); - const height = Math.ceil(docInfo.height * scale); - - const bitmap = viewer.createBitmap(width, height, 0); - bitmap.fill(0, 0, width, height); - page.render(bitmap, 0, 0, width, height, 0, flags); - - const data = new Uint8ClampedArray(bitmap.toBytes()); - - bitmap.close(); - page.close(); - - return new ImageData(data, width, height); -} - -function createJob(index: number, kind: RenderKind, scale: number) { - return () => runJob(index, kind, scale); -} - -async function runJob(index: number, kind: RenderKind, scale: number) { - const key = `${kind}:${index}`; - - let imageData = cached.size > 0 ? cached.get(key) : undefined; - - if (!imageData) { - try { - imageData = renderToImageData(index, scale); - } catch (err) { - console.error(err); - } - - if (!imageData) return; - - cached.set(key, imageData); - } - - post(MessageOp.Rendered, { index, kind, imageData }); -} - -function clearOut(kind: RenderKind, startIndex: number, endIndex: number) { - const oldStartIndex = ranges[`${kind}:startIndex`]; - const oldEndIndex = ranges[`${kind}:endIndex`]; - let i = 0; - let l = 0; - - if (oldEndIndex < startIndex || oldStartIndex > endIndex) { - i = oldStartIndex; - l = oldEndIndex; - } else { - const oldMid = Math.ceil((oldStartIndex + oldEndIndex) / 2); - const mid = Math.ceil((startIndex + endIndex) / 2); - const diff = Math.abs(mid - oldMid); - - if (mid > oldMid) { - i = oldStartIndex; - l = i + diff; - } else if (mid < oldMid) { - i = endIndex; - l = i + diff; - } - } - - for (; i < l + 1; i++) { - cached.delete(`${kind}:${i}`); - } - - ranges[`${kind}:startIndex`] = startIndex; - ranges[`${kind}:endIndex`] = endIndex; -} - -async function start() { - inited = true; - - logger.debug('pdf worker pending'); - self.postMessage({ type: MessageOp.Init }); - - const pdfium = await createPDFium(); - viewer = new Viewer(new Runtime(pdfium)); - - self.postMessage({ type: MessageOp.Inited }); - logger.debug('pdf worker ready'); -} - -async function process({ data }: MessageEvent) { - if (!inited) { - await start(); - } - - if (!viewer) return; - - const { type } = data; - - switch (type) { - case MessageOp.Open: { - const buffer = data[type]; - if (!buffer) return; - - doc = viewer.open(new Uint8Array(buffer)); - - if (!doc) return; - - const page = doc.page(0); - - if (!page) return; - - Object.assign(docInfo, { - total: doc.pageCount(), - height: Math.ceil(page.height()), - width: Math.ceil(page.width()), - }); - page.close(); - post(MessageOp.Opened, docInfo); - - break; - } - - case MessageOp.Render: { - if (!doc) return; - - const { - kind, - scale, - range: { startIndex, endIndex }, - } = data[type]; - - if (startIndex > endIndex || startIndex < 0) return; - - const { total } = docInfo; - const queue: (() => Promise)[] = []; - - if (startIndex === 0) { - for (let n = startIndex; n <= endIndex; n++) { - const b = createJob(n, kind, scale); - queue.push(b); - } - } else if (endIndex + 1 === total) { - for (let n = endIndex; n >= startIndex; n--) { - const a = createJob(n, kind, scale); - queue.push(a); - } - } else { - const mid = Math.floor((startIndex + endIndex) / 2); - const m = createJob(mid, kind, scale); - queue.push(m); - - let n = 1; - const s = Math.max(endIndex - mid, mid - startIndex); - for (; n <= s; n++) { - const j = Math.min(mid + n, endIndex); - const i = Math.max(mid - (j - mid), 0); - const a = createJob(j, kind, scale); - const b = createJob(i, kind, scale); - const ab = () => Promise.all([a(), b()]); - queue.push(ab); - } - } - - queueMicrotask(() => { - (async () => { - for (const q of queue) { - await q(); - } - })() - .catch(console.error) - .finally(() => { - clearOut(kind, startIndex, endIndex); - }); - }); - - break; - } - } -} - -self.addEventListener('message', (event: MessageEvent) => { - process(event).catch(console.error); -}); - -start().catch(error => { - inited = false; - console.log(error); -}); diff --git a/packages/frontend/core/src/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/channel.ts b/packages/frontend/core/src/modules/pdf/entities/channel.ts new file mode 100644 index 0000000000000..97e9904abcbf8 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/entities/channel.ts @@ -0,0 +1,60 @@ +import { DebugLogger } from '@affine/debug'; + +import type { + MessageData, + MessageDataMap, + MessageDataType, +} from '../workers/types'; +import { MessageOp } from '../workers/types'; + +const logger = new DebugLogger('affine:workspace:pdf:channel'); + +export class PDFChannel { + constructor( + public readonly id: string, + public readonly port: MessagePort + ) {} + + on(callback: (data: MessageDataMap[MessageOp.Rendered]) => void) { + this.port.addEventListener( + 'message', + ({ data }: MessageEvent) => { + const { type } = data; + if (type !== MessageOp.Rendered) return; + callback(data[type]); + } + ); + return this; + } + + start() { + this.port.start(); + logger.debug('opened', this.id); + } + + post( + type: T, + data?: MessageDataType[T], + transfers?: Transferable[] + ) { + const message = { type }; + if (data) { + Object.assign(message, { [type]: data }); + } + if (transfers?.length) { + this.port.postMessage(message, transfers); + return; + } + this.port.postMessage(message); + } + + dispose() { + this.post(MessageOp.ChannelClose, this.id); + this.port.close(); + logger.debug('closed', this.id); + } + + [Symbol.dispose]() { + this.dispose(); + } +} 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..259ff420c7346 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/entities/pdf.ts @@ -0,0 +1,37 @@ +import type { WorkspaceService } from '@toeverything/infra'; +import { Entity, ObjectPool } from '@toeverything/infra'; + +import { PDFWorker } from './worker'; + +export class PDFEntity extends Entity { + workers = new ObjectPool({ + onDelete(worker) { + worker.dispose(); + }, + }); + + constructor(private readonly workspaceService: WorkspaceService) { + super(); + } + + get(id: string) { + let result = this.workers.get(id); + if (!result) { + const worker = new PDFWorker(id, this.name); + result = this.workers.put(id, worker); + } + return { worker: result.obj, release: result.release }; + } + + get name() { + return this.workspaceService.workspace.id; + } + + override dispose(): void { + for (const worker of this.workers.objects.values()) { + worker.obj.dispose(); + } + this.workers.clear(); + super.dispose(); + } +} diff --git a/packages/frontend/core/src/modules/pdf/entities/worker.ts b/packages/frontend/core/src/modules/pdf/entities/worker.ts new file mode 100644 index 0000000000000..fe76e87ce1fd7 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/entities/worker.ts @@ -0,0 +1,113 @@ +import { DebugLogger } from '@affine/debug'; +import { LiveData } from '@toeverything/infra'; +import { nanoid } from 'nanoid'; + +import type { MessageData, MessageDataType } from '../workers/types'; +import { MessageOp, State } from '../workers/types'; +import { PDFChannel } from './channel'; + +const logger = new DebugLogger('affine:workspace:pdf:worker'); + +export class PDFWorker { + public readonly worker: Worker; + + public docInfo$ = new LiveData({ total: 0, width: 1, height: 1 }); + + constructor( + public readonly id: string, + public readonly name: string + ) { + const worker = new Worker( + /* webpackChunkName: "pdf.worker" */ new URL( + '../workers/worker.ts', + import.meta.url + ) + ); + + worker.addEventListener('message', (e: MessageEvent) => { + this.process(e).catch(console.error); + }); + + this.worker = worker; + logger.debug('created'); + } + + async process({ data }: MessageEvent) { + const { type } = data; + + // @ts-expect-error allow + if (type === State.Loaded) { + this.worker.dispatchEvent(new CustomEvent('ready')); + return; + } + + if (type === MessageOp.Opened) { + this.docInfo$.value = data[type]; + this.worker.dispatchEvent(new CustomEvent('opened')); + return; + } + + if (type === MessageOp.Rendered) { + this.worker.dispatchEvent( + new CustomEvent('rendered', { + detail: data[type], + }) + ); + } + } + + on(listeners: Record void>) { + const disposables: Disposable[] = []; + + for (const [type, listener] of Object.entries(listeners)) { + this.worker.addEventListener(type, listener); + + disposables.push({ + [Symbol.dispose]: () => { + this.worker.removeEventListener(type, listener); + }, + }); + } + return { + [Symbol.dispose]: () => { + disposables.forEach(disposable => disposable[Symbol.dispose]()); + }, + }; + } + + // Creates a channel. + channel(id = nanoid()) { + const { port1, port2 } = new MessageChannel(); + this.post(MessageOp.ChannelOpen, id, [port2]); + return new PDFChannel(id, port1); + } + + open(buffer: ArrayBuffer) { + this.post(MessageOp.Open, buffer, [buffer]); + } + + post( + type: T, + data?: MessageDataType[T], + transfers?: Transferable[] + ) { + const message = { type }; + if (data) { + Object.assign(message, { [type]: data }); + } + if (transfers?.length) { + this.worker.postMessage(message, transfers); + return; + } + this.worker.postMessage(message); + } + + dispose() { + this.worker.terminate(); + logger.debug('closed'); + } + + [Symbol.dispose]() { + this.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..89c97930bd263 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/index.ts @@ -0,0 +1,16 @@ +import type { Framework } from '@toeverything/infra'; +import { WorkspaceScope, WorkspaceService } from '@toeverything/infra'; + +import { PDFEntity } from './entities/pdf'; +import { PDFService } from './services/pdf'; + +export function configurePDFModule(framework: Framework) { + framework + .scope(WorkspaceScope) + .service(PDFService) + .entity(PDFEntity, [WorkspaceService]); +} + +export { PDFChannel } from './entities/channel'; +export { PDFWorker } from './entities/worker'; +export { PDFService } from './services/pdf'; 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..115403a863627 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/services/pdf.ts @@ -0,0 +1,16 @@ +import { Service } from '@toeverything/infra'; + +import { PDFEntity } from '../entities/pdf'; + +export class PDFService extends Service { + pdf = this.framework.createEntity(PDFEntity); + + get(id: string) { + return this.pdf.get(id); + } + + override dispose(): void { + this.pdf.dispose(); + super.dispose(); + } +} diff --git a/packages/frontend/core/src/components/attachment-viewer/worker/types.ts b/packages/frontend/core/src/modules/pdf/workers/types.ts similarity index 65% rename from packages/frontend/core/src/components/attachment-viewer/worker/types.ts rename to packages/frontend/core/src/modules/pdf/workers/types.ts index 5a259dca391aa..df0134dceadd2 100644 --- a/packages/frontend/core/src/components/attachment-viewer/worker/types.ts +++ b/packages/frontend/core/src/modules/pdf/workers/types.ts @@ -1,8 +1,7 @@ export enum State { - Connecting = 0, - Connected, - Opening, - Opened, + IDLE = 0, + Loading, + Loaded, Failed, } @@ -19,18 +18,13 @@ export type ViewportInfo = { height: number; }; -export enum MessageState { - Poll, - Ready, -} - export enum MessageOp { - Init, - Inited, - Open, + Open = State.Failed + 1, Opened, Render, Rendered, + ChannelOpen, + ChannelClose, } export enum RenderKind { @@ -38,26 +32,27 @@ export enum RenderKind { Thumbnail, } -export type Range = { - startIndex: number; - endIndex: number; -}; - export interface MessageDataMap { - [MessageOp.Init]: undefined; - [MessageOp.Inited]: undefined; + [State.IDLE]: undefined; + [State.Loading]: undefined; + [State.Loaded]: undefined; + [State.Failed]: undefined; [MessageOp.Open]: ArrayBuffer; [MessageOp.Opened]: DocInfo; [MessageOp.Render]: { - range: Range; + index: number; kind: RenderKind; - scale: number; + scale?: number; }; [MessageOp.Rendered]: { index: number; + width: number; + height: number; kind: RenderKind; - imageData: ImageData; + buffer: Uint8ClampedArray; }; + [MessageOp.ChannelOpen]: string; + [MessageOp.ChannelClose]: string; } export type MessageDataType = { diff --git a/packages/frontend/core/src/modules/pdf/workers/utils.ts b/packages/frontend/core/src/modules/pdf/workers/utils.ts new file mode 100644 index 0000000000000..b4569b13d97ce --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/workers/utils.ts @@ -0,0 +1,39 @@ +import type { Document, Viewer } from '@toeverything/pdf-viewer'; + +export function resizeImageBitmap( + imageData: ImageData, + options: { + resizeWidth: number; + resizeHeight: number; + } +) { + return createImageBitmap(imageData, 0, 0, imageData.width, imageData.height, { + colorSpaceConversion: 'none', + resizeQuality: 'pixelated', + ...options, + }); +} + +export function renderToUint8ClampedArray( + viewer: Viewer, + doc: Document, + flags: number, + index: number, + width: number, + height: number +) { + const page = doc.page(index); + + if (!page) return; + + const bitmap = viewer.createBitmap(width, height, 0); + bitmap.fill(0, 0, width, height); + page.render(bitmap, 0, 0, width, height, 0, flags); + + const data = new Uint8ClampedArray(bitmap.toUint8Array()); + + bitmap.close(); + page.close(); + + return data; +} diff --git a/packages/frontend/core/src/modules/pdf/workers/worker.ts b/packages/frontend/core/src/modules/pdf/workers/worker.ts new file mode 100644 index 0000000000000..c3c62fee6964e --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/workers/worker.ts @@ -0,0 +1,188 @@ +import type { Document } from '@toeverything/pdf-viewer'; +import { + createPDFium, + PageRenderingflags, + Runtime, + Viewer, +} from '@toeverything/pdf-viewer'; + +import type { MessageData, MessageDataType } from './types'; +import { MessageOp, State } from './types'; +import { renderToUint8ClampedArray } from './utils'; + +let state = State.IDLE; +let viewer: Viewer | null = null; +let doc: Document | undefined = undefined; +const docInfo = { total: 0, width: 1, height: 1 }; +const flags = PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT; +const channels = new Set(); + +// Waits for wasm to load and initialize. +async function start() { + if (state !== State.IDLE) return; + + waitForReady({ id: 0 }); + + state = State.Loading; + + const pdfium = await createPDFium(); + viewer = new Viewer(new Runtime(pdfium)); + + state = State.Loaded; +} + +function post( + sender: typeof globalThis | MessagePort, + type: T, + data?: MessageDataType[T], + transfers?: Transferable[] +) { + const message = { type }; + if (data) { + Object.assign(message, { [type]: data }); + } + if (transfers?.length) { + if (sender instanceof MessagePort) { + sender.postMessage(message, transfers); + return; + } + sender.postMessage(message, '*', transfers); + return; + } + sender.postMessage(message); +} + +function waitForReady(tick: { id: number }) { + post(self, state); + if (state === State.Loaded || state === State.Failed) { + if (tick.id) { + clearTimeout(tick.id); + tick.id = 0; + } + return; + } + // @ts-expect-error allow + tick.id = setTimeout(waitForReady, 55, tick); +} + +function rendering( + sender: typeof globalThis | MessagePort, + viewer: Viewer, + doc: Document, + data: MessageDataType[MessageOp.Render] +) { + const { index, kind, scale = 1 } = data; + + if (index < 0 || index >= docInfo.total) return; + + const width = Math.ceil(docInfo.width * scale); + const height = Math.ceil(docInfo.height * scale); + const buffer = renderToUint8ClampedArray( + viewer, + doc, + flags, + index, + width, + height + ); + if (!buffer) return; + + post(sender, MessageOp.Rendered, { index, kind, width, height, buffer }, [ + buffer.buffer, + ]); +} + +function process({ data, ports: [port] }: MessageEvent) { + const { type } = data; + + switch (type) { + case MessageOp.Open: { + if (!viewer) return; + + const buffer = data[type]; + if (!buffer) return; + + // release loaded document + if (doc) { + doc.close(); + } + + doc = viewer.open(new Uint8Array(buffer)); + + if (!doc) return; + + const page = doc.page(0); + + if (!page) return; + + Object.assign(docInfo, { + total: doc.pageCount(), + height: Math.ceil(page.height()), + width: Math.ceil(page.width()), + }); + page.close(); + + post(self, MessageOp.Opened, docInfo); + + break; + } + + case MessageOp.Render: { + queueMicrotask(() => { + if (!viewer || !doc) return; + rendering(self, viewer, doc, data[type]); + }); + + break; + } + + // process only images + case MessageOp.ChannelOpen: { + const id = data[type]; + if (id && port) { + port.addEventListener( + 'message', + ({ data }: MessageEvent) => { + const { type } = data; + + if (type === MessageOp.ChannelClose) { + port.close(); + channels.delete(port); + return; + } + + if (type !== MessageOp.Render) return; + + queueMicrotask(() => { + if (!viewer || !doc) return; + rendering(port, viewer, doc, data[type]); + }); + } + ); + port.start(); + } + + break; + } + } +} + +self.addEventListener('message', process); + +start().catch(err => { + if (channels.size > 0) { + for (const channel of channels) { + channel.close(); + } + channels.clear(); + } + if (doc) { + doc.close(); + doc = undefined; + } + if (viewer) { + viewer.close(); + viewer = null; + } + console.error(err); +}); 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/yarn.lock b/yarn.lock index a82df8990f942..ff08cbed163ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -352,7 +352,6 @@ __metadata: "@storybook/react": "npm:^8.2.9" "@storybook/react-vite": "npm:^8.2.9" "@testing-library/react": "npm:^16.0.0" - "@toeverything/pdf-viewer": "npm:^0.1.0" "@toeverything/theme": "npm:^1.0.17" "@types/react": "npm:^18.2.75" "@types/react-dom": "npm:^18.2.24" @@ -431,7 +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.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" @@ -13113,27 +13112,27 @@ __metadata: languageName: unknown linkType: soft -"@toeverything/pdf-viewer-types@npm:0.1.0": - version: 0.1.0 - resolution: "@toeverything/pdf-viewer-types@npm:0.1.0" - checksum: 10/aed8a2e9375e121a663e96147d6a726d4dd7b4917cfa35bac5c422c820125b2bc3e129281cad8c3983d1da12191ba4c6e0e45231123fce5d89091ce18dbc9560 +"@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.0": - version: 0.1.0 - resolution: "@toeverything/pdf-viewer@npm:0.1.0" +"@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.0" - "@toeverything/pdfium": "npm:0.1.0" - checksum: 10/75e1df49ecce97e667ac9ec1a630ba4492f1b712d4380a9c999f05535e305e4fd2a069d004449477520ea308ec5928f02b51268659f75788ae4715a7b6e14da7 + "@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.0": - version: 0.1.0 - resolution: "@toeverything/pdfium@npm:0.1.0" - checksum: 10/5fee0f76608d27d747b9266096cdd153027e8f3e8774571524f86afd88757833a61472400df5dcf2a6ea2e16bcfe9864b090ef7e483c260998ba3fad418a9ba8 +"@toeverything/pdfium@npm:0.1.1": + version: 0.1.1 + resolution: "@toeverything/pdfium@npm:0.1.1" + checksum: 10/3d832eaef3ea4aba142561ed8529e216ed739026e7680e5d4fcd05d56fdd458a06273d6b18f1dbc0af9edcc538fa6fd0246757ebb4957a048a8d69bbf579549a languageName: node linkType: hard From 39d94d4eaa143e6188c32bdf5990e49b03a78c40 Mon Sep 17 00:00:00 2001 From: Fangdun Tsai Date: Mon, 11 Nov 2024 02:49:53 +0800 Subject: [PATCH 4/6] refactor: pdf viewer using op and service --- packages/common/infra/src/op/client.ts | 2 +- packages/common/infra/src/op/message.ts | 2 + .../src/components/attachment-viewer/utils.ts | 17 +- .../components/attachment-viewer/viewer.tsx | 322 ++++++++++-------- .../core/src/modules/pdf/entities/channel.ts | 60 ---- .../core/src/modules/pdf/entities/pdf.ts | 42 +-- .../core/src/modules/pdf/entities/pdfs.ts | 41 +++ .../core/src/modules/pdf/entities/worker.ts | 113 ------ .../frontend/core/src/modules/pdf/index.ts | 16 +- .../core/src/modules/pdf/services/pdf.ts | 16 - .../core/src/modules/pdf/services/pdfs.ts | 20 ++ .../core/src/modules/pdf/workers/client.ts | 130 +++++++ .../core/src/modules/pdf/workers/ops.ts | 24 ++ .../core/src/modules/pdf/workers/types.ts | 60 +--- .../core/src/modules/pdf/workers/utils.ts | 15 + .../core/src/modules/pdf/workers/worker.ts | 265 ++++++-------- 16 files changed, 573 insertions(+), 572 deletions(-) delete mode 100644 packages/frontend/core/src/modules/pdf/entities/channel.ts create mode 100644 packages/frontend/core/src/modules/pdf/entities/pdfs.ts delete mode 100644 packages/frontend/core/src/modules/pdf/entities/worker.ts delete mode 100644 packages/frontend/core/src/modules/pdf/services/pdf.ts create mode 100644 packages/frontend/core/src/modules/pdf/services/pdfs.ts create mode 100644 packages/frontend/core/src/modules/pdf/workers/client.ts create mode 100644 packages/frontend/core/src/modules/pdf/workers/ops.ts diff --git a/packages/common/infra/src/op/client.ts b/packages/common/infra/src/op/client.ts index bdf66b0d0a003..e07f0c73f728e 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; } diff --git a/packages/common/infra/src/op/message.ts b/packages/common/infra/src/op/message.ts index 852cdc6d29b9f..bd06a9e58ed1d 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) { @@ -150,6 +151,7 @@ export abstract class AutoMessageHandler { close() { this.port.close?.(); + this.port.terminate?.(); // For Worker this.port.removeEventListener('message', this.handleMessage); } } diff --git a/packages/frontend/core/src/components/attachment-viewer/utils.ts b/packages/frontend/core/src/components/attachment-viewer/utils.ts index bda4b1000fcad..04e5a7c9479e0 100644 --- a/packages/frontend/core/src/components/attachment-viewer/utils.ts +++ b/packages/frontend/core/src/components/attachment-viewer/utils.ts @@ -1,3 +1,4 @@ +import type { RenderOut } from '@affine/core/modules/pdf/workers/types'; import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; import { filesize } from 'filesize'; @@ -29,19 +30,18 @@ export async function download(model: AttachmentBlockModel) { export function renderItem( scroller: HTMLElement | null, className: string, - id: number, - width: number, - height: number, - buffer: Uint8ClampedArray + data: RenderOut ) { if (!scroller) return; const item = scroller.querySelector( - `[data-index="${id}"] > div.${className}` + `[data-index="${data.index}"] > div.${className}` ); if (!item) return; if (item.firstElementChild) return; + const { width, height, buffer } = data; + const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) return; @@ -67,6 +67,13 @@ export function buildAttachmentProps(model: AttachmentBlockModel) { return { model, name, ext, size, isPDF }; } +/** + * Generates a set of sequences. + * + * 1. when `start` is `0`, returns `[0, .., 5]` + * 2. when `end` is `total - 1`, returns `[total - 1, .., total - 5]` + * 2. when `start > 0` and `end < total - 1`, returns `[18, 17, 19, 16, 20, 15, 21]` + */ export function genSeq(start: number, end: number, total: number) { start = Math.max(start, 0); end = Math.min(end, Math.max(total - 1, 0)); diff --git a/packages/frontend/core/src/components/attachment-viewer/viewer.tsx b/packages/frontend/core/src/components/attachment-viewer/viewer.tsx index 3040d04a9e15e..a8fd26e4060de 100644 --- a/packages/frontend/core/src/components/attachment-viewer/viewer.tsx +++ b/packages/frontend/core/src/components/attachment-viewer/viewer.tsx @@ -1,10 +1,12 @@ import { IconButton, observeResize, Scrollable } from '@affine/component'; +import type { Pdf, PdfSender } from '@affine/core/modules/pdf'; +import { PdfsService } from '@affine/core/modules/pdf'; import { - type PDFChannel, - PDFService, - type PDFWorker, -} from '@affine/core/modules/pdf'; -import { MessageOp, RenderKind } from '@affine/core/modules/pdf/workers/types'; + defaultDocInfo, + RenderKind, + type RenderOut, + State, +} from '@affine/core/modules/pdf/workers/types'; import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc'; import { LiveData, useLiveData, useService } from '@toeverything/infra'; @@ -24,7 +26,7 @@ import type { VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso'; import * as styles from './styles.css'; -import { genSeq, getAttachmentBlob, renderItem } from './utils'; +import { genSeq, renderItem } from './utils'; type ItemProps = VirtuosoProps; @@ -105,23 +107,20 @@ interface ViewerProps { export const Viewer = ({ model }: ViewerProps): ReactElement => { const { showBoundary } = useErrorBoundary(); - const service = useService(PDFService); - const [worker, setWorker] = useState(null); - const docInfo = useLiveData( + const pdfsService = useService(PdfsService); + const [pdf, setPdf] = useState(null); + const [sender, setSender] = useState(null); + const [cursor, setCursor] = useState(0); + const info = useLiveData( useMemo( () => - worker - ? worker.docInfo$ - : new LiveData({ - total: 0, - width: 1, - height: 1, - }), - [worker] + pdf + ? pdf.info$ + : new LiveData({ state: State.IDLE, ...defaultDocInfo() }), + [pdf] ) ); - const [channel, setChannel] = useState(null); - const [cursor, setCursor] = useState(0); + const [viewportInfo, setViewportInfo] = useState({ dpi: window.devicePixelRatio, width: 1, @@ -135,7 +134,17 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { startIndex: 0, endIndex: 0, }); - const mainCaches = useMemo>(() => new Set(), []); + const mainRenderingSeq$ = useMemo( + () => + new LiveData<{ + seq: Set; + diff: Set; + }>({ + seq: new Set(), + diff: new Set(), + }), + [] + ); const [collapsed, setCollapsed] = useState(true); const thumbnailsScrollerHandleRef = useRef(null); @@ -144,61 +153,33 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { startIndex: 0, endIndex: 0, }); - const thumbnailsCaches = useMemo>(() => new Set(), []); + const thumbnailsRenderingSeq$ = useMemo( + () => + new LiveData<{ + seq: Set; + diff: Set; + }>({ + seq: new Set(), + diff: new Set(), + }), + [] + ); const render = useCallback( - ( - id: number, - kind: RenderKind, - width: number, - height: number, - buffer: Uint8ClampedArray - ) => { - const isPage = kind === RenderKind.Page; + (data: RenderOut) => { + const isPage = data.kind === RenderKind.Page; const container = isPage ? scrollerRef : thumbnailsScrollerRef; const name = isPage ? 'page' : 'thumbnail'; - renderItem(container.current, `pdf-${name}`, id, width, height, buffer); + renderItem(container.current, `pdf-${name}`, data); }, [scrollerRef, thumbnailsScrollerRef] ); - const postQueue = useCallback( - (caches: Set, start: number, end: number, kind: RenderKind) => { - if (!channel) return; - - const scale = - viewportInfo.dpi * - (kind === RenderKind.Thumbnail ? THUMBNAIL_WIDTH / docInfo.width : 1); - const seq = new Set(genSeq(start, end, docInfo.total)); - - // fixes doc with only one page - if (seq.size === 1) { - channel.post(MessageOp.Render, { - index: 0, - scale, - kind, - }); - } else { - seq.difference(caches).forEach(index => { - channel.post(MessageOp.Render, { - index, - scale, - kind, - }); - }); - } - - caches.clear(); - seq.forEach(index => caches.add(index)); - }, - [docInfo, viewportInfo, channel] - ); - const onScroll = useCallback(() => { const el = scrollerRef.current; if (!el) return; - const { total } = docInfo; + const { total } = info; if (!total) return; const { scrollTop, scrollHeight } = el; @@ -209,7 +190,7 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { const cursor = Math.min(index, total - 1); setCursor(cursor); - }, [scrollerRef, docInfo]); + }, [scrollerRef, info]); const onSelect = useCallback( (index: number) => { @@ -232,30 +213,67 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { [setThumbnailsVisibleRange] ); - useEffect(() => { - const el = viewerRef.current; - if (!el) return; + const updateSeq = useCallback( + ( + range: { startIndex: number; endIndex: number }, + seq$: LiveData<{ + seq: Set; + diff: Set; + }> + ) => { + if (!sender) return; + const { startIndex, endIndex } = range; + const seq = new Set(genSeq(startIndex, endIndex, info.total)); + seq$.next({ + seq, + diff: seq.difference(seq$.value.seq), + }); + }, + [info, sender] + ); - return observeResize(el, entry => { - const rect = entry.contentRect; - setViewportInfo(info => ({ - ...info, - width: rect.width, - height: rect.height, - })); - }); - }, [viewerRef]); + const createRenderingSubscriber = useCallback( + ( + seq$: LiveData<{ + seq: Set; + diff: Set; + }>, + kind: RenderKind + ) => { + if (!sender) return; - useEffect(() => { - const { startIndex, endIndex } = mainVisibleRange; - postQueue(mainCaches, startIndex, endIndex, RenderKind.Page); - }, [postQueue, mainVisibleRange, mainCaches]); + const scale = + (kind === RenderKind.Page ? 1 : THUMBNAIL_WIDTH / info.width) * + viewportInfo.dpi; + + let unsubscribe: () => void; + + const subscriber = seq$.subscribe(({ seq: _, diff }) => { + unsubscribe?.(); + + unsubscribe = sender.subscribe( + 'render', + { seq: Array.from(diff), kind, scale }, + { + next: data => { + if (!data) return; + render(data); + }, + error: err => { + console.error(err); + unsubscribe(); + }, + } + ); + }); - useEffect(() => { - if (collapsed) return; - const { startIndex, endIndex } = thumbnailsVisibleRange; - postQueue(thumbnailsCaches, startIndex, endIndex, RenderKind.Thumbnail); - }, [postQueue, thumbnailsVisibleRange, thumbnailsCaches, collapsed]); + return () => { + unsubscribe?.(); + subscriber.unsubscribe(); + }; + }, + [viewportInfo, info, render, sender] + ); const pageContent = useCallback( (index: number) => { @@ -264,12 +282,12 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { key={index} index={index} className={clsx([styles.viewerPage, 'pdf-page'])} - width={docInfo.width} - height={docInfo.height} + width={info.width} + height={info.height} /> ); }, - [docInfo] + [info] ); const thumbnailContent = useCallback( @@ -284,12 +302,12 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { 'pdf-thumbnail', ])} width={THUMBNAIL_WIDTH} - height={Math.ceil((docInfo.height / docInfo.width) * THUMBNAIL_WIDTH)} + height={Math.ceil((info.height / info.width) * THUMBNAIL_WIDTH)} onSelect={onSelect} /> ); }, - [cursor, docInfo, onSelect] + [cursor, info, onSelect] ); const mainComponents = useMemo(() => { @@ -313,21 +331,68 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { }, []); const increaseViewportBy = useMemo(() => { - const size = Math.min(5, docInfo.total); - const itemHeight = docInfo.height + 20; + const size = Math.min(5, info.total); + const itemHeight = info.height + 20; const height = Math.ceil(size * itemHeight); return { top: height, bottom: height }; - }, [docInfo]); + }, [info]); const mainStyle = useMemo(() => { const { height: vh } = viewportInfo; - const { total: t, height: h, width: w } = docInfo; + const { total: t, height: h, width: w } = info; const height = Math.min( vh - 60 - 24 - 24 - 2 - 8, t * THUMBNAIL_WIDTH * (h / w) + (t - 1) * 12 ); return { height: `${height}px` }; - }, [docInfo, viewportInfo]); + }, [info, viewportInfo]); + + useEffect(() => { + const unsubscribe = createRenderingSubscriber( + mainRenderingSeq$, + RenderKind.Page + ); + return () => { + unsubscribe?.(); + }; + }, [scrollerRef, createRenderingSubscriber, mainRenderingSeq$]); + + useEffect(() => { + const unsubscribe = createRenderingSubscriber( + thumbnailsRenderingSeq$, + RenderKind.Thumbnail + ); + return () => { + unsubscribe?.(); + }; + }, [ + thumbnailsScrollerHandleRef, + createRenderingSubscriber, + thumbnailsRenderingSeq$, + ]); + + useEffect(() => { + const el = viewerRef.current; + if (!el) return; + + return observeResize(el, entry => { + const rect = entry.contentRect; + setViewportInfo(info => ({ + ...info, + width: rect.width, + height: rect.height, + })); + }); + }, [viewerRef]); + + useEffect(() => { + updateSeq(mainVisibleRange, mainRenderingSeq$); + }, [updateSeq, mainVisibleRange, mainRenderingSeq$]); + + useEffect(() => { + if (collapsed) return; + updateSeq(thumbnailsVisibleRange, thumbnailsRenderingSeq$); + }, [collapsed, updateSeq, thumbnailsVisibleRange, thumbnailsRenderingSeq$]); useEffect(() => { scrollerHandleRef.current?.scrollToIndex({ @@ -339,50 +404,41 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { align: 'start', }); setCursor(0); - mainCaches.clear(); - thumbnailsCaches.clear(); + mainRenderingSeq$.next({ seq: new Set(), diff: new Set() }); + thumbnailsRenderingSeq$.next({ seq: new Set(), diff: new Set() }); setMainVisibleRange({ startIndex: 0, endIndex: 0 }); setThumbnailsVisibleRange({ startIndex: 0, endIndex: 0 }); - }, [channel, mainCaches, thumbnailsCaches]); + }, [sender, mainRenderingSeq$, thumbnailsRenderingSeq$]); useLayoutEffect(() => { - const { worker, release } = service.get(model.id); - - const disposables = worker.on({ - ready: () => { - if (worker.docInfo$.value.total) { - return; - } - - getAttachmentBlob(model) - .then(blob => { - if (!blob) return; - return blob.arrayBuffer(); - }) - .then(buffer => { - if (!buffer) return; - worker.open(buffer); - }) - .catch(showBoundary); - }, - }); + if (!model.sourceId) { + showBoundary('Attachment not found'); + return; + } + + let unsubscribe: () => void; - const channel = worker.channel(); - channel - .on(({ index, kind, height, width, buffer }) => - render(index, kind, width, height, buffer) - ) - .start(); + const { pdf, release } = pdfsService.get(model); - setChannel(channel); - setWorker(worker); + setPdf(pdf); + + const subscriber = pdf.open(model).subscribe({ + error: error => { + console.log(error); + }, + complete: () => { + const { sender, release } = pdf.client.channel(); + setSender(sender); + unsubscribe = release; + }, + }); return () => { - channel.dispose(); - disposables[Symbol.dispose](); + unsubscribe?.(); + subscriber.unsubscribe(); release(); }; - }, [showBoundary, render, service, model]); + }, [showBoundary, pdfsService, model]); return (
{ className={styles.virtuoso} rangeChanged={updateMainVisibleRange} increaseViewportBy={increaseViewportBy} - totalCount={docInfo.total} + totalCount={info.total} itemContent={pageContent} components={mainComponents} /> @@ -421,7 +477,7 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { }} rangeChanged={updateThumbnailsVisibleRange} className={styles.virtuoso} - totalCount={docInfo.total} + totalCount={info.total} itemContent={thumbnailContent} components={thumbnailsComponents} /> @@ -429,9 +485,9 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => {
- {docInfo.total > 0 ? cursor + 1 : 0} + {info.total > 0 ? cursor + 1 : 0} - /{docInfo.total} + /{info.total}
: } diff --git a/packages/frontend/core/src/modules/pdf/entities/channel.ts b/packages/frontend/core/src/modules/pdf/entities/channel.ts deleted file mode 100644 index 97e9904abcbf8..0000000000000 --- a/packages/frontend/core/src/modules/pdf/entities/channel.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { DebugLogger } from '@affine/debug'; - -import type { - MessageData, - MessageDataMap, - MessageDataType, -} from '../workers/types'; -import { MessageOp } from '../workers/types'; - -const logger = new DebugLogger('affine:workspace:pdf:channel'); - -export class PDFChannel { - constructor( - public readonly id: string, - public readonly port: MessagePort - ) {} - - on(callback: (data: MessageDataMap[MessageOp.Rendered]) => void) { - this.port.addEventListener( - 'message', - ({ data }: MessageEvent) => { - const { type } = data; - if (type !== MessageOp.Rendered) return; - callback(data[type]); - } - ); - return this; - } - - start() { - this.port.start(); - logger.debug('opened', this.id); - } - - post( - type: T, - data?: MessageDataType[T], - transfers?: Transferable[] - ) { - const message = { type }; - if (data) { - Object.assign(message, { [type]: data }); - } - if (transfers?.length) { - this.port.postMessage(message, transfers); - return; - } - this.port.postMessage(message); - } - - dispose() { - this.post(MessageOp.ChannelClose, this.id); - this.port.close(); - logger.debug('closed', this.id); - } - - [Symbol.dispose]() { - this.dispose(); - } -} diff --git a/packages/frontend/core/src/modules/pdf/entities/pdf.ts b/packages/frontend/core/src/modules/pdf/entities/pdf.ts index 259ff420c7346..dc994db07159f 100644 --- a/packages/frontend/core/src/modules/pdf/entities/pdf.ts +++ b/packages/frontend/core/src/modules/pdf/entities/pdf.ts @@ -1,37 +1,25 @@ -import type { WorkspaceService } from '@toeverything/infra'; -import { Entity, ObjectPool } from '@toeverything/infra'; +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { Entity, LiveData } from '@toeverything/infra'; -import { PDFWorker } from './worker'; +import { createPdfClient } from '../workers/client'; +import { defaultDocInfo, type DocState, State } from '../workers/types'; -export class PDFEntity extends Entity { - workers = new ObjectPool({ - onDelete(worker) { - worker.dispose(); - }, - }); +export class Pdf extends Entity<{ id: string }> { + public readonly id: string = this.props.id; - constructor(private readonly workspaceService: WorkspaceService) { - super(); - } + public readonly info$ = new LiveData({ + state: State.IDLE, + ...defaultDocInfo(), + }); - get(id: string) { - let result = this.workers.get(id); - if (!result) { - const worker = new PDFWorker(id, this.name); - result = this.workers.put(id, worker); - } - return { worker: result.obj, release: result.release }; - } + public readonly client = createPdfClient(); - get name() { - return this.workspaceService.workspace.id; + open(model: AttachmentBlockModel) { + return this.client.open(model, info => this.info$.next(info)); } - override dispose(): void { - for (const worker of this.workers.objects.values()) { - worker.obj.dispose(); - } - this.workers.clear(); + override dispose() { + this.client.destroy(); super.dispose(); } } diff --git a/packages/frontend/core/src/modules/pdf/entities/pdfs.ts b/packages/frontend/core/src/modules/pdf/entities/pdfs.ts new file mode 100644 index 0000000000000..063602ec33f1b --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/entities/pdfs.ts @@ -0,0 +1,41 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import type { WorkspaceService } from '@toeverything/infra'; +import { Entity, ObjectPool } from '@toeverything/infra'; + +import { Pdf } from './pdf'; + +export class Pdfs extends Entity { + pdfs = new ObjectPool({ + onDelete: pdf => { + pdf.dispose(); + }, + }); + + constructor(private readonly workspaceService: WorkspaceService) { + super(); + } + + get(model: AttachmentBlockModel) { + const { id } = model; + + let result = this.pdfs.get(id); + + if (!result) { + const pdf = this.framework.createEntity(Pdf, { id }); + result = this.pdfs.put(id, pdf); + } + + const { obj: pdf, release } = result; + + return { pdf, release }; + } + + get name() { + return this.workspaceService.workspace.id; + } + + override dispose() { + this.pdfs.clear(); + super.dispose(); + } +} diff --git a/packages/frontend/core/src/modules/pdf/entities/worker.ts b/packages/frontend/core/src/modules/pdf/entities/worker.ts deleted file mode 100644 index fe76e87ce1fd7..0000000000000 --- a/packages/frontend/core/src/modules/pdf/entities/worker.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import { LiveData } from '@toeverything/infra'; -import { nanoid } from 'nanoid'; - -import type { MessageData, MessageDataType } from '../workers/types'; -import { MessageOp, State } from '../workers/types'; -import { PDFChannel } from './channel'; - -const logger = new DebugLogger('affine:workspace:pdf:worker'); - -export class PDFWorker { - public readonly worker: Worker; - - public docInfo$ = new LiveData({ total: 0, width: 1, height: 1 }); - - constructor( - public readonly id: string, - public readonly name: string - ) { - const worker = new Worker( - /* webpackChunkName: "pdf.worker" */ new URL( - '../workers/worker.ts', - import.meta.url - ) - ); - - worker.addEventListener('message', (e: MessageEvent) => { - this.process(e).catch(console.error); - }); - - this.worker = worker; - logger.debug('created'); - } - - async process({ data }: MessageEvent) { - const { type } = data; - - // @ts-expect-error allow - if (type === State.Loaded) { - this.worker.dispatchEvent(new CustomEvent('ready')); - return; - } - - if (type === MessageOp.Opened) { - this.docInfo$.value = data[type]; - this.worker.dispatchEvent(new CustomEvent('opened')); - return; - } - - if (type === MessageOp.Rendered) { - this.worker.dispatchEvent( - new CustomEvent('rendered', { - detail: data[type], - }) - ); - } - } - - on(listeners: Record void>) { - const disposables: Disposable[] = []; - - for (const [type, listener] of Object.entries(listeners)) { - this.worker.addEventListener(type, listener); - - disposables.push({ - [Symbol.dispose]: () => { - this.worker.removeEventListener(type, listener); - }, - }); - } - return { - [Symbol.dispose]: () => { - disposables.forEach(disposable => disposable[Symbol.dispose]()); - }, - }; - } - - // Creates a channel. - channel(id = nanoid()) { - const { port1, port2 } = new MessageChannel(); - this.post(MessageOp.ChannelOpen, id, [port2]); - return new PDFChannel(id, port1); - } - - open(buffer: ArrayBuffer) { - this.post(MessageOp.Open, buffer, [buffer]); - } - - post( - type: T, - data?: MessageDataType[T], - transfers?: Transferable[] - ) { - const message = { type }; - if (data) { - Object.assign(message, { [type]: data }); - } - if (transfers?.length) { - this.worker.postMessage(message, transfers); - return; - } - this.worker.postMessage(message); - } - - dispose() { - this.worker.terminate(); - logger.debug('closed'); - } - - [Symbol.dispose]() { - this.dispose(); - } -} diff --git a/packages/frontend/core/src/modules/pdf/index.ts b/packages/frontend/core/src/modules/pdf/index.ts index 89c97930bd263..d5b1596b11fbe 100644 --- a/packages/frontend/core/src/modules/pdf/index.ts +++ b/packages/frontend/core/src/modules/pdf/index.ts @@ -1,16 +1,18 @@ import type { Framework } from '@toeverything/infra'; import { WorkspaceScope, WorkspaceService } from '@toeverything/infra'; -import { PDFEntity } from './entities/pdf'; -import { PDFService } from './services/pdf'; +import { Pdf } from './entities/pdf'; +import { Pdfs } from './entities/pdfs'; +import { PdfsService } from './services/pdfs'; export function configurePDFModule(framework: Framework) { framework .scope(WorkspaceScope) - .service(PDFService) - .entity(PDFEntity, [WorkspaceService]); + .service(PdfsService) + .entity(Pdfs, [WorkspaceService]) + .entity(Pdf); } -export { PDFChannel } from './entities/channel'; -export { PDFWorker } from './entities/worker'; -export { PDFService } from './services/pdf'; +export { Pdf } from './entities/pdf'; +export { PdfsService } from './services/pdfs'; +export { PdfClient, type PdfSender } from './workers/client'; diff --git a/packages/frontend/core/src/modules/pdf/services/pdf.ts b/packages/frontend/core/src/modules/pdf/services/pdf.ts deleted file mode 100644 index 115403a863627..0000000000000 --- a/packages/frontend/core/src/modules/pdf/services/pdf.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Service } from '@toeverything/infra'; - -import { PDFEntity } from '../entities/pdf'; - -export class PDFService extends Service { - pdf = this.framework.createEntity(PDFEntity); - - get(id: string) { - return this.pdf.get(id); - } - - override dispose(): void { - this.pdf.dispose(); - super.dispose(); - } -} diff --git a/packages/frontend/core/src/modules/pdf/services/pdfs.ts b/packages/frontend/core/src/modules/pdf/services/pdfs.ts new file mode 100644 index 0000000000000..bcc421b4938df --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/services/pdfs.ts @@ -0,0 +1,20 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { Service } from '@toeverything/infra'; + +import { Pdfs } from '../entities/pdfs'; + +// One PDF document one worker. +// Multiple channels correspond to multiple views. + +export class PdfsService extends Service { + pdfs = this.framework.createEntity(Pdfs); + + get(model: AttachmentBlockModel) { + return this.pdfs.get(model); + } + + override dispose() { + this.pdfs.dispose(); + super.dispose(); + } +} diff --git a/packages/frontend/core/src/modules/pdf/workers/client.ts b/packages/frontend/core/src/modules/pdf/workers/client.ts new file mode 100644 index 0000000000000..5e857a7580ea5 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/workers/client.ts @@ -0,0 +1,130 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { fromPromise, ObjectPool } from '@toeverything/infra'; +import { OpClient, transfer } from '@toeverything/infra/op'; +import { nanoid } from 'nanoid'; +import { Observable, type Observer } from 'rxjs'; + +import type { ChannelOps, ClientOps } from './ops'; +import { type DocState, State } from './types'; +import { downloadBlobToBuffer } from './utils'; + +export function createPdfClient() { + const worker = new Worker( + /* webpackChunkName: "pdf.worker" */ new URL('./worker.ts', import.meta.url) + ); + + const client = new PdfClient(worker); + client.listen(); + return client; +} + +export type PdfSender = OpClient; + +export class PdfClient extends OpClient { + channels = new ObjectPool({ + onDelete(client) { + client.destroy(); + }, + }); + + private _ping(id: string, subscriber: Observer) { + return this.subscribe('pingpong', { id }, subscriber); + } + + private _open( + id: string, + buffer: ArrayBuffer, + subscriber: Observer + ) { + return this.subscribe( + 'open', + transfer({ id, buffer }, [buffer]), + subscriber + ); + } + + private _downloadBlobToBuffer( + model: AttachmentBlockModel, + subscriber: Partial> + ) { + return fromPromise(downloadBlobToBuffer(model)).subscribe(subscriber); + } + + // Opens a PDF document. + open(model: AttachmentBlockModel, update?: (info: DocState) => void) { + const { id } = model; + const ob$ = new Observable(subscriber => { + const setInfo = (info: DocState) => { + update?.(info); + subscriber.next(info); + }; + const error = (err?: any) => subscriber.error(err); + const complete = () => subscriber.complete(); + + this._ping(id, { + next: info => { + setInfo(info); + + if (info.state === State.Opened) { + complete(); + return; + } + + if (info.state === State.Opening) { + return; + } + + if (info.state === State.Loaded) { + info.state = State.Opening; + setInfo(info); + + this._downloadBlobToBuffer(model, { + next: buffer => + this._open(id, buffer, { + next: info => setInfo(info), + error, + complete, + }), + error: err => subscriber.error(err), + }); + } + }, + error, + complete, + }); + }); + + return ob$; + } + + // Creates a channel. + channel(id = nanoid()) { + let result = this.channels.get(id); + + if (!result) { + const { port1, port2: port } = new MessageChannel(); + const sender = new OpClient(port1); + + this.call('channel', transfer({ id, port }, [port])).catch(err => { + console.error(err); + }); + + result = this.channels.put(id, sender); + + sender.listen(); + } + + const { obj: sender, release } = result; + + return { sender, release }; + } + + override destroy() { + this.channels.clear(); + super.destroy(); + } + + [Symbol.dispose]() { + this.destroy(); + } +} diff --git a/packages/frontend/core/src/modules/pdf/workers/ops.ts b/packages/frontend/core/src/modules/pdf/workers/ops.ts new file mode 100644 index 0000000000000..5ddd446cc6624 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/workers/ops.ts @@ -0,0 +1,24 @@ +import type { OpSchema } from '@toeverything/infra/op'; + +import type { DocState, RenderKind, RenderOut } from './types'; + +export interface ClientOps extends OpSchema { + // Ping-Pong + pingpong: [{ id: string }, DocState]; + // Opens a PDF document + open: [{ id: string; buffer: ArrayBuffer }, DocState]; + // Creates a channel + channel: [{ id: string; port: MessagePort }, boolean]; +} + +export interface ChannelOps extends OpSchema { + // Renders image data by page index + render: [ + { + seq: number[]; + kind: RenderKind; + scale?: number; + }, + RenderOut | void, + ]; +} diff --git a/packages/frontend/core/src/modules/pdf/workers/types.ts b/packages/frontend/core/src/modules/pdf/workers/types.ts index df0134dceadd2..92392361a91dd 100644 --- a/packages/frontend/core/src/modules/pdf/workers/types.ts +++ b/packages/frontend/core/src/modules/pdf/workers/types.ts @@ -1,8 +1,9 @@ export enum State { IDLE = 0, Loading, - Loaded, - Failed, + Loaded, // WASM has been loaded and initialized. + Opening, + Opened, // A document has been opened. } export type DocInfo = { @@ -11,54 +12,21 @@ export type DocInfo = { height: number; }; -export type ViewportInfo = { - // TODO(@fundon): zoom & scale - dpi: number; - width: number; - height: number; -}; - -export enum MessageOp { - Open = State.Failed + 1, - Opened, - Render, - Rendered, - ChannelOpen, - ChannelClose, -} - export enum RenderKind { Page, Thumbnail, } -export interface MessageDataMap { - [State.IDLE]: undefined; - [State.Loading]: undefined; - [State.Loaded]: undefined; - [State.Failed]: undefined; - [MessageOp.Open]: ArrayBuffer; - [MessageOp.Opened]: DocInfo; - [MessageOp.Render]: { - index: number; - kind: RenderKind; - scale?: number; - }; - [MessageOp.Rendered]: { - index: number; - width: number; - height: number; - kind: RenderKind; - buffer: Uint8ClampedArray; - }; - [MessageOp.ChannelOpen]: string; - [MessageOp.ChannelClose]: string; -} - -export type MessageDataType = { - [P in keyof T]: T[P]; +export type RenderOut = { + index: number; + width: number; + height: number; + kind: RenderKind; + buffer: Uint8ClampedArray; }; -export type MessageData = { - type: T; -} & P; +export type DocState = { state: State } & DocInfo; + +export function defaultDocInfo(total = 1, width = 1, height = 1) { + return { total, width, height }; +} diff --git a/packages/frontend/core/src/modules/pdf/workers/utils.ts b/packages/frontend/core/src/modules/pdf/workers/utils.ts index b4569b13d97ce..40b612bf1e163 100644 --- a/packages/frontend/core/src/modules/pdf/workers/utils.ts +++ b/packages/frontend/core/src/modules/pdf/workers/utils.ts @@ -1,5 +1,20 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; import type { Document, Viewer } from '@toeverything/pdf-viewer'; +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(); +} + export function resizeImageBitmap( imageData: ImageData, options: { diff --git a/packages/frontend/core/src/modules/pdf/workers/worker.ts b/packages/frontend/core/src/modules/pdf/workers/worker.ts index c3c62fee6964e..e7f95656cdfa1 100644 --- a/packages/frontend/core/src/modules/pdf/workers/worker.ts +++ b/packages/frontend/core/src/modules/pdf/workers/worker.ts @@ -1,3 +1,4 @@ +import { OpConsumer, transfer } from '@toeverything/infra/op'; import type { Document } from '@toeverything/pdf-viewer'; import { createPDFium, @@ -5,184 +6,120 @@ import { Runtime, Viewer, } from '@toeverything/pdf-viewer'; +import { BehaviorSubject, filter, from, map, switchMap, take } from 'rxjs'; -import type { MessageData, MessageDataType } from './types'; -import { MessageOp, State } from './types'; +import type { ChannelOps, ClientOps } from './ops'; +import type { DocInfo } from './types'; +import { defaultDocInfo, State } from './types'; import { renderToUint8ClampedArray } from './utils'; -let state = State.IDLE; let viewer: Viewer | null = null; let doc: Document | undefined = undefined; -const docInfo = { total: 0, width: 1, height: 1 }; -const flags = PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT; -const channels = new Set(); +const info: DocInfo = defaultDocInfo(); +const state$ = new BehaviorSubject(State.IDLE); +const FLAGS = PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT; -// Waits for wasm to load and initialize. -async function start() { - if (state !== State.IDLE) return; +// Pipes +const statePipe$ = state$.pipe(map(state => ({ state, ...info }))); - waitForReady({ id: 0 }); +state$.next(State.Loading); - state = State.Loading; +createPDFium() + .then(pdfium => { + viewer = new Viewer(new Runtime(pdfium)); + state$.next(State.Loaded); + }) + .catch(err => { + state$.error(err); + }); - const pdfium = await createPDFium(); - viewer = new Viewer(new Runtime(pdfium)); +// Multiple channels can be processed in a worker. - state = State.Loaded; -} +// @ts-expect-error fixme +const consumer = new OpConsumer(self); -function post( - sender: typeof globalThis | MessagePort, - type: T, - data?: MessageDataType[T], - transfers?: Transferable[] -) { - const message = { type }; - if (data) { - Object.assign(message, { [type]: data }); - } - if (transfers?.length) { - if (sender instanceof MessagePort) { - sender.postMessage(message, transfers); - return; - } - sender.postMessage(message, '*', transfers); - return; - } - sender.postMessage(message); -} - -function waitForReady(tick: { id: number }) { - post(self, state); - if (state === State.Loaded || state === State.Failed) { - if (tick.id) { - clearTimeout(tick.id); - tick.id = 0; - } - return; +consumer.register('pingpong', () => { + return statePipe$; +}); + +consumer.register('open', ({ id: _, buffer }) => { + if (!viewer) { + return statePipe$; } - // @ts-expect-error allow - tick.id = setTimeout(waitForReady, 55, tick); -} - -function rendering( - sender: typeof globalThis | MessagePort, - viewer: Viewer, - doc: Document, - data: MessageDataType[MessageOp.Render] -) { - const { index, kind, scale = 1 } = data; - - if (index < 0 || index >= docInfo.total) return; - - const width = Math.ceil(docInfo.width * scale); - const height = Math.ceil(docInfo.height * scale); - const buffer = renderToUint8ClampedArray( - viewer, - doc, - flags, - index, - width, - height - ); - if (!buffer) return; - - post(sender, MessageOp.Rendered, { index, kind, width, height, buffer }, [ - buffer.buffer, - ]); -} - -function process({ data, ports: [port] }: MessageEvent) { - const { type } = data; - - switch (type) { - case MessageOp.Open: { - if (!viewer) return; - - const buffer = data[type]; - if (!buffer) return; - - // release loaded document - if (doc) { - doc.close(); - } - - doc = viewer.open(new Uint8Array(buffer)); - - if (!doc) return; - - const page = doc.page(0); - - if (!page) return; - - Object.assign(docInfo, { - total: doc.pageCount(), - height: Math.ceil(page.height()), - width: Math.ceil(page.width()), - }); - page.close(); - - post(self, MessageOp.Opened, docInfo); - - break; - } - - case MessageOp.Render: { - queueMicrotask(() => { + + return state$ + .pipe( + take(1), + filter(s => s === State.Loaded) + ) + .pipe( + switchMap(() => { + if (doc) { + doc?.close(); + } + + state$.next(State.Opening); + + doc = viewer?.open(new Uint8Array(buffer)); + + if (!doc) { + Object.assign(info, defaultDocInfo()); + state$.next(State.Loaded); + return statePipe$; + } + + const page = doc.page(0); + if (!page) { + doc.close(); + Object.assign(info, defaultDocInfo()); + state$.next(State.Loaded); + return statePipe$; + } + + const rect = page.size(); + page.close(); + + const total = doc.pageCount(); + + Object.assign(info, { total, ...rect }); + state$.next(State.Opened); + return statePipe$; + }) + ); +}); + +consumer.register('channel', ({ id: _, port }) => { + const receiver = new OpConsumer(port); + + receiver.register('render', ({ seq, kind, scale = 1 }) => { + if (!viewer || !doc) return from([]).pipe(); + + const width = Math.ceil(info.width * scale); + const height = Math.ceil(info.height * scale); + + return from(seq).pipe( + map(index => { if (!viewer || !doc) return; - rendering(self, viewer, doc, data[type]); - }); - - break; - } - - // process only images - case MessageOp.ChannelOpen: { - const id = data[type]; - if (id && port) { - port.addEventListener( - 'message', - ({ data }: MessageEvent) => { - const { type } = data; - - if (type === MessageOp.ChannelClose) { - port.close(); - channels.delete(port); - return; - } - - if (type !== MessageOp.Render) return; - - queueMicrotask(() => { - if (!viewer || !doc) return; - rendering(port, viewer, doc, data[type]); - }); - } - ); - port.start(); - } - break; - } - } -} + const buffer = renderToUint8ClampedArray( + viewer, + doc, + FLAGS, + index, + width, + height + ); + if (!buffer) return; -self.addEventListener('message', process); + return transfer({ index, kind, width, height, buffer }, [ + buffer.buffer, + ]); + }) + ); + }); -start().catch(err => { - if (channels.size > 0) { - for (const channel of channels) { - channel.close(); - } - channels.clear(); - } - if (doc) { - doc.close(); - doc = undefined; - } - if (viewer) { - viewer.close(); - viewer = null; - } - console.error(err); + receiver.listen(); + return true; }); + +consumer.listen(); From bbc6b7ae5370fce6891a6c5a6bdc06eb8036421d Mon Sep 17 00:00:00 2001 From: forehalo Date: Mon, 11 Nov 2024 20:29:31 +0800 Subject: [PATCH 5/6] refactor(core): patch pdf viewer --- packages/common/infra/src/op/README.md | 2 +- .../infra/src/op/__tests__/client.spec.ts | 28 +- packages/common/infra/src/op/client.ts | 31 +- packages/common/infra/src/op/consumer.ts | 13 +- packages/common/infra/src/op/message.ts | 9 + .../components/attachment-viewer/index.tsx | 6 +- .../attachment-viewer/pdf-viewer.tsx | 324 ++++++++++++ .../components/attachment-viewer/viewer.tsx | 500 ------------------ .../core/src/modules/pdf/entities/pdf.ts | 127 ++++- .../core/src/modules/pdf/entities/pdfs.ts | 41 -- .../frontend/core/src/modules/pdf/index.ts | 19 +- .../core/src/modules/pdf/renderer/index.ts | 30 ++ .../core/src/modules/pdf/renderer/ops.ts | 8 + .../core/src/modules/pdf/renderer/types.ts | 16 + .../core/src/modules/pdf/renderer/worker.ts | 140 +++++ .../core/src/modules/pdf/services/pdf.ts | 32 ++ .../core/src/modules/pdf/services/pdfs.ts | 20 - .../core/src/modules/pdf/workers/client.ts | 130 ----- .../core/src/modules/pdf/workers/ops.ts | 24 - .../core/src/modules/pdf/workers/types.ts | 32 -- .../core/src/modules/pdf/workers/utils.ts | 54 -- .../core/src/modules/pdf/workers/worker.ts | 125 ----- 22 files changed, 713 insertions(+), 998 deletions(-) create mode 100644 packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx delete mode 100644 packages/frontend/core/src/components/attachment-viewer/viewer.tsx delete mode 100644 packages/frontend/core/src/modules/pdf/entities/pdfs.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/types.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 delete mode 100644 packages/frontend/core/src/modules/pdf/services/pdfs.ts delete mode 100644 packages/frontend/core/src/modules/pdf/workers/client.ts delete mode 100644 packages/frontend/core/src/modules/pdf/workers/ops.ts delete mode 100644 packages/frontend/core/src/modules/pdf/workers/types.ts delete mode 100644 packages/frontend/core/src/modules/pdf/workers/utils.ts delete mode 100644 packages/frontend/core/src/modules/pdf/workers/worker.ts 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 e07f0c73f728e..c469028f96c69 100644 --- a/packages/common/infra/src/op/client.ts +++ b/packages/common/infra/src/op/client.ts @@ -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 bd06a9e58ed1d..aa58fd3b0cfc6 100644 --- a/packages/common/infra/src/op/message.ts +++ b/packages/common/infra/src/op/message.ts @@ -131,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) {} @@ -145,13 +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/core/src/components/attachment-viewer/index.tsx b/packages/frontend/core/src/components/attachment-viewer/index.tsx index e336e03ede264..e9ccdfe589555 100644 --- a/packages/frontend/core/src/components/attachment-viewer/index.tsx +++ b/packages/frontend/core/src/components/attachment-viewer/index.tsx @@ -2,10 +2,10 @@ 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'; -import { Viewer } from './viewer'; export type AttachmentViewerProps = { model: AttachmentBlockModel; @@ -20,7 +20,7 @@ export const AttachmentViewer = ({ model }: AttachmentViewerProps) => { {props.isPDF ? ( - + ) : ( @@ -41,7 +41,7 @@ export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => { {props.isPDF ? ( - + ) : ( 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..6de3d15a08959 --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx @@ -0,0 +1,324 @@ +import { IconButton, Scrollable } from '@affine/component'; +import { + type PDF, + type PDFRendererState, + PDFService, + PDFStatus, +} from '@affine/core/modules/pdf'; +import type { PDFPage } from '@affine/core/modules/pdf/entities/pdf'; +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 React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; +import { Virtuoso } from 'react-virtuoso'; + +import * as styles from './styles.css'; + +type ItemProps = VirtuosoProps; + +const Page = React.memo( + ({ + width, + height, + className, + }: { + index: number; + width: number; + height: number; + className: string; + }) => { + return ( +
+ ); + } +); + +Page.displayName = 'viewer-page'; + +const THUMBNAIL_WIDTH = 94; + +const Thumbnail = React.memo( + ({ + index, + width, + height, + className, + onSelect, + }: { + index: number; + width: number; + height: number; + className: string; + onSelect: (index: number) => void; + }) => { + return ( +
onSelect(index)} + >
+ ); + } +); + +Thumbnail.displayName = 'viewer-thumbnail'; + +const Scroller = React.forwardRef( + ({ ...props }, ref) => { + return ( + + + + + ); + } +); + +Scroller.displayName = 'viewer-scroller'; + +const Item = React.forwardRef( + ({ ...props }, ref) => { + return
; + } +); + +Item.displayName = 'viewer-item'; + +interface ViewerProps { + model: AttachmentBlockModel; +} + +enum RenderKind { + Page = 'page', + Thumbnail = 'thumbnail', +} + +interface PDFPageProps { + className?: string; + pdf: PDF; + page: number; + width: number; + height: number; +} + +function PDFPageRenderer({ + pdf, + page, + className, + width, + height, +}: PDFPageProps) { + const [pdfPage, setPdfPage] = useState(null); + const canvasRef = useRef(null); + + const img = useLiveData(pdfPage?.bitmap$ ?? null); + + useEffect(() => { + const pdfPage = pdf.page(RenderKind.Page, page); + setPdfPage(pdfPage.page); + + return () => { + pdfPage.release(); + }; + }, [pdf, page, width, height]); + + useEffect(() => { + pdfPage?.render({ width, height, scale: 1 }); + + return pdfPage?.render.unsubscribe; + }, [pdfPage, height, width]); + + useEffect(() => { + if (!canvasRef.current || !img) return; + const ctx = canvasRef.current.getContext('2d'); + if (!ctx) return; + ctx.drawImage(img, 0, 0); + }, [img, height, width]); + + return ( +
+ +
+ ); +} + +interface PDFViewerInnerProps { + pdf: PDF; + state: Extract; +} + +const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => { + const pdfMeta = state.meta; + const [cursor, setCursor] = useState(0); + const [viewportInfo, setViewportInfo] = useState({ + dpi: window.devicePixelRatio, + width: 1, + height: 1, + }); + const scrollerHandleRef = useRef(null); + const [collapsed, setCollapsed] = useState(true); + + const onSelect = useCallback( + (index: number) => { + scrollerHandleRef.current?.scrollToIndex({ + index, + align: 'start', + behavior: 'smooth', + }); + }, + [scrollerHandleRef] + ); + + const pageContent = useCallback( + (index: number) => { + return ( + + ); + }, + [pdf, pdfMeta] + ); + + const thumbnailContent = useCallback( + (index: number) => { + return ( + + ); + }, + [cursor, state, onSelect] + ); + + const mainComponents = useMemo(() => { + return { + Header: () =>
, + Footer: () =>
, + Item: (props: ItemProps) => ( + + ), + Scroller, + }; + }, []); + + const thumbnailsComponents = useMemo(() => { + return { + Item: (props: ItemProps) => ( + + ), + Scroller, + }; + }, []); + + const mainStyle = useMemo(() => { + const { height: vh } = viewportInfo; + const { pageCount: t, height: h, width: w } = state.meta; + const height = Math.min( + vh - 60 - 24 - 24 - 2 - 8, + t * THUMBNAIL_WIDTH * (h / w) + (t - 1) * 12 + ); + return { height: `${height}px` }; + }, [state, viewportInfo]); + + return ( +
+ + key={pdf.id} + ref={scrollerHandleRef} + className={styles.virtuoso} + totalCount={state.meta.pageCount} + itemContent={pageContent} + components={mainComponents} + /> +
+
+ + key={`${pdf.id}-thumbnails`} + style={mainStyle} + className={styles.virtuoso} + totalCount={state.meta.pageCount} + itemContent={thumbnailContent} + components={thumbnailsComponents} + /> +
+
+
+ + {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 null; + } + + 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 null; + } + + return ; +} diff --git a/packages/frontend/core/src/components/attachment-viewer/viewer.tsx b/packages/frontend/core/src/components/attachment-viewer/viewer.tsx deleted file mode 100644 index a8fd26e4060de..0000000000000 --- a/packages/frontend/core/src/components/attachment-viewer/viewer.tsx +++ /dev/null @@ -1,500 +0,0 @@ -import { IconButton, observeResize, Scrollable } from '@affine/component'; -import type { Pdf, PdfSender } from '@affine/core/modules/pdf'; -import { PdfsService } from '@affine/core/modules/pdf'; -import { - defaultDocInfo, - RenderKind, - type RenderOut, - State, -} from '@affine/core/modules/pdf/workers/types'; -import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc'; -import { LiveData, useLiveData, useService } from '@toeverything/infra'; -import clsx from 'clsx'; -import { debounce } from 'lodash-es'; -import type { ReactElement } from 'react'; -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { useErrorBoundary } from 'react-error-boundary'; -import type { VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; -import { Virtuoso } from 'react-virtuoso'; - -import * as styles from './styles.css'; -import { genSeq, renderItem } from './utils'; - -type ItemProps = VirtuosoProps; - -const Page = React.memo( - ({ - width, - height, - className, - }: { - index: number; - width: number; - height: number; - className: string; - }) => { - return ( -
- ); - } -); - -Page.displayName = 'viewer-page'; - -const THUMBNAIL_WIDTH = 94; - -const Thumbnail = React.memo( - ({ - index, - width, - height, - className, - onSelect, - }: { - index: number; - width: number; - height: number; - className: string; - onSelect: (index: number) => void; - }) => { - return ( -
onSelect(index)} - >
- ); - } -); - -Thumbnail.displayName = 'viewer-thumbnail'; - -const Scroller = React.forwardRef( - ({ ...props }, ref) => { - return ( - - - - - ); - } -); - -Scroller.displayName = 'viewer-scroller'; - -const Item = React.forwardRef( - ({ ...props }, ref) => { - return
; - } -); - -Item.displayName = 'viewer-item'; - -interface ViewerProps { - model: AttachmentBlockModel; -} - -export const Viewer = ({ model }: ViewerProps): ReactElement => { - const { showBoundary } = useErrorBoundary(); - const pdfsService = useService(PdfsService); - const [pdf, setPdf] = useState(null); - const [sender, setSender] = useState(null); - const [cursor, setCursor] = useState(0); - const info = useLiveData( - useMemo( - () => - pdf - ? pdf.info$ - : new LiveData({ state: State.IDLE, ...defaultDocInfo() }), - [pdf] - ) - ); - - const [viewportInfo, setViewportInfo] = useState({ - dpi: window.devicePixelRatio, - width: 1, - height: 1, - }); - const viewerRef = useRef(null); - const scrollerRef = useRef(null); - const scrollerHandleRef = useRef(null); - - const [mainVisibleRange, setMainVisibleRange] = useState({ - startIndex: 0, - endIndex: 0, - }); - const mainRenderingSeq$ = useMemo( - () => - new LiveData<{ - seq: Set; - diff: Set; - }>({ - seq: new Set(), - diff: new Set(), - }), - [] - ); - - const [collapsed, setCollapsed] = useState(true); - const thumbnailsScrollerHandleRef = useRef(null); - const thumbnailsScrollerRef = useRef(null); - const [thumbnailsVisibleRange, setThumbnailsVisibleRange] = useState({ - startIndex: 0, - endIndex: 0, - }); - const thumbnailsRenderingSeq$ = useMemo( - () => - new LiveData<{ - seq: Set; - diff: Set; - }>({ - seq: new Set(), - diff: new Set(), - }), - [] - ); - - const render = useCallback( - (data: RenderOut) => { - const isPage = data.kind === RenderKind.Page; - const container = isPage ? scrollerRef : thumbnailsScrollerRef; - const name = isPage ? 'page' : 'thumbnail'; - renderItem(container.current, `pdf-${name}`, data); - }, - [scrollerRef, thumbnailsScrollerRef] - ); - - const onScroll = useCallback(() => { - const el = scrollerRef.current; - if (!el) return; - - const { total } = info; - if (!total) return; - - const { scrollTop, scrollHeight } = el; - const itemHeight = scrollHeight / total; - const n = scrollTop / itemHeight; - const t = n / total; - const index = Math.floor(n + t); - const cursor = Math.min(index, total - 1); - - setCursor(cursor); - }, [scrollerRef, info]); - - const onSelect = useCallback( - (index: number) => { - scrollerHandleRef.current?.scrollToIndex({ - index, - align: 'start', - behavior: 'smooth', - }); - }, - [scrollerHandleRef] - ); - - const updateMainVisibleRange = useMemo( - () => debounce(setMainVisibleRange, 233, { trailing: true }), - [setMainVisibleRange] - ); - - const updateThumbnailsVisibleRange = useMemo( - () => debounce(setThumbnailsVisibleRange, 233, { trailing: true }), - [setThumbnailsVisibleRange] - ); - - const updateSeq = useCallback( - ( - range: { startIndex: number; endIndex: number }, - seq$: LiveData<{ - seq: Set; - diff: Set; - }> - ) => { - if (!sender) return; - const { startIndex, endIndex } = range; - const seq = new Set(genSeq(startIndex, endIndex, info.total)); - seq$.next({ - seq, - diff: seq.difference(seq$.value.seq), - }); - }, - [info, sender] - ); - - const createRenderingSubscriber = useCallback( - ( - seq$: LiveData<{ - seq: Set; - diff: Set; - }>, - kind: RenderKind - ) => { - if (!sender) return; - - const scale = - (kind === RenderKind.Page ? 1 : THUMBNAIL_WIDTH / info.width) * - viewportInfo.dpi; - - let unsubscribe: () => void; - - const subscriber = seq$.subscribe(({ seq: _, diff }) => { - unsubscribe?.(); - - unsubscribe = sender.subscribe( - 'render', - { seq: Array.from(diff), kind, scale }, - { - next: data => { - if (!data) return; - render(data); - }, - error: err => { - console.error(err); - unsubscribe(); - }, - } - ); - }); - - return () => { - unsubscribe?.(); - subscriber.unsubscribe(); - }; - }, - [viewportInfo, info, render, sender] - ); - - const pageContent = useCallback( - (index: number) => { - return ( - - ); - }, - [info] - ); - - const thumbnailContent = useCallback( - (index: number) => { - return ( - - ); - }, - [cursor, info, onSelect] - ); - - const mainComponents = useMemo(() => { - return { - Header: () =>
, - Footer: () =>
, - Item: (props: ItemProps) => ( - - ), - Scroller, - }; - }, []); - - const thumbnailsComponents = useMemo(() => { - return { - Item: (props: ItemProps) => ( - - ), - Scroller, - }; - }, []); - - const increaseViewportBy = useMemo(() => { - const size = Math.min(5, info.total); - const itemHeight = info.height + 20; - const height = Math.ceil(size * itemHeight); - return { top: height, bottom: height }; - }, [info]); - - const mainStyle = useMemo(() => { - const { height: vh } = viewportInfo; - const { total: t, height: h, width: w } = info; - const height = Math.min( - vh - 60 - 24 - 24 - 2 - 8, - t * THUMBNAIL_WIDTH * (h / w) + (t - 1) * 12 - ); - return { height: `${height}px` }; - }, [info, viewportInfo]); - - useEffect(() => { - const unsubscribe = createRenderingSubscriber( - mainRenderingSeq$, - RenderKind.Page - ); - return () => { - unsubscribe?.(); - }; - }, [scrollerRef, createRenderingSubscriber, mainRenderingSeq$]); - - useEffect(() => { - const unsubscribe = createRenderingSubscriber( - thumbnailsRenderingSeq$, - RenderKind.Thumbnail - ); - return () => { - unsubscribe?.(); - }; - }, [ - thumbnailsScrollerHandleRef, - createRenderingSubscriber, - thumbnailsRenderingSeq$, - ]); - - useEffect(() => { - const el = viewerRef.current; - if (!el) return; - - return observeResize(el, entry => { - const rect = entry.contentRect; - setViewportInfo(info => ({ - ...info, - width: rect.width, - height: rect.height, - })); - }); - }, [viewerRef]); - - useEffect(() => { - updateSeq(mainVisibleRange, mainRenderingSeq$); - }, [updateSeq, mainVisibleRange, mainRenderingSeq$]); - - useEffect(() => { - if (collapsed) return; - updateSeq(thumbnailsVisibleRange, thumbnailsRenderingSeq$); - }, [collapsed, updateSeq, thumbnailsVisibleRange, thumbnailsRenderingSeq$]); - - useEffect(() => { - scrollerHandleRef.current?.scrollToIndex({ - index: 0, - align: 'start', - }); - thumbnailsScrollerHandleRef.current?.scrollToIndex({ - index: 0, - align: 'start', - }); - setCursor(0); - mainRenderingSeq$.next({ seq: new Set(), diff: new Set() }); - thumbnailsRenderingSeq$.next({ seq: new Set(), diff: new Set() }); - setMainVisibleRange({ startIndex: 0, endIndex: 0 }); - setThumbnailsVisibleRange({ startIndex: 0, endIndex: 0 }); - }, [sender, mainRenderingSeq$, thumbnailsRenderingSeq$]); - - useLayoutEffect(() => { - if (!model.sourceId) { - showBoundary('Attachment not found'); - return; - } - - let unsubscribe: () => void; - - const { pdf, release } = pdfsService.get(model); - - setPdf(pdf); - - const subscriber = pdf.open(model).subscribe({ - error: error => { - console.log(error); - }, - complete: () => { - const { sender, release } = pdf.client.channel(); - setSender(sender); - unsubscribe = release; - }, - }); - - return () => { - unsubscribe?.(); - subscriber.unsubscribe(); - release(); - }; - }, [showBoundary, pdfsService, model]); - - return ( -
- - key={model.id} - onScroll={onScroll} - ref={scrollerHandleRef} - scrollerRef={scroller => { - scrollerRef.current = scroller as HTMLElement; - }} - className={styles.virtuoso} - rangeChanged={updateMainVisibleRange} - increaseViewportBy={increaseViewportBy} - totalCount={info.total} - itemContent={pageContent} - components={mainComponents} - /> -
-
- - key={model.id} - style={mainStyle} - ref={thumbnailsScrollerHandleRef} - scrollerRef={scroller => { - thumbnailsScrollerRef.current = scroller as HTMLElement; - }} - rangeChanged={updateThumbnailsVisibleRange} - className={styles.virtuoso} - totalCount={info.total} - itemContent={thumbnailContent} - components={thumbnailsComponents} - /> -
-
-
- - {info.total > 0 ? cursor + 1 : 0} - - /{info.total} -
- : } - onClick={() => setCollapsed(!collapsed)} - /> -
-
-
- ); -}; diff --git a/packages/frontend/core/src/modules/pdf/entities/pdf.ts b/packages/frontend/core/src/modules/pdf/entities/pdf.ts index dc994db07159f..3ad8a27433036 100644 --- a/packages/frontend/core/src/modules/pdf/entities/pdf.ts +++ b/packages/frontend/core/src/modules/pdf/entities/pdf.ts @@ -1,25 +1,128 @@ import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import { Entity, LiveData } from '@toeverything/infra'; +import { + effect, + Entity, + LiveData, + mapInto, + ObjectPool, +} from '@toeverything/infra'; +import { catchError, from, map, of, startWith, switchMap } from 'rxjs'; -import { createPdfClient } from '../workers/client'; -import { defaultDocInfo, type DocState, State } from '../workers/types'; +import type { PDFMeta, RenderPageOpts } from '../renderer'; +import { PDFRenderer } from '../renderer'; -export class Pdf extends Entity<{ id: string }> { - public readonly id: string = this.props.id; +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; + }; - public readonly info$ = new LiveData({ - state: State.IDLE, - ...defaultDocInfo(), +function resizeImageBitmap( + imageData: ImageData, + options: { + resizeWidth: number; + resizeHeight: number; + } +) { + return createImageBitmap(imageData, 0, 0, imageData.width, imageData.height, { + colorSpaceConversion: 'none', + resizeQuality: 'pixelated', + ...options, }); +} - public readonly client = createPdfClient(); +async function downloadBlobToBuffer(model: AttachmentBlockModel) { + const sourceId = model.sourceId; + if (!sourceId) { + throw new Error('Attachment not found'); + } - open(model: AttachmentBlockModel) { - return this.client.open(model, info => this.info$.next(info)); + const blob = await model.doc.blobSync.get(sourceId); + if (!blob) { + throw new Error('Attachment not found'); + } + + return await blob.arrayBuffer(); +} + +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(type: string, page: number) { + const key = `${type}:${page}`; + let rc = this.pages.get(key); + + if (!rc) { + rc = this.pages.put( + key, + this.framework.createEntity(PDFPage, { pdf: this, page }) + ); + } + + return { page: rc.obj, release: rc.release }; } override dispose() { - this.client.destroy(); + this.renderer.destroy(); super.dispose(); } } + +export class PDFPage extends Entity<{ pdf: PDF; page: number }> { + readonly page: number = this.props.page; + bitmap$ = new LiveData(null); + + render = effect( + switchMap((opts: Omit) => + this.props.pdf.renderer.ob$('render', { + ...opts, + pageNum: this.props.page, + }) + ), + map(data => data.bitmap), + mapInto(this.bitmap$) + ); + + constructor() { + super(); + this.disposables.push(() => this.render.unsubscribe); + } +} diff --git a/packages/frontend/core/src/modules/pdf/entities/pdfs.ts b/packages/frontend/core/src/modules/pdf/entities/pdfs.ts deleted file mode 100644 index 063602ec33f1b..0000000000000 --- a/packages/frontend/core/src/modules/pdf/entities/pdfs.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import type { WorkspaceService } from '@toeverything/infra'; -import { Entity, ObjectPool } from '@toeverything/infra'; - -import { Pdf } from './pdf'; - -export class Pdfs extends Entity { - pdfs = new ObjectPool({ - onDelete: pdf => { - pdf.dispose(); - }, - }); - - constructor(private readonly workspaceService: WorkspaceService) { - super(); - } - - get(model: AttachmentBlockModel) { - const { id } = model; - - let result = this.pdfs.get(id); - - if (!result) { - const pdf = this.framework.createEntity(Pdf, { id }); - result = this.pdfs.put(id, pdf); - } - - const { obj: pdf, release } = result; - - return { pdf, release }; - } - - get name() { - return this.workspaceService.workspace.id; - } - - override dispose() { - this.pdfs.clear(); - super.dispose(); - } -} diff --git a/packages/frontend/core/src/modules/pdf/index.ts b/packages/frontend/core/src/modules/pdf/index.ts index d5b1596b11fbe..5b69edd691d4e 100644 --- a/packages/frontend/core/src/modules/pdf/index.ts +++ b/packages/frontend/core/src/modules/pdf/index.ts @@ -1,18 +1,17 @@ import type { Framework } from '@toeverything/infra'; -import { WorkspaceScope, WorkspaceService } from '@toeverything/infra'; +import { WorkspaceScope } from '@toeverything/infra'; -import { Pdf } from './entities/pdf'; -import { Pdfs } from './entities/pdfs'; -import { PdfsService } from './services/pdfs'; +import { PDF, PDFPage } from './entities/pdf'; +import { PDFService } from './services/pdf'; export function configurePDFModule(framework: Framework) { framework .scope(WorkspaceScope) - .service(PdfsService) - .entity(Pdfs, [WorkspaceService]) - .entity(Pdf); + .service(PDFService) + .entity(PDF) + .entity(PDFPage); } -export { Pdf } from './entities/pdf'; -export { PdfsService } from './services/pdfs'; -export { PdfClient, type PdfSender } from './workers/client'; +export { PDF, type PDFRendererState, PDFStatus } from './entities/pdf'; +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..f911a08237598 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/renderer/index.ts @@ -0,0 +1,30 @@ +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(); + } +} + +export type { PDFMeta, RenderedPage, RenderPageOpts } from './types'; 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/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/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..7880300007352 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/services/pdf.ts @@ -0,0 +1,32 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { ObjectPool, Service } from '@toeverything/infra'; + +import { PDF } from '../entities/pdf'; + +// One PDF document one worker. +// Multiple channels correspond to multiple views. + +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/services/pdfs.ts b/packages/frontend/core/src/modules/pdf/services/pdfs.ts deleted file mode 100644 index bcc421b4938df..0000000000000 --- a/packages/frontend/core/src/modules/pdf/services/pdfs.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import { Service } from '@toeverything/infra'; - -import { Pdfs } from '../entities/pdfs'; - -// One PDF document one worker. -// Multiple channels correspond to multiple views. - -export class PdfsService extends Service { - pdfs = this.framework.createEntity(Pdfs); - - get(model: AttachmentBlockModel) { - return this.pdfs.get(model); - } - - override dispose() { - this.pdfs.dispose(); - super.dispose(); - } -} diff --git a/packages/frontend/core/src/modules/pdf/workers/client.ts b/packages/frontend/core/src/modules/pdf/workers/client.ts deleted file mode 100644 index 5e857a7580ea5..0000000000000 --- a/packages/frontend/core/src/modules/pdf/workers/client.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import { fromPromise, ObjectPool } from '@toeverything/infra'; -import { OpClient, transfer } from '@toeverything/infra/op'; -import { nanoid } from 'nanoid'; -import { Observable, type Observer } from 'rxjs'; - -import type { ChannelOps, ClientOps } from './ops'; -import { type DocState, State } from './types'; -import { downloadBlobToBuffer } from './utils'; - -export function createPdfClient() { - const worker = new Worker( - /* webpackChunkName: "pdf.worker" */ new URL('./worker.ts', import.meta.url) - ); - - const client = new PdfClient(worker); - client.listen(); - return client; -} - -export type PdfSender = OpClient; - -export class PdfClient extends OpClient { - channels = new ObjectPool({ - onDelete(client) { - client.destroy(); - }, - }); - - private _ping(id: string, subscriber: Observer) { - return this.subscribe('pingpong', { id }, subscriber); - } - - private _open( - id: string, - buffer: ArrayBuffer, - subscriber: Observer - ) { - return this.subscribe( - 'open', - transfer({ id, buffer }, [buffer]), - subscriber - ); - } - - private _downloadBlobToBuffer( - model: AttachmentBlockModel, - subscriber: Partial> - ) { - return fromPromise(downloadBlobToBuffer(model)).subscribe(subscriber); - } - - // Opens a PDF document. - open(model: AttachmentBlockModel, update?: (info: DocState) => void) { - const { id } = model; - const ob$ = new Observable(subscriber => { - const setInfo = (info: DocState) => { - update?.(info); - subscriber.next(info); - }; - const error = (err?: any) => subscriber.error(err); - const complete = () => subscriber.complete(); - - this._ping(id, { - next: info => { - setInfo(info); - - if (info.state === State.Opened) { - complete(); - return; - } - - if (info.state === State.Opening) { - return; - } - - if (info.state === State.Loaded) { - info.state = State.Opening; - setInfo(info); - - this._downloadBlobToBuffer(model, { - next: buffer => - this._open(id, buffer, { - next: info => setInfo(info), - error, - complete, - }), - error: err => subscriber.error(err), - }); - } - }, - error, - complete, - }); - }); - - return ob$; - } - - // Creates a channel. - channel(id = nanoid()) { - let result = this.channels.get(id); - - if (!result) { - const { port1, port2: port } = new MessageChannel(); - const sender = new OpClient(port1); - - this.call('channel', transfer({ id, port }, [port])).catch(err => { - console.error(err); - }); - - result = this.channels.put(id, sender); - - sender.listen(); - } - - const { obj: sender, release } = result; - - return { sender, release }; - } - - override destroy() { - this.channels.clear(); - super.destroy(); - } - - [Symbol.dispose]() { - this.destroy(); - } -} diff --git a/packages/frontend/core/src/modules/pdf/workers/ops.ts b/packages/frontend/core/src/modules/pdf/workers/ops.ts deleted file mode 100644 index 5ddd446cc6624..0000000000000 --- a/packages/frontend/core/src/modules/pdf/workers/ops.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { OpSchema } from '@toeverything/infra/op'; - -import type { DocState, RenderKind, RenderOut } from './types'; - -export interface ClientOps extends OpSchema { - // Ping-Pong - pingpong: [{ id: string }, DocState]; - // Opens a PDF document - open: [{ id: string; buffer: ArrayBuffer }, DocState]; - // Creates a channel - channel: [{ id: string; port: MessagePort }, boolean]; -} - -export interface ChannelOps extends OpSchema { - // Renders image data by page index - render: [ - { - seq: number[]; - kind: RenderKind; - scale?: number; - }, - RenderOut | void, - ]; -} diff --git a/packages/frontend/core/src/modules/pdf/workers/types.ts b/packages/frontend/core/src/modules/pdf/workers/types.ts deleted file mode 100644 index 92392361a91dd..0000000000000 --- a/packages/frontend/core/src/modules/pdf/workers/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -export enum State { - IDLE = 0, - Loading, - Loaded, // WASM has been loaded and initialized. - Opening, - Opened, // A document has been opened. -} - -export type DocInfo = { - total: number; - width: number; - height: number; -}; - -export enum RenderKind { - Page, - Thumbnail, -} - -export type RenderOut = { - index: number; - width: number; - height: number; - kind: RenderKind; - buffer: Uint8ClampedArray; -}; - -export type DocState = { state: State } & DocInfo; - -export function defaultDocInfo(total = 1, width = 1, height = 1) { - return { total, width, height }; -} diff --git a/packages/frontend/core/src/modules/pdf/workers/utils.ts b/packages/frontend/core/src/modules/pdf/workers/utils.ts deleted file mode 100644 index 40b612bf1e163..0000000000000 --- a/packages/frontend/core/src/modules/pdf/workers/utils.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import type { Document, Viewer } from '@toeverything/pdf-viewer'; - -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(); -} - -export function resizeImageBitmap( - imageData: ImageData, - options: { - resizeWidth: number; - resizeHeight: number; - } -) { - return createImageBitmap(imageData, 0, 0, imageData.width, imageData.height, { - colorSpaceConversion: 'none', - resizeQuality: 'pixelated', - ...options, - }); -} - -export function renderToUint8ClampedArray( - viewer: Viewer, - doc: Document, - flags: number, - index: number, - width: number, - height: number -) { - const page = doc.page(index); - - if (!page) return; - - const bitmap = viewer.createBitmap(width, height, 0); - bitmap.fill(0, 0, width, height); - page.render(bitmap, 0, 0, width, height, 0, flags); - - const data = new Uint8ClampedArray(bitmap.toUint8Array()); - - bitmap.close(); - page.close(); - - return data; -} diff --git a/packages/frontend/core/src/modules/pdf/workers/worker.ts b/packages/frontend/core/src/modules/pdf/workers/worker.ts deleted file mode 100644 index e7f95656cdfa1..0000000000000 --- a/packages/frontend/core/src/modules/pdf/workers/worker.ts +++ /dev/null @@ -1,125 +0,0 @@ -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, filter, from, map, switchMap, take } from 'rxjs'; - -import type { ChannelOps, ClientOps } from './ops'; -import type { DocInfo } from './types'; -import { defaultDocInfo, State } from './types'; -import { renderToUint8ClampedArray } from './utils'; - -let viewer: Viewer | null = null; -let doc: Document | undefined = undefined; -const info: DocInfo = defaultDocInfo(); -const state$ = new BehaviorSubject(State.IDLE); -const FLAGS = PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT; - -// Pipes -const statePipe$ = state$.pipe(map(state => ({ state, ...info }))); - -state$.next(State.Loading); - -createPDFium() - .then(pdfium => { - viewer = new Viewer(new Runtime(pdfium)); - state$.next(State.Loaded); - }) - .catch(err => { - state$.error(err); - }); - -// Multiple channels can be processed in a worker. - -// @ts-expect-error fixme -const consumer = new OpConsumer(self); - -consumer.register('pingpong', () => { - return statePipe$; -}); - -consumer.register('open', ({ id: _, buffer }) => { - if (!viewer) { - return statePipe$; - } - - return state$ - .pipe( - take(1), - filter(s => s === State.Loaded) - ) - .pipe( - switchMap(() => { - if (doc) { - doc?.close(); - } - - state$.next(State.Opening); - - doc = viewer?.open(new Uint8Array(buffer)); - - if (!doc) { - Object.assign(info, defaultDocInfo()); - state$.next(State.Loaded); - return statePipe$; - } - - const page = doc.page(0); - if (!page) { - doc.close(); - Object.assign(info, defaultDocInfo()); - state$.next(State.Loaded); - return statePipe$; - } - - const rect = page.size(); - page.close(); - - const total = doc.pageCount(); - - Object.assign(info, { total, ...rect }); - state$.next(State.Opened); - return statePipe$; - }) - ); -}); - -consumer.register('channel', ({ id: _, port }) => { - const receiver = new OpConsumer(port); - - receiver.register('render', ({ seq, kind, scale = 1 }) => { - if (!viewer || !doc) return from([]).pipe(); - - const width = Math.ceil(info.width * scale); - const height = Math.ceil(info.height * scale); - - return from(seq).pipe( - map(index => { - if (!viewer || !doc) return; - - const buffer = renderToUint8ClampedArray( - viewer, - doc, - FLAGS, - index, - width, - height - ); - if (!buffer) return; - - return transfer({ index, kind, width, height, buffer }, [ - buffer.buffer, - ]); - }) - ); - }); - - receiver.listen(); - return true; -}); - -consumer.listen(); From c5bf03d432224f2934484af7122fb5a78fe1ece3 Mon Sep 17 00:00:00 2001 From: Fangdun Tsai Date: Tue, 12 Nov 2024 05:36:28 +0800 Subject: [PATCH 6/6] refactor: pdf viewer components --- packages/frontend/component/package.json | 2 +- .../components/attachment-viewer/error.tsx | 2 +- .../components/attachment-viewer/index.tsx | 4 +- .../attachment-viewer/pdf-viewer.tsx | 334 ++++++------------ .../attachment-viewer/styles.css.ts | 101 +++--- .../components/attachment-viewer/titlebar.tsx | 1 - .../src/components/attachment-viewer/utils.ts | 82 +---- .../core/src/modules/pdf/entities/pdf-page.ts | 39 ++ .../core/src/modules/pdf/entities/pdf.ts | 68 +--- .../frontend/core/src/modules/pdf/index.ts | 4 +- .../core/src/modules/pdf/renderer/index.ts | 31 +- .../core/src/modules/pdf/renderer/renderer.ts | 28 ++ .../core/src/modules/pdf/renderer/utils.ts | 15 + .../core/src/modules/pdf/services/pdf.ts | 1 - .../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 ++++ packages/frontend/i18n/src/resources/en.json | 3 +- .../e2e/attachment-preview.spec.ts | 12 +- yarn.lock | 2 +- 21 files changed, 617 insertions(+), 456 deletions(-) create mode 100644 packages/frontend/core/src/modules/pdf/entities/pdf-page.ts create mode 100644 packages/frontend/core/src/modules/pdf/renderer/renderer.ts create mode 100644 packages/frontend/core/src/modules/pdf/renderer/utils.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 diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index 52c8d712d55ca..d1628ca559e82 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -14,7 +14,7 @@ }, "peerDependencies": { "@blocksuite/affine": "*", - "@blocksuite/icons": "*" + "@blocksuite/icons": "2.1.67" }, "dependencies": { "@affine/cli": "workspace:*", diff --git a/packages/frontend/core/src/components/attachment-viewer/error.tsx b/packages/frontend/core/src/components/attachment-viewer/error.tsx index 92944cdb5cfec..5d56704dd3193 100644 --- a/packages/frontend/core/src/components/attachment-viewer/error.tsx +++ b/packages/frontend/core/src/components/attachment-viewer/error.tsx @@ -74,7 +74,7 @@ export const ErrorBase = ({ buttons = [], }: ErrorBaseProps) => { return ( -
+
{icon}

{title}

{subtitle}

diff --git a/packages/frontend/core/src/components/attachment-viewer/index.tsx b/packages/frontend/core/src/components/attachment-viewer/index.tsx index e9ccdfe589555..9ab69a85583c6 100644 --- a/packages/frontend/core/src/components/attachment-viewer/index.tsx +++ b/packages/frontend/core/src/components/attachment-viewer/index.tsx @@ -18,7 +18,7 @@ export const AttachmentViewer = ({ model }: AttachmentViewerProps) => { return (
- {props.isPDF ? ( + {model.type.endsWith('pdf') ? ( @@ -39,7 +39,7 @@ export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => { - {props.isPDF ? ( + {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 index 6de3d15a08959..a60926c7f6fd9 100644 --- a/packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx +++ b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx @@ -1,289 +1,177 @@ -import { IconButton, Scrollable } from '@affine/component'; +import { IconButton, observeResize } from '@affine/component'; import { type PDF, type PDFRendererState, PDFService, PDFStatus, } from '@affine/core/modules/pdf'; -import type { PDFPage } from '@affine/core/modules/pdf/entities/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 React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import type { VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; -import { Virtuoso } from 'react-virtuoso'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; import * as styles from './styles.css'; - -type ItemProps = VirtuosoProps; - -const Page = React.memo( - ({ - width, - height, - className, - }: { - index: number; - width: number; - height: number; - className: string; - }) => { - return ( -
- ); - } -); - -Page.displayName = 'viewer-page'; +import { calculatePageNum } from './utils'; const THUMBNAIL_WIDTH = 94; -const Thumbnail = React.memo( - ({ - index, - width, - height, - className, - onSelect, - }: { - index: number; - width: number; - height: number; - className: string; - onSelect: (index: number) => void; - }) => { - return ( -
onSelect(index)} - >
- ); - } -); - -Thumbnail.displayName = 'viewer-thumbnail'; - -const Scroller = React.forwardRef( - ({ ...props }, ref) => { - return ( - - - - - ); - } -); - -Scroller.displayName = 'viewer-scroller'; - -const Item = React.forwardRef( - ({ ...props }, ref) => { - return
; - } -); - -Item.displayName = 'viewer-item'; - interface ViewerProps { model: AttachmentBlockModel; } -enum RenderKind { - Page = 'page', - Thumbnail = 'thumbnail', -} - -interface PDFPageProps { - className?: string; - pdf: PDF; - page: number; - width: number; - height: number; -} - -function PDFPageRenderer({ - pdf, - page, - className, - width, - height, -}: PDFPageProps) { - const [pdfPage, setPdfPage] = useState(null); - const canvasRef = useRef(null); - - const img = useLiveData(pdfPage?.bitmap$ ?? null); - - useEffect(() => { - const pdfPage = pdf.page(RenderKind.Page, page); - setPdfPage(pdfPage.page); - - return () => { - pdfPage.release(); - }; - }, [pdf, page, width, height]); - - useEffect(() => { - pdfPage?.render({ width, height, scale: 1 }); - - return pdfPage?.render.unsubscribe; - }, [pdfPage, height, width]); - - useEffect(() => { - if (!canvasRef.current || !img) return; - const ctx = canvasRef.current.getContext('2d'); - if (!ctx) return; - ctx.drawImage(img, 0, 0); - }, [img, height, width]); - - return ( -
- -
- ); -} - interface PDFViewerInnerProps { pdf: PDF; state: Extract; } const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => { - const pdfMeta = state.meta; const [cursor, setCursor] = useState(0); - const [viewportInfo, setViewportInfo] = useState({ - dpi: window.devicePixelRatio, - width: 1, - height: 1, - }); - const scrollerHandleRef = useRef(null); 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 onSelect = useCallback( + const { pageCount } = state.meta; + if (!pageCount) return; + + const cursor = calculatePageNum(el, pageCount); + + setCursor(cursor); + }, [pagesScrollerRef, state]); + + const onPageSelect = useCallback( (index: number) => { - scrollerHandleRef.current?.scrollToIndex({ + const scroller = pagesScrollerHandleRef.current; + if (!scroller) return; + + scroller.scrollToIndex({ index, - align: 'start', + align: 'center', behavior: 'smooth', }); }, - [scrollerHandleRef] + [pagesScrollerHandleRef] ); const pageContent = useCallback( - (index: number) => { + ( + index: number, + _: unknown, + { width, height, onPageSelect, pageClassName }: PDFVirtuosoContext + ) => { return ( - ); - }, - [pdf, pdfMeta] - ); - - const thumbnailContent = useCallback( - (index: number) => { - return ( - ); }, - [cursor, state, onSelect] + [pdf] ); - const mainComponents = useMemo(() => { - return { - Header: () =>
, - Footer: () =>
, - Item: (props: ItemProps) => ( - - ), - Scroller, - }; - }, []); - - const thumbnailsComponents = useMemo(() => { + 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 { - Item: (props: ItemProps) => ( - - ), - Scroller, + context: { + width: pw, + height: ph, + onPageSelect, + pageClassName: styles.pdfThumbnail, + }, + style: { height }, }; - }, []); + }, [state, viewportInfo, onPageSelect]); - const mainStyle = useMemo(() => { - const { height: vh } = viewportInfo; - const { pageCount: t, height: h, width: w } = state.meta; - const height = Math.min( - vh - 60 - 24 - 24 - 2 - 8, - t * THUMBNAIL_WIDTH * (h / w) + (t - 1) * 12 + useEffect(() => { + const viewer = viewerRef.current; + if (!viewer) return; + return observeResize(viewer, ({ contentRect: { width, height } }) => + setViewportInfo({ width, height }) ); - return { height: `${height}px` }; - }, [state, viewportInfo]); + }, []); return (
- + key={pdf.id} - ref={scrollerHandleRef} + ref={pagesScrollerHandleRef} + scrollerRef={scroller => { + pagesScrollerRef.current = scroller as HTMLElement; + }} + onScroll={onScroll} className={styles.virtuoso} totalCount={state.meta.pageCount} itemContent={pageContent} - components={mainComponents} + components={{ + Item, + List, + Scroller, + Header: ListPadding, + Footer: ListPadding, + }} + context={{ + width: state.meta.width, + height: state.meta.height, + pageClassName: styles.pdfPage, + }} /> -
-
- - key={`${pdf.id}-thumbnails`} - style={mainStyle} +
+
+ + key={`${pdf.id}-thumbnail`} + ref={thumbnailsScrollerHandleRef} className={styles.virtuoso} totalCount={state.meta.pageCount} - itemContent={thumbnailContent} - components={thumbnailsComponents} + itemContent={pageContent} + components={{ + Item, + Scroller, + List: ListWithSmallGap, + }} + style={thumbnailsConfig.style} + context={thumbnailsConfig.context} />
-
+
- + {state.meta.pageCount > 0 ? cursor + 1 : 0} - /{state.meta.pageCount} + /{state.meta.pageCount}
: } @@ -299,7 +187,7 @@ function PDFViewerStatus({ pdf }: { pdf: PDF }) { const state = useLiveData(pdf.state$); if (state?.status !== PDFStatus.Opened) { - return null; + return ; } return ; @@ -317,7 +205,7 @@ export function PDFViewer({ model }: ViewerProps) { }, [model, pdfService, setPdf]); if (!pdf) { - return null; + 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 index 0ba25f051447c..65525db4af6db 100644 --- a/packages/frontend/core/src/components/attachment-viewer/styles.css.ts +++ b/packages/frontend/core/src/components/attachment-viewer/styles.css.ts @@ -21,9 +21,12 @@ export const titlebar = style({ borderTopWidth: '0.5px', borderTopStyle: 'solid', borderTopColor: cssVarV2('layer/insideBorder/border'), + textWrap: 'nowrap', + overflow: 'hidden', }); export const titlebarChild = style({ + overflow: 'hidden', selectors: { [`${titlebar} > &`]: { display: 'flex', @@ -40,36 +43,10 @@ export const titlebarChild = style({ export const titlebarName = style({ display: 'flex', -}); - -export const body = style({ - position: 'relative', - zIndex: 0, - display: 'flex', - flex: 1, - 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%', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'pre', + wordWrap: 'break-word', }); export const error = style({ @@ -101,22 +78,46 @@ export const errorBtns = style({ marginTop: '28px', }); -export const mainItemWrapper = style({ +export const viewer = style({ + position: 'relative', + zIndex: 0, display: 'flex', - alignItems: 'center', - justifyContent: 'center', - margin: '20px auto', + flex: 1, + overflow: 'hidden', + resize: 'both', selectors: { - '&:first-of-type': { - marginTop: 0, + '&:before': { + position: 'absolute', + content: '', + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: -1, }, - '&:last-of-type': { - marginBottom: 0, + '&: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 viewerPage = style({ +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', @@ -125,9 +126,10 @@ export const viewerPage = style({ borderColor: cssVarV2('layer/insideBorder/border'), boxShadow: '0px 4px 20px 0px var(--transparent-black-200, rgba(0, 0, 0, 0.10))', + overflow: 'hidden', }); -export const thumbnails = style({ +export const pdfThumbnails = style({ display: 'flex', flexDirection: 'column', position: 'absolute', @@ -148,13 +150,13 @@ export const thumbnails = style({ color: cssVarV2('text/secondary'), }); -export const thumbnailsPages = style({ +export const pdfThumbnailsList = style({ position: 'relative', display: 'flex', flexDirection: 'column', maxHeight: '100%', overflow: 'hidden', - // gap: '12px', + resize: 'both', selectors: { '&.collapsed': { display: 'none', @@ -165,13 +167,9 @@ export const thumbnailsPages = style({ }, }); -export const thumbnailsItemWrapper = style({ - margin: '0px 12px 12px', -}); - -export const thumbnailsPage = style({ +export const pdfThumbnail = style({ display: 'flex', - overflow: 'clip', + overflow: 'hidden', // width: '100%', borderRadius: '4px', borderWidth: '1px', @@ -183,10 +181,3 @@ export const thumbnailsPage = style({ }, }, }); - -export const thumbnailsIndicator = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - padding: '0 12px', -}); diff --git a/packages/frontend/core/src/components/attachment-viewer/titlebar.tsx b/packages/frontend/core/src/components/attachment-viewer/titlebar.tsx index d694062322877..8f826513d8907 100644 --- a/packages/frontend/core/src/components/attachment-viewer/titlebar.tsx +++ b/packages/frontend/core/src/components/attachment-viewer/titlebar.tsx @@ -46,7 +46,6 @@ export interface TitlebarProps { name: string; ext: string; size: string; - isPDF: boolean; zoom?: number; } diff --git a/packages/frontend/core/src/components/attachment-viewer/utils.ts b/packages/frontend/core/src/components/attachment-viewer/utils.ts index 04e5a7c9479e0..fb0e5c8f8383b 100644 --- a/packages/frontend/core/src/components/attachment-viewer/utils.ts +++ b/packages/frontend/core/src/components/attachment-viewer/utils.ts @@ -1,4 +1,3 @@ -import type { RenderOut } from '@affine/core/modules/pdf/workers/types'; import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; import { filesize } from 'filesize'; @@ -27,83 +26,20 @@ export async function download(model: AttachmentBlockModel) { await downloadBlob(blob, model.name); } -export function renderItem( - scroller: HTMLElement | null, - className: string, - data: RenderOut -) { - if (!scroller) return; - - const item = scroller.querySelector( - `[data-index="${data.index}"] > div.${className}` - ); - if (!item) return; - if (item.firstElementChild) return; - - const { width, height, buffer } = data; - - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const imageData = new ImageData(buffer, width, height); - - canvas.width = width; - canvas.height = height; - canvas.style.width = '100%'; - canvas.style.height = '100%'; - - ctx.putImageData(imageData, 0, 0); - - item.append(canvas); -} - export function buildAttachmentProps(model: AttachmentBlockModel) { - const isPDF = model.type.endsWith('pdf'); const pieces = model.name.split('.'); const ext = pieces.pop() || ''; const name = pieces.join('.'); const size = filesize(model.size); - return { model, name, ext, size, isPDF }; + return { model, name, ext, size }; } -/** - * Generates a set of sequences. - * - * 1. when `start` is `0`, returns `[0, .., 5]` - * 2. when `end` is `total - 1`, returns `[total - 1, .., total - 5]` - * 2. when `start > 0` and `end < total - 1`, returns `[18, 17, 19, 16, 20, 15, 21]` - */ -export function genSeq(start: number, end: number, total: number) { - start = Math.max(start, 0); - end = Math.min(end, Math.max(total - 1, 0)); - let diff = end - start; - - if (diff < 0) return []; - - if (diff === 0) return [start]; - - if (start === 0) - return Array.from({ length: diff }) - .fill(start) - .map((n, i) => n + i); - - if (end === total - 1) - return Array.from({ length: diff }) - .fill(end) - .map((n, i) => n - i); - - diff = Math.ceil(diff / 2); - const mid = start + diff; - - return Array.from<[number, number]>({ length: diff }) - .fill([mid, mid]) - .map(([s, e], i) => [s - i, e + i]) - .reduce((a, [s, e]) => { - s = Math.max(start, s); - e = Math.min(end, e); - if (!a.includes(s)) a.push(s); - if (!a.includes(e)) a.push(e); - return a; - }, []); +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/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 index 3ad8a27433036..bcf12d33b77b3 100644 --- a/packages/frontend/core/src/modules/pdf/entities/pdf.ts +++ b/packages/frontend/core/src/modules/pdf/entities/pdf.ts @@ -1,15 +1,10 @@ import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import { - effect, - Entity, - LiveData, - mapInto, - ObjectPool, -} from '@toeverything/infra'; +import { Entity, LiveData, ObjectPool } from '@toeverything/infra'; import { catchError, from, map, of, startWith, switchMap } from 'rxjs'; -import type { PDFMeta, RenderPageOpts } from '../renderer'; -import { PDFRenderer } from '../renderer'; +import type { PDFMeta } from '../renderer'; +import { downloadBlobToBuffer, PDFRenderer } from '../renderer'; +import { PDFPage } from './pdf-page'; export enum PDFStatus { IDLE = 0, @@ -31,34 +26,6 @@ export type PDFRendererState = error: Error; }; -function resizeImageBitmap( - imageData: ImageData, - options: { - resizeWidth: number; - resizeHeight: number; - } -) { - return createImageBitmap(imageData, 0, 0, imageData.width, imageData.height, { - colorSpaceConversion: 'none', - resizeQuality: 'pixelated', - ...options, - }); -} - -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(); -} - export class PDF extends Entity { public readonly id: string = this.props.id; readonly renderer = new PDFRenderer(); @@ -86,14 +53,14 @@ export class PDF extends Entity { this.disposables.push(() => this.pages.clear()); } - page(type: string, page: number) { - const key = `${type}:${page}`; + 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, page }) + this.framework.createEntity(PDFPage, { pdf: this, pageNum }) ); } @@ -105,24 +72,3 @@ export class PDF extends Entity { super.dispose(); } } - -export class PDFPage extends Entity<{ pdf: PDF; page: number }> { - readonly page: number = this.props.page; - bitmap$ = new LiveData(null); - - render = effect( - switchMap((opts: Omit) => - this.props.pdf.renderer.ob$('render', { - ...opts, - pageNum: this.props.page, - }) - ), - map(data => data.bitmap), - mapInto(this.bitmap$) - ); - - constructor() { - super(); - this.disposables.push(() => this.render.unsubscribe); - } -} diff --git a/packages/frontend/core/src/modules/pdf/index.ts b/packages/frontend/core/src/modules/pdf/index.ts index 5b69edd691d4e..998173d9ce585 100644 --- a/packages/frontend/core/src/modules/pdf/index.ts +++ b/packages/frontend/core/src/modules/pdf/index.ts @@ -1,7 +1,8 @@ import type { Framework } from '@toeverything/infra'; import { WorkspaceScope } from '@toeverything/infra'; -import { PDF, PDFPage } from './entities/pdf'; +import { PDF } from './entities/pdf'; +import { PDFPage } from './entities/pdf-page'; import { PDFService } from './services/pdf'; export function configurePDFModule(framework: Framework) { @@ -13,5 +14,6 @@ export function configurePDFModule(framework: Framework) { } 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 index f911a08237598..d3e9a83e744aa 100644 --- a/packages/frontend/core/src/modules/pdf/renderer/index.ts +++ b/packages/frontend/core/src/modules/pdf/renderer/index.ts @@ -1,30 +1,3 @@ -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(); - } -} - +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/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/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/services/pdf.ts b/packages/frontend/core/src/modules/pdf/services/pdf.ts index 7880300007352..141b1731a96d0 100644 --- a/packages/frontend/core/src/modules/pdf/services/pdf.ts +++ b/packages/frontend/core/src/modules/pdf/services/pdf.ts @@ -4,7 +4,6 @@ import { ObjectPool, Service } from '@toeverything/infra'; import { PDF } from '../entities/pdf'; // One PDF document one worker. -// Multiple channels correspond to multiple views. export class PDFService extends Service { PDFs = new ObjectPool({ 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/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 3fa3a54596b29..33392f7ebd1e4 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1463,5 +1463,6 @@ "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.attachment.preview.error.title": "Unable to preview this file", - "com.affine.attachment.preview.error.subtitle": "file type not supported." + "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 index 092d393912de0..8b3ddf2528e07 100644 --- a/tests/affine-local/e2e/attachment-preview.spec.ts +++ b/tests/affine-local/e2e/attachment-preview.spec.ts @@ -48,14 +48,14 @@ test('attachment preview should be shown', async ({ page }) => { await page.locator('affine-attachment').first().dblclick(); - const attachmentViewer = page.getByTestId('attachment-viewer'); + const attachmentViewer = page.getByTestId('pdf-viewer'); await expect(attachmentViewer).toBeVisible(); await page.waitForTimeout(500); - const pageCount = attachmentViewer.locator('.page-count'); + const pageCount = attachmentViewer.locator('.page-cursor'); expect(await pageCount.textContent()).toBe('1'); - const pageTotal = attachmentViewer.locator('.page-total'); + const pageTotal = attachmentViewer.locator('.page-count'); expect(await pageTotal.textContent()).toBe('3'); const thumbnails = attachmentViewer.locator('.thumbnails'); @@ -89,7 +89,7 @@ test('attachment preview can be expanded', async ({ page }) => { await page.locator('affine-attachment').first().dblclick(); - const attachmentViewer = page.getByTestId('attachment-viewer'); + const attachmentViewer = page.getByTestId('pdf-viewer'); await page.waitForTimeout(500); @@ -99,9 +99,9 @@ test('attachment preview can be expanded', async ({ page }) => { await page.waitForTimeout(500); - const pageCount = attachmentViewer.locator('.page-count'); + const pageCount = attachmentViewer.locator('.page-cursor'); expect(await pageCount.textContent()).toBe('1'); - const pageTotal = attachmentViewer.locator('.page-total'); + const pageTotal = attachmentViewer.locator('.page-count'); expect(await pageTotal.textContent()).toBe('3'); const thumbnails = attachmentViewer.locator('.thumbnails'); diff --git a/yarn.lock b/yarn.lock index ff08cbed163ea..03d4aa73c12ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -382,7 +382,7 @@ __metadata: zod: "npm:^3.22.4" peerDependencies: "@blocksuite/affine": "*" - "@blocksuite/icons": "*" + "@blocksuite/icons": 2.1.67 languageName: unknown linkType: soft