From e2a2988702676388d3d21b221be62ccd9bea15d3 Mon Sep 17 00:00:00 2001 From: Fangdun Tsai Date: Tue, 12 Nov 2024 05:36:28 +0800 Subject: [PATCH] refactor: pdf viewer components --- .../components/attachment-viewer/error.tsx | 2 +- .../attachment-viewer/pdf-viewer.tsx | 329 ++++++------------ .../attachment-viewer/styles.css.ts | 88 ++--- .../src/components/attachment-viewer/utils.ts | 44 +-- .../core/src/modules/pdf/entities/pdf-page.ts | 26 ++ .../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/views/components.tsx | 64 ++++ .../core/src/modules/pdf/views/index.ts | 10 + .../src/modules/pdf/views/page-renderer.tsx | 65 ++++ .../core/src/modules/pdf/views/styles.css.ts | 42 +++ 14 files changed, 416 insertions(+), 400 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/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/pdf-viewer.tsx b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx index 6de3d15a08959..e8e3ffcbbb8bc 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,176 @@ -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, + 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 thumbnailsInfo = 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={thumbnailsInfo.style} + context={thumbnailsInfo.context} />
-
+
- + {state.meta.pageCount > 0 ? cursor + 1 : 0} - /{state.meta.pageCount} + /{state.meta.pageCount}
: } 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..c0cb0fed3a1c4 100644 --- a/packages/frontend/core/src/components/attachment-viewer/styles.css.ts +++ b/packages/frontend/core/src/components/attachment-viewer/styles.css.ts @@ -42,36 +42,6 @@ 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%', -}); - export const error = style({ flexDirection: 'column', alignItems: 'center', @@ -101,22 +71,44 @@ 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, 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', @@ -127,7 +119,7 @@ export const viewerPage = style({ '0px 4px 20px 0px var(--transparent-black-200, rgba(0, 0, 0, 0.10))', }); -export const thumbnails = style({ +export const pdfThumbnails = style({ display: 'flex', flexDirection: 'column', position: 'absolute', @@ -148,13 +140,12 @@ 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', selectors: { '&.collapsed': { display: 'none', @@ -165,11 +156,7 @@ export const thumbnailsPages = style({ }, }); -export const thumbnailsItemWrapper = style({ - margin: '0px 12px 12px', -}); - -export const thumbnailsPage = style({ +export const pdfThumbnail = style({ display: 'flex', overflow: 'clip', // width: '100%', @@ -183,10 +170,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/utils.ts b/packages/frontend/core/src/components/attachment-viewer/utils.ts index 04e5a7c9479e0..a26ebd0338ca4 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,37 +26,6 @@ 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('.'); @@ -72,7 +40,7 @@ export function buildAttachmentProps(model: AttachmentBlockModel) { * * 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]` + * 3. 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); @@ -107,3 +75,13 @@ export function genSeq(start: number, end: number, total: number) { 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..6a7d7c0af5c71 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/entities/pdf-page.ts @@ -0,0 +1,26 @@ +import { effect, Entity, LiveData, mapInto } from '@toeverything/infra'; +import { map, switchMap } from 'rxjs'; + +import type { RenderPageOpts } from '../renderer'; +import type { PDF } from './pdf'; + +export class PDFPage extends Entity<{ pdf: PDF; pageNum: number }> { + readonly pageNum: number = this.props.pageNum; + bitmap$ = 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$) + ); + + 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/views/components.tsx b/packages/frontend/core/src/modules/pdf/views/components.tsx new file mode 100644 index 0000000000000..63a92238a1338 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/views/components.tsx @@ -0,0 +1,64 @@ +import { Scrollable } from '@affine/component'; +import clsx from 'clsx'; +import { forwardRef } 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 = () => ( +
+); 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..a0410c1ab46c9 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/views/index.ts @@ -0,0 +1,10 @@ +export { + Item, + List, + ListPadding, + ListWithSmallGap, + 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..caf793a80f6fc --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/views/page-renderer.tsx @@ -0,0 +1,65 @@ +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 * 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 [pdfPage, setPdfPage] = useState(null); + const canvasRef = useRef(null); + const img = useLiveData(pdfPage?.bitmap$ ?? null); + + 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]); + + return ( +
onSelect?.(pageNum)} + style={{ width, aspectRatio: `${width} / ${height}` }} + > + +
+ ); +}; 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..4356c449c0ec9 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/views/styles.css.ts @@ -0,0 +1,42 @@ +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 pdfPageCanvas = style({ + width: '100%', +});