From 2d344356d6cfcf099995522fb788e8402de87ca3 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 | 226 +++++++++--------- .../attachment-viewer/worker/types.ts | 36 ++- .../attachment-viewer/worker/utils.ts | 20 ++ .../attachment-viewer/worker/worker.ts | 118 +++++---- 6 files changed, 259 insertions(+), 190 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..66c5777d6c608 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,18 @@ import React, { useRef, useState, } from 'react'; -import type { VirtuosoHandle } from 'react-virtuoso'; +import type { VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso'; import { IconButton } from '../../ui/button'; import { Scrollable } from '../../ui/scrollbar'; import * 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 } from './worker/types'; + +type ItemProps = VirtuosoProps; const Page = React.memo( ({ @@ -34,20 +37,16 @@ const Page = React.memo( return (
- -
+ style={{ width: `${width}px`, height: `${height}px` }} + > ); } ); +// Page.displayName = 'viewer-page'; @@ -68,33 +67,38 @@ const Thumbnail = React.memo( onSelect: (index: number) => void; }) => { return ( -
onSelect(index)}> - -
+
onSelect(index)} + >
); } ); Thumbnail.displayName = 'viewer-thumbnail'; -const Scroller = React.forwardRef< - HTMLDivElement, - PropsWithChildren> ->(({ style, ...props }, ref) => { - return ( - - - - - ); -}); +const Scroller = React.forwardRef( + ({ ...props }, ref) => { + return ( + + + + + ); + } +); Scroller.displayName = 'viewer-scroller'; +const Item = React.forwardRef( + ({ ...props }, ref) => { + return
; + } +); + +Item.displayName = 'viewer-item'; + interface ViewerProps { model: AttachmentBlockModel; } @@ -102,7 +106,8 @@ interface ViewerProps { export const Viewer = ({ model }: ViewerProps): ReactElement => { const [connected, setConnected] = useState(false); const [loaded, setLoaded] = useState(false); - const [docInfo, setDocInfo] = useState({ + const [docInfo, setDocInfo] = useState({ + dpi: window.devicePixelRatio, cursor: 0, total: 0, width: 1, @@ -127,73 +132,40 @@ 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.Pull, + }); }, [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(() => { const el = scrollerRef.current; if (!el) return; + console.log(123); 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 +184,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] ); @@ -235,18 +203,20 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { // }, []); useEffect(() => { + console.log(456); const { startIndex, endIndex } = mainVisibleRange; + console.log(startIndex, endIndex); 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 +232,27 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { const { type, state } = data; if (type === MessageOp.Init) { - setConnected(state === State.Ready); + setConnected(state === MessageState.Ready); return; } if (type === MessageOp.Open) { - setLoaded(state === State.Ready); + setLoaded(state === MessageState.Ready); return; } - if (state === State.Pull) return; + if (state === MessageState.Pull) { + return; + } switch (type) { case MessageOp.ReadInfo: { - const action = data[type]; - setDocInfo(info => ({ ...info, ...action })); + const updated = data[type]; + setDocInfo(info => ({ ...info, ...updated })); 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,7 +265,7 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { return () => { workerRef.current?.terminate(); }; - }, [model, post, render, renderThumbnail]); + }, [model, post, render]); useEffect(() => { if (!connected) return; @@ -307,15 +275,15 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { .get(model.sourceId) .then(blob => { if (!blob) return; - post(MessageOp.Open, { blob, dpi: window.devicePixelRatio }); + post(MessageOp.Open, { blob }); }) .catch(console.error); }, [connected, model, post]); useEffect(() => { if (!loaded) return; - post(MessageOp.ReadInfo); - }, [loaded, post]); + post(MessageOp.ReadInfo, pick(docInfo, ['dpi'])); + }, [loaded, post, docInfo]); const pageContent = (index: number) => { return ( @@ -345,12 +313,30 @@ export const Viewer = ({ model }: ViewerProps): ReactElement => { ); }; - 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 +358,20 @@ 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: 1, + bottom: 1, + //top: docInfo.height * Math.min(5, docInfo.total), + //bottom: docInfo.height * 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 +385,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..2d79d780d7cf0 100644 --- a/packages/frontend/component/src/components/attachment-viewer/worker/types.ts +++ b/packages/frontend/component/src/components/attachment-viewer/worker/types.ts @@ -1,4 +1,12 @@ -export enum State { +export type DocInfo = { + dpi: number; + cursor: number; + total: number; + width: number; + height: number; +}; + +export enum MessageState { Pull, Ready, } @@ -11,23 +19,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..58384c880043f 100644 --- a/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts +++ b/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts @@ -8,55 +8,35 @@ import { } from '@toeverything/pdf-viewer'; import type { MessageData, MessageDataType } from './types'; -import { MessageOp, State } from './types'; +import { MessageOp, MessageState, RenderKind } from './types'; +import { resizeImageBitmap } from './utils'; -const logger = new DebugLogger('affine:pdf-worker'); +const logger = new DebugLogger('affine:worker:pdf'); -let dpi = 2; let inited = false; let viewer: Viewer | null = null; let doc: Document | undefined = undefined; -const cached = new Map(); -const docInfo = { cursor: 0, total: 0, width: 1, height: 1 }; +console.log(self.devicePixelRatio); +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.Pull, 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,13 +48,12 @@ async function process({ data }: MessageEvent) { const { type, state } = data; - if (state !== State.Pull) return; + if (state !== MessageState.Pull) return; if (type === MessageOp.Open) { const action = data[type]; if (!action?.blob) return; - dpi = action.dpi; doc = await viewer.openWithBlob(action.blob); if (doc) { @@ -88,12 +67,20 @@ async function process({ data }: MessageEvent) { switch (type) { case MessageOp.ReadInfo: { + const updated = data[type]; + console.log(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: page.height(), + width: 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;