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