From ec799f18b5e31a1bad4a5dd4fe2c61fc1607736c Mon Sep 17 00:00:00 2001 From: Fangdun Tsai Date: Mon, 28 Oct 2024 14:21:29 +0800 Subject: [PATCH] chore: improve pdf preview --- .../attachment-viewer/styles.css.ts | 21 +- .../src/components/attachment-viewer/utils.ts | 28 ++ .../components/attachment-viewer/viewer.tsx | 266 +++++++++--------- .../attachment-viewer/worker/types.ts | 45 ++- .../attachment-viewer/worker/utils.ts | 20 ++ .../attachment-viewer/worker/worker.ts | 130 +++++---- 6 files changed, 298 insertions(+), 212 deletions(-) create mode 100644 packages/frontend/component/src/components/attachment-viewer/worker/utils.ts diff --git a/packages/frontend/component/src/components/attachment-viewer/styles.css.ts b/packages/frontend/component/src/components/attachment-viewer/styles.css.ts index 193fa68e99391..8407b9367d6c6 100644 --- a/packages/frontend/component/src/components/attachment-viewer/styles.css.ts +++ b/packages/frontend/component/src/components/attachment-viewer/styles.css.ts @@ -103,8 +103,22 @@ export const errorBtns = style({ marginTop: '28px', }); -export const viewerPage = style({ +export const mainItemWrapper = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', margin: '20px auto', + selectors: { + '&:first-of-type': { + marginTop: 0, + }, + '&:last-of-type': { + marginBottom: 0, + }, + }, +}); + +export const viewerPage = style({ maxWidth: 'calc(100% - 40px)', background: cssVarV2('layer/white'), boxSizing: 'border-box', @@ -148,8 +162,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/utils.ts b/packages/frontend/component/src/components/attachment-viewer/utils.ts index 2238b3eb09857..e312d9f831bb2 100644 --- a/packages/frontend/component/src/components/attachment-viewer/utils.ts +++ b/packages/frontend/component/src/components/attachment-viewer/utils.ts @@ -38,3 +38,31 @@ export async function attachmentUrlToBlob( } return; } + +export function renderItem( + scroller: HTMLElement | null, + id: number, + imageBitmap: ImageBitmap +) { + if (!scroller) return; + + const wrapper = scroller.querySelector(`[data-index="${id}"]`); + if (!wrapper) return; + + const item = wrapper.firstElementChild; + if (!item) return; + if (item.firstElementChild) return; + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('bitmaprenderer'); + if (!ctx) return; + + canvas.width = imageBitmap.width; + canvas.height = imageBitmap.height; + canvas.style.width = '100%'; + canvas.style.height = '100%'; + + ctx.transferFromImageBitmap(imageBitmap); + + item.append(canvas); +} diff --git a/packages/frontend/component/src/components/attachment-viewer/viewer.tsx b/packages/frontend/component/src/components/attachment-viewer/viewer.tsx index 666a8158ca9e2..0753129102433 100644 --- a/packages/frontend/component/src/components/attachment-viewer/viewer.tsx +++ b/packages/frontend/component/src/components/attachment-viewer/viewer.tsx @@ -1,8 +1,8 @@ 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 { debounce, pick } from 'lodash-es'; +import type { ReactElement } from 'react'; import React, { useCallback, useEffect, @@ -10,15 +10,22 @@ import React, { useRef, useState, } from 'react'; -import type { VirtuosoHandle } from 'react-virtuoso'; +import type { + ItemContent, + VirtuosoHandle, + VirtuosoProps, +} 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 { renderItem } from './utils'; // import { observeResize } from '../../utils'; -import type { MessageData, MessageDataType } from './worker/types'; -import { MessageOp, State } from './worker/types'; +import type { DocInfo, MessageData, MessageDataType } from './worker/types'; +import { MessageOp, MessageState, RenderKind, State } from './worker/types'; + +type ItemProps = VirtuosoProps; const Page = React.memo( ({ @@ -34,17 +41,8 @@ const Page = React.memo( return (
- -
+ style={{ width: `${width}px`, height: `${height}px` }} + > ); } ); @@ -68,41 +66,46 @@ const Thumbnail = React.memo( onSelect: (index: number) => void; }) => { return ( -
onSelect(index)}> - -
+
onSelect(index)} + >
); } ); Thumbnail.displayName = 'viewer-thumbnail'; -const Scroller = React.forwardRef< - HTMLDivElement, - PropsWithChildren> ->(({ style, ...props }, ref) => { - return ( - - - - - ); -}); +const Scroller = React.forwardRef( + ({ context: _, ...props }, ref) => { + return ( + + + + + ); + } +); Scroller.displayName = 'viewer-scroller'; +const Item = React.forwardRef( + ({ context: _, ...props }, ref) => { + return
; + } +); + +Item.displayName = 'viewer-item'; + interface ViewerProps { model: AttachmentBlockModel; } export const Viewer = ({ model }: ViewerProps): ReactElement => { - const [connected, setConnected] = useState(false); - const [loaded, setLoaded] = useState(false); - const [docInfo, setDocInfo] = useState({ + const [state, setState] = useState(State.Connecting); + const [docInfo, setDocInfo] = useState({ + dpi: window.devicePixelRatio, cursor: 0, total: 0, width: 1, @@ -127,63 +130,26 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { }); const post = useCallback( - ( - type: T, - data?: MessageDataType[T], - transfer = [] - ) => { - workerRef.current?.postMessage( - { - state: State.Pull, - type, - [type]: data, - }, - transfer - ); + (type: T, data?: MessageDataType[T]) => { + workerRef.current?.postMessage({ + type, + [type]: data, + state: MessageState.Poll, + }); }, [workerRef] ); const render = useCallback( - (id: number, imageData: ImageData) => { - const el = scrollerRef.current; - if (!el) return; - - const canvas: HTMLCanvasElement | null = el.querySelector( - `[data-index="${id}"] canvas` + (id: number, kind: RenderKind, imageBitmap: ImageBitmap) => { + renderItem( + (kind === RenderKind.Page ? scrollerRef : thumbnailsScrollerRef) + .current, + id, + imageBitmap ); - if (!canvas) return; - if (canvas.dataset.rendered) return; - - // TODO(@fundon): improve - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.putImageData(imageData, 0, 0); - canvas.dataset.rendered = 'true'; - } - }, - [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] + [scrollerRef, thumbnailsScrollerRef] ); const onScroll = useCallback(() => { @@ -193,7 +159,10 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { const { scrollTop, scrollHeight } = el; setDocInfo(info => { - const cursor = Math.ceil(scrollTop / (scrollHeight / info.total)); + const cursor = Math.min( + Math.ceil(scrollTop / (scrollHeight / info.total)), + info.total - 1 + ); // thumbnailsScrollerHandleRef.current?.scrollToIndex(cursor) return { ...info, @@ -212,16 +181,12 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { ); const updateMainVisibleRange = useMemo( - () => debounce(setMainVisibleRange, 233, { leading: true, trailing: true }), + () => debounce(setMainVisibleRange, 233, { trailing: true }), [setMainVisibleRange] ); const updateThumbnailsVisibleRange = useMemo( - () => - debounce(setThumbnailsVisibleRange, 233, { - leading: true, - trailing: true, - }), + () => debounce(setThumbnailsVisibleRange, 233, { trailing: true }), [setThumbnailsVisibleRange] ); @@ -237,16 +202,16 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { useEffect(() => { const { startIndex, endIndex } = mainVisibleRange; let index = startIndex; - for (; index < endIndex + 1; index++) { - post(MessageOp.Render, { index, kind: 'page' }); + for (; index <= endIndex; index++) { + post(MessageOp.Render, { index, kind: RenderKind.Page }); } }, [mainVisibleRange, post]); useEffect(() => { const { startIndex, endIndex } = thumbnailsVisibleRange; let index = startIndex; - for (; index < endIndex + 1; index++) { - post(MessageOp.Render, { index, kind: 'thumbnail' }); + for (; index <= endIndex; index++) { + post(MessageOp.Render, { index, kind: RenderKind.Thumbnail }); } }, [thumbnailsVisibleRange, post]); @@ -262,29 +227,30 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { const { type, state } = data; if (type === MessageOp.Init) { - setConnected(state === State.Ready); + setState( + state === MessageState.Ready ? State.Connected : State.Connecting + ); return; } - if (type === MessageOp.Open) { - setLoaded(state === State.Ready); + if (type === MessageOp.Open && state === MessageState.Ready) { + setState(State.Loaded); return; } - if (state === State.Pull) return; + if (state === MessageState.Poll) { + return; + } switch (type) { case MessageOp.ReadInfo: { - const action = data[type]; - setDocInfo(info => ({ ...info, ...action })); + const updated = data[type]; + setDocInfo(info => ({ ...info, ...updated })); + setState(State.Synced); break; } case MessageOp.Rendered: { - const { index, imageData, kind } = data[type]; - if (kind === 'page') { - render(index, imageData); - } else { - renderThumbnail(index, imageData); - } + const { index, kind, imageBitmap } = data[type]; + render(index, kind, imageBitmap); break; } } @@ -297,27 +263,35 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { return () => { workerRef.current?.terminate(); }; - }, [model, post, render, renderThumbnail]); + }, [model, post, render]); useEffect(() => { - if (!connected) return; if (!model.sourceId) return; - model.doc.blobSync - .get(model.sourceId) - .then(blob => { - if (!blob) return; - post(MessageOp.Open, { blob, dpi: window.devicePixelRatio }); - }) - .catch(console.error); - }, [connected, model, post]); + if (state === State.Connected) { + model.doc.blobSync + .get(model.sourceId) + .then(blob => { + if (!blob) return; + setState(State.Loading); + post(MessageOp.Open, { blob }); + }) + .catch(console.error); + return; + } - useEffect(() => { - if (!loaded) return; - post(MessageOp.ReadInfo); - }, [loaded, post]); + if (state === State.Loaded) { + setState(State.Syncing); + post(MessageOp.ReadInfo, pick(docInfo, ['dpi'])); + return; + } + }, [state, post, model, docInfo]); - const pageContent = (index: number) => { + const pageContent: ItemContent = ( + index: number, + _, + { docInfo } + ) => { return ( { ); }; - const thumbnailContent = (index: number) => { + const thumbnailContent: ItemContent = ( + index: number, + _, + { docInfo } + ) => { return ( { { selected: index === docInfo.cursor }, ])} width={THUMBNAIL_WIDTH} - height={(docInfo.height / docInfo.width) * THUMBNAIL_WIDTH} + height={Math.ceil((docInfo.height / docInfo.width) * THUMBNAIL_WIDTH)} onSelect={onSelect} /> ); }; - const components = useMemo(() => { + const mainComponents = useMemo(() => { return { + Header: () =>
, + Footer: () =>
, + Item: (props: ItemProps) => ( + + ), Scroller, }; }, []); + const thumbnailsComponents = useMemo(() => { + return { + Item: (props: ItemProps) => ( + + ), + Scroller, + }; + }, []); + + const context = useMemo(() => { + return { docInfo }; + }, [docInfo]); + return (
{ ])} ref={viewerRef} > - onScroll={onScroll} ref={scrollerHandleRef} scrollerRef={scroller => { @@ -372,17 +368,18 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { className={styles.virtuoso} rangeChanged={updateMainVisibleRange} increaseViewportBy={{ - top: docInfo.height * Math.min(5, docInfo.total), - bottom: docInfo.height * Math.min(5, docInfo.total), + top: (docInfo.height + 20) * Math.min(5, docInfo.total), + bottom: (docInfo.height + 20) * Math.min(5, docInfo.total), }} + context={context} totalCount={docInfo.total} itemContent={pageContent} - components={components} + components={mainComponents} />
{collapsed ? null : (
- style={{ height: Math.min(3, docInfo.total) * @@ -396,9 +393,10 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { }} rangeChanged={updateThumbnailsVisibleRange} className={styles.virtuoso} + context={context} totalCount={docInfo.total} itemContent={thumbnailContent} - components={components} + components={thumbnailsComponents} />
)} diff --git a/packages/frontend/component/src/components/attachment-viewer/worker/types.ts b/packages/frontend/component/src/components/attachment-viewer/worker/types.ts index 4c473ba73f4b2..0140c9bf3ca8a 100644 --- a/packages/frontend/component/src/components/attachment-viewer/worker/types.ts +++ b/packages/frontend/component/src/components/attachment-viewer/worker/types.ts @@ -1,5 +1,22 @@ +export type DocInfo = { + dpi: number; + cursor: number; + total: number; + width: number; + height: number; +}; + export enum State { - Pull, + Connecting = 0, + Connected, + Loading, + Loaded, + Syncing, + Synced, +} + +export enum MessageState { + Poll, Ready, } @@ -11,23 +28,33 @@ export enum MessageOp { Rendered, } -export type MessageDataMap = { +export enum RenderKind { + Page, + Thumbnail, +} + +export interface MessageDataMap { [MessageOp.Init]: undefined; - [MessageOp.Open]: { blob: Blob; dpi: number }; - [MessageOp.ReadInfo]: { total: number; width: number; height: number }; - [MessageOp.Render]: { index: number; kind: 'page' | 'thumbnail' }; + [MessageOp.Open]: { + blob: Blob; + }; + [MessageOp.ReadInfo]: Partial; + [MessageOp.Render]: { + index: number; + kind: RenderKind; + }; [MessageOp.Rendered]: { index: number; - imageData: ImageData; - kind: 'page' | 'thumbnail'; + kind: RenderKind; + imageBitmap: ImageBitmap; }; -}; +} export type MessageDataType = { [P in keyof T]: T[P]; }; export type MessageData = { - state: State; + state: MessageState; type: T; } & P; diff --git a/packages/frontend/component/src/components/attachment-viewer/worker/utils.ts b/packages/frontend/component/src/components/attachment-viewer/worker/utils.ts new file mode 100644 index 0000000000000..495630212a496 --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/worker/utils.ts @@ -0,0 +1,20 @@ +export function resizeImageBitmap( + imageBitmap: ImageBitmap, + options: { + resizeWidth: number; + resizeHeight: number; + } +) { + return createImageBitmap( + imageBitmap, + 0, + 0, + imageBitmap.width, + imageBitmap.height, + { + colorSpaceConversion: 'none', + resizeQuality: 'pixelated', + ...options, + } + ); +} diff --git a/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts b/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts index 8c80856729c0f..7e1d733c1450c 100644 --- a/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts +++ b/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts @@ -8,55 +8,34 @@ import { } from '@toeverything/pdf-viewer'; import type { MessageData, MessageDataType } from './types'; -import { MessageOp, State } from './types'; +import { MessageOp, MessageState, RenderKind } from './types'; +import { resizeImageBitmap } from './utils'; -const logger = new DebugLogger('affine:pdf-worker'); +const logger = new DebugLogger('affine:worker:pdf'); -let dpi = 2; let inited = false; let viewer: Viewer | null = null; let doc: Document | undefined = undefined; -const cached = new Map(); -const docInfo = { cursor: 0, total: 0, width: 1, height: 1 }; +const canvas = new OffscreenCanvas(0, 0); +const ctx = canvas.getContext('2d'); +const cached = new Map(); +const docInfo = { dpi: 2, 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); + self.postMessage({ state: MessageState.Ready, type, [type]: data }); } async function start() { logger.debug('pdf worker pending'); - self.postMessage({ state: State.Pull, type: MessageOp.Init }); + self.postMessage({ state: MessageState.Poll, type: MessageOp.Init }); const pdfium = await createPDFium(); viewer = new Viewer(new Runtime(pdfium)); inited = true; - self.postMessage({ state: State.Ready, type: MessageOp.Init }); + self.postMessage({ state: MessageState.Ready, type: MessageOp.Init }); logger.debug('pdf worker ready'); } @@ -68,18 +47,17 @@ async function process({ data }: MessageEvent) { const { type, state } = data; - if (state !== State.Pull) return; + if (state !== MessageState.Poll) return; if (type === MessageOp.Open) { - const action = data[type]; - if (!action?.blob) return; + const props = data[type]; + if (!props?.blob) return; - dpi = action.dpi; - doc = await viewer.openWithBlob(action.blob); + doc = await viewer.openWithBlob(props.blob); - if (doc) { - post(MessageOp.Open); - } + if (!doc) return; + + post(MessageOp.Open); return; } @@ -88,12 +66,21 @@ async function process({ data }: MessageEvent) { switch (type) { case MessageOp.ReadInfo: { + const updated = data[type]; + + if (updated) { + Object.assign(docInfo, updated); + } + const page = doc.page(0); + if (page) { - docInfo.cursor = 0; - docInfo.total = doc.pageCount(); - docInfo.height = page.height(); - docInfo.width = page.width(); + Object.assign(docInfo, { + cursor: 0, + total: doc.pageCount(), + height: Math.ceil(page.height()), + width: Math.ceil(page.width()), + }); page.close(); post(MessageOp.ReadInfo, docInfo); } @@ -103,24 +90,23 @@ async function process({ data }: MessageEvent) { case MessageOp.Render: { const { index, kind } = data[type]; - let imageData = cached.size > 0 ? cached.get(index) : undefined; - if (imageData) { - if (kind === 'thumbnail') { - const resizeWidth = (94 * dpi) >> 0; - const resizeHeight = - ((docInfo.height / docInfo.width) * resizeWidth) >> 0; - imageData = await resizeImageData(imageData, { - resizeWidth, - resizeHeight, + let imageBitmap = cached.size > 0 ? cached.get(index) : undefined; + if (imageBitmap) { + if (kind === RenderKind.Thumbnail) { + const rw = 94 * docInfo.dpi; + const rh = (docInfo.height / docInfo.width) * rw; + imageBitmap = await resizeImageBitmap(imageBitmap, { + resizeWidth: Math.ceil(rw), + resizeHeight: Math.ceil(rh), }); } - post(MessageOp.Rendered, { index, imageData, kind }); + post(MessageOp.Rendered, { index, kind, imageBitmap }); return; } - const width = Math.ceil(docInfo.width * dpi); - const height = Math.ceil(docInfo.height * dpi); + const width = Math.ceil(docInfo.width * docInfo.dpi); + const height = Math.ceil(docInfo.height * docInfo.dpi); const page = doc.page(index); if (page) { @@ -133,21 +119,31 @@ async function process({ data }: MessageEvent) { bitmap.close(); page.close(); - imageData = new ImageData(new Uint8ClampedArray(data), width, height); - - cached.set(index, imageData); - - if (kind === 'thumbnail') { - const resizeWidth = (94 * dpi) >> 0; - const resizeHeight = - ((docInfo.height / docInfo.width) * resizeWidth) >> 0; - imageData = await resizeImageData(imageData, { - resizeWidth, - resizeHeight, + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + const imageData = new ImageData( + new Uint8ClampedArray(data), + width, + height + ); + ctx?.clearRect(0, 0, width, height); + ctx?.putImageData(imageData, 0, 0); + imageBitmap = canvas.transferToImageBitmap(); + + cached.set(index, imageBitmap); + + if (kind === RenderKind.Thumbnail) { + const rw = 94 * docInfo.dpi; + const rh = (docInfo.height / docInfo.width) * rw; + imageBitmap = await resizeImageBitmap(imageBitmap, { + resizeWidth: Math.ceil(rw), + resizeHeight: Math.ceil(rh), }); } - post(MessageOp.Rendered, { index, imageData, kind }); + post(MessageOp.Rendered, { index, kind, imageBitmap }); } break;