From e6082d037beddbc7f3bf5cce5a6243cc6c771a7e Mon Sep 17 00:00:00 2001 From: Fangdun Tsai Date: Thu, 14 Nov 2024 07:16:15 +0800 Subject: [PATCH] feat: pdf viewer supports fit to page --- .../pdf-viewer-embedded-inner.tsx | 30 ++-- .../attachment-viewer/pdf-viewer-inner.tsx | 72 +++++++-- .../attachment-viewer/pdf-viewer.tsx | 16 +- .../attachment-viewer/styles.css.ts | 3 + .../components/hooks/affine/use-share-url.ts | 5 +- .../core/src/modules/pdf/entities/pdf-page.ts | 5 +- .../core/src/modules/pdf/renderer/types.ts | 18 ++- .../core/src/modules/pdf/renderer/worker.ts | 28 +++- .../core/src/modules/pdf/views/components.tsx | 49 ++++-- .../src/modules/pdf/views/page-renderer.tsx | 149 +++++++++++++----- .../core/src/modules/pdf/views/styles.css.ts | 10 +- .../core/src/utils/clipboard/index.ts | 4 +- 12 files changed, 279 insertions(+), 110 deletions(-) diff --git a/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-embedded-inner.tsx b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-embedded-inner.tsx index 7948f1b0b477f..0d67436f979b0 100644 --- a/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-embedded-inner.tsx +++ b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-embedded-inner.tsx @@ -5,6 +5,8 @@ import { PDFService, PDFStatus, } from '@affine/core/modules/pdf'; +import type { PDFMeta } from '@affine/core/modules/pdf/renderer'; +import type { PageSize } from '@affine/core/modules/pdf/renderer/types'; import { LoadingSvg, PDFPageCanvas } from '@affine/core/modules/pdf/views'; import { PeekViewService } from '@affine/core/modules/peek-view'; import { stopPropagation } from '@affine/core/utils'; @@ -43,6 +45,7 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) { page: PDFPage; release: () => void; } | null>(null); + const [pageSize, setPageSize] = useState(null); const meta = useLiveData( useMemo(() => { @@ -50,9 +53,9 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) { ? pdfEntity.pdf.state$.map(s => { return s.status === PDFStatus.Opened ? s.meta - : { pageCount: 0, width: 0, height: 0 }; + : { pageCount: 0, pageSizes: [] }; }) - : new LiveData({ pageCount: 0, width: 0, height: 0 }); + : new LiveData({ pageCount: 0, pageSizes: [] }); }, [pdfEntity]) ); const img = useLiveData( @@ -107,44 +110,45 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) { if (!img) return; const ctx = canvas.getContext('2d'); if (!ctx) return; - const { width, height } = meta; - if (width * height === 0) return; setIsLoading(false); - canvas.width = width * 2; - canvas.height = height * 2; + canvas.width = img.width; + canvas.height = img.height; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0); - }, [img, meta]); + }, [img]); useEffect(() => { if (!visibility) return; if (!pageEntity) return; + if (!pageSize) return; - const { width, height } = meta; - if (width * height === 0) return; + const { width, height } = pageSize; pageEntity.page.render({ width, height, scale: 2 }); return () => { pageEntity.page.render.unsubscribe(); }; - }, [visibility, pageEntity, meta]); + }, [visibility, pageEntity, pageSize]); useEffect(() => { if (!visibility) return; if (!pdfEntity) return; - const { width, height } = meta; - if (width * height === 0) return; + const size = meta.pageSizes[cursor]; + if (!size) return; + const { width, height } = size; const pageEntity = pdfEntity.pdf.page(cursor, `${width}:${height}:2`); setPageEntity(pageEntity); + setPageSize(size); return () => { pageEntity.release(); + setPageSize(null); setPageEntity(null); }; }, [visibility, pdfEntity, cursor, meta]); @@ -191,7 +195,7 @@ export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) { justifyContent: 'center', alignItems: 'center', width: '100%', - minHeight: '759px', + minHeight: '253px', }} > diff --git a/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-inner.tsx b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-inner.tsx index ac2eac56b1480..95c47f88e8724 100644 --- a/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-inner.tsx +++ b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-inner.tsx @@ -4,6 +4,7 @@ import type { PDFRendererState, PDFStatus, } from '@affine/core/modules/pdf'; +import type { PageSize } from '@affine/core/modules/pdf/renderer/types'; import { Item, List, @@ -51,6 +52,28 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => { [] ); + const fitToPage = useCallback( + (viewportInfo: PageSize, actualSize: PageSize) => { + const { width: vw, height: vh } = viewportInfo; + const { width: w, height: h } = actualSize; + let width = 0; + let height = 0; + if (h / w > vh / vw) { + height = vh; + width = (w / h) * height; + } else { + width = vw; + height = (h / w) * width; + } + return { + width: Math.ceil(width), + height: Math.ceil(height), + aspectRatio: width / height, + }; + }, + [] + ); + const onScroll = useCallback(() => { const el = pagesScrollerRef.current; if (!el) return; @@ -81,17 +104,24 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => { ( index: number, _: unknown, - { width, height, onPageSelect, pageClassName }: PDFVirtuosoContext + { + viewportInfo, + meta, + onPageSelect, + resize, + pageClassName, + }: PDFVirtuosoContext ) => { return ( ); }, @@ -100,22 +130,28 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => { 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); + const { pageCount: t, pageSizes } = state.meta; + const height = Math.min( + vh - 60 - 24 - 24 - 2 - 8, + pageSizes.reduce((h, { height }) => h + height, 0) + (t - 1) * 12 + ); return { context: { - width: pw, - height: ph, onPageSelect, + viewportInfo: { + width: THUMBNAIL_WIDTH, + height, + }, + meta: state.meta, + resize: fitToPage, pageClassName: styles.pdfThumbnail, }, style: { height }, }; - }, [state, viewportInfo, onPageSelect]); + }, [state, viewportInfo, onPageSelect, fitToPage]); + // 1. works fine if they are the same size + // 2. uses the `observeIntersection` when targeting different sizes const scrollSeekConfig = useMemo(() => { return { enter: velocity => Math.abs(velocity) > 1024, @@ -154,8 +190,12 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => { ScrollSeekPlaceholder, }} context={{ - width: state.meta.width, - height: state.meta.height, + viewportInfo: { + width: viewportInfo.width - 40, + height: viewportInfo.height - 40, + }, + meta: state.meta, + resize: fitToPage, pageClassName: styles.pdfPage, }} scrollSeekConfiguration={scrollSeekConfig} @@ -174,9 +214,9 @@ export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => { Scroller, ScrollSeekPlaceholder, }} - scrollSeekConfiguration={scrollSeekConfig} style={thumbnailsConfig.style} context={thumbnailsConfig.context} + scrollSeekConfiguration={scrollSeekConfig} />
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 c93292162c6c1..2eb508ef6ab34 100644 --- a/packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx +++ b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx @@ -1,5 +1,5 @@ +import { Loading } from '@affine/component'; import { type PDF, PDFService, PDFStatus } from '@affine/core/modules/pdf'; -import { LoadingSvg } from '@affine/core/modules/pdf/views'; import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; import { useLiveData, useService } from '@toeverything/infra'; import { useEffect, useState } from 'react'; @@ -10,7 +10,7 @@ function PDFViewerStatus({ pdf, ...props }: PDFViewerProps & { pdf: PDF }) { const state = useLiveData(pdf.state$); if (state?.status !== PDFStatus.Opened) { - return ; + return ; } return ; @@ -31,12 +31,20 @@ export function PDFViewer({ model, ...props }: PDFViewerProps) { const { pdf, release } = pdfService.get(model); setPdf(pdf); - return release; + return () => { + release(); + }; }, [model, pdfService, setPdf]); if (!pdf) { - return ; + return ; } return ; } + +const PDFLoading = () => ( +
+ +
+); 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 1e85631d4e6c5..bdd269ae32c66 100644 --- a/packages/frontend/core/src/components/attachment-viewer/styles.css.ts +++ b/packages/frontend/core/src/components/attachment-viewer/styles.css.ts @@ -128,6 +128,9 @@ export const pdfPage = style({ '0px 4px 20px 0px var(--transparent-black-200, rgba(0, 0, 0, 0.10))', overflow: 'hidden', maxHeight: 'max-content', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', }); export const pdfThumbnails = style({ diff --git a/packages/frontend/core/src/components/hooks/affine/use-share-url.ts b/packages/frontend/core/src/components/hooks/affine/use-share-url.ts index 8dc9650856467..a8281f80be63e 100644 --- a/packages/frontend/core/src/components/hooks/affine/use-share-url.ts +++ b/packages/frontend/core/src/components/hooks/affine/use-share-url.ts @@ -148,9 +148,8 @@ export const useSharingUrl = ({ workspaceId, pageId }: UseSharingUrl) => { if (sharingUrl) { copyTextToClipboard(sharingUrl) .then(success => { - if (success) { - notify.success({ title: t['Copied link to clipboard']() }); - } + if (!success) return; + notify.success({ title: t['Copied link to clipboard']() }); }) .catch(err => { console.error(err); diff --git a/packages/frontend/core/src/modules/pdf/entities/pdf-page.ts b/packages/frontend/core/src/modules/pdf/entities/pdf-page.ts index b1f91b14ea172..ab71a03264530 100644 --- a/packages/frontend/core/src/modules/pdf/entities/pdf-page.ts +++ b/packages/frontend/core/src/modules/pdf/entities/pdf-page.ts @@ -6,7 +6,7 @@ import { LiveData, mapInto, } from '@toeverything/infra'; -import { map, switchMap } from 'rxjs'; +import { filter, map, switchMap } from 'rxjs'; import type { RenderPageOpts } from '../renderer'; import type { PDF } from './pdf'; @@ -25,7 +25,8 @@ export class PDFPage extends Entity<{ pdf: PDF; pageNum: number }> { pageNum: this.pageNum, }) ), - map(data => data.bitmap), + map(data => data?.bitmap), + filter(Boolean), mapInto(this.bitmap$), catchErrorInto(this.error$, error => { logger.error('Failed to render page', error); diff --git a/packages/frontend/core/src/modules/pdf/renderer/types.ts b/packages/frontend/core/src/modules/pdf/renderer/types.ts index 3e79550a1d80f..b1e2b8087fa52 100644 --- a/packages/frontend/core/src/modules/pdf/renderer/types.ts +++ b/packages/frontend/core/src/modules/pdf/renderer/types.ts @@ -1,16 +1,22 @@ -export type PDFMeta = { - pageCount: number; +export type PageSize = { width: number; height: number; }; +export type PDFMeta = { + pageCount: number; + pageSizes: PageSize[]; +}; + +export type PageSizeOpts = { + pageNum: number; +}; + export type RenderPageOpts = { pageNum: number; - width: number; - height: number; scale?: number; -}; +} & PageSize; -export type RenderedPage = RenderPageOpts & { +export type RenderedPage = { bitmap: ImageBitmap; }; diff --git a/packages/frontend/core/src/modules/pdf/renderer/worker.ts b/packages/frontend/core/src/modules/pdf/renderer/worker.ts index cd5f9351f940b..2987a30a3c712 100644 --- a/packages/frontend/core/src/modules/pdf/renderer/worker.ts +++ b/packages/frontend/core/src/modules/pdf/renderer/worker.ts @@ -45,7 +45,9 @@ class PDFRendererBackend extends OpConsumer { observer.next(doc); return () => { - doc.close(); + setTimeout(() => { + doc.close(); + }, 1000); // Waits for ObjectPool GC }; }); }), @@ -60,15 +62,26 @@ class PDFRendererBackend extends OpConsumer { throw new Error('Document not opened'); } - const firstPage = doc.page(0); - if (!firstPage) { - throw new Error('Document has no pages'); + const pageCount = doc.pageCount(); + const pageSizes = []; + let i = 0; + + for (; i < pageCount; i++) { + const page = doc.page(i); + if (!page) { + throw new Error('Document has no pages'); + } + const size = page.size(); + pageSizes.push({ + width: Math.ceil(size.width), + height: Math.ceil(size.height), + }); + page.close(); } return { - pageCount: doc.pageCount(), - width: firstPage.width(), - height: firstPage.height(), + pageCount, + pageSizes, }; }) ); @@ -100,7 +113,6 @@ class PDFRendererBackend extends OpConsumer { async renderPage(viewer: Viewer, doc: Document, opts: RenderPageOpts) { const page = doc.page(opts.pageNum); - if (!page) return; const scale = opts.scale ?? 1; diff --git a/packages/frontend/core/src/modules/pdf/views/components.tsx b/packages/frontend/core/src/modules/pdf/views/components.tsx index c7af3e29ca89b..e19416d765eef 100644 --- a/packages/frontend/core/src/modules/pdf/views/components.tsx +++ b/packages/frontend/core/src/modules/pdf/views/components.tsx @@ -3,13 +3,19 @@ import clsx from 'clsx'; import { type CSSProperties, forwardRef, memo } from 'react'; import type { ScrollSeekPlaceholderProps, VirtuosoProps } from 'react-virtuoso'; +import type { PDFMeta } from '../renderer'; +import type { PageSize } from '../renderer/types'; import * as styles from './styles.css'; export type PDFVirtuosoContext = { - width: number; - height: number; + viewportInfo: PageSize; + meta: PDFMeta; pageClassName?: string; onPageSelect?: (index: number) => void; + resize: ( + viewportInfo: PageSize, + actualSize: PageSize + ) => { aspectRatio: number } & PageSize; }; export type PDFVirtuosoProps = VirtuosoProps; @@ -32,16 +38,27 @@ export const ScrollSeekPlaceholder = forwardRef< ScrollSeekPlaceholderProps & { context?: PDFVirtuosoContext; } ->(({ context }, ref) => { +>(({ context, index }, ref) => { const className = context?.pageClassName; - const width = context?.width ?? 537; - const height = context?.height ?? 759; - const style = { width, aspectRatio: `${width} / ${height}` }; + const size = context?.meta.pageSizes[index]; + const height = size?.height ?? 759; + const style = + context?.viewportInfo && size + ? context.resize(context.viewportInfo, size) + : undefined; return ( -
- -
+ +
+ +
+
); }); @@ -114,8 +131,18 @@ export const LoadingSvg = memo( LoadingSvg.displayName = 'pdf-loading'; -export const PDFPageCanvas = forwardRef((props, ref) => { - return ; +export const PDFPageCanvas = forwardRef< + HTMLCanvasElement, + { style?: CSSProperties } +>(({ style, ...props }, ref) => { + return ( + + ); }); PDFPageCanvas.displayName = 'pdf-page-canvas'; diff --git a/packages/frontend/core/src/modules/pdf/views/page-renderer.tsx b/packages/frontend/core/src/modules/pdf/views/page-renderer.tsx index e9ad029df53f1..b4b517a826af9 100644 --- a/packages/frontend/core/src/modules/pdf/views/page-renderer.tsx +++ b/packages/frontend/core/src/modules/pdf/views/page-renderer.tsx @@ -1,85 +1,156 @@ +import { observeIntersection } from '@affine/component'; import { useI18n } from '@affine/i18n'; import { useLiveData } from '@toeverything/infra'; -import { useEffect, useRef, useState } from 'react'; +import { debounce } from 'lodash-es'; +import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'; import type { PDF } from '../entities/pdf'; import type { PDFPage } from '../entities/pdf-page'; +import type { PageSize } from '../renderer/types'; import { LoadingSvg, PDFPageCanvas } from './components'; import * as styles from './styles.css'; interface PDFPageProps { pdf: PDF; - width: number; - height: number; + actualSize: PageSize; pageNum: number; scale?: number; className?: string; + viewportInfo: PageSize; onSelect?: (pageNum: number) => void; + resize: ( + viewportInfo: PageSize, + actualSize: PageSize + ) => { aspectRatio: number } & PageSize; } export const PDFPageRenderer = ({ pdf, - width, - height, pageNum, className, + actualSize, + viewportInfo, onSelect, + resize, scale = window.devicePixelRatio, }: PDFPageProps) => { const t = useI18n(); - const [pdfPage, setPdfPage] = useState(null); + const pageViewRef = useRef(null); const canvasRef = useRef(null); - const img = useLiveData(pdfPage?.bitmap$ ?? null); - const error = useLiveData(pdfPage?.error$ ?? null); - const style = { width, aspectRatio: `${width} / ${height}` }; + const [page, setPage] = useState(null); + const img = useLiveData(useMemo(() => (page ? page.bitmap$ : null), [page])); + const error = useLiveData(page?.error$ ?? null); + const size = useMemo(() => { + return resize(viewportInfo, actualSize); + }, [resize, viewportInfo, actualSize]); + const [visibility, setVisibility] = useState(false); useEffect(() => { - if (width * height === 0) return; + const canvas = canvasRef.current; + if (!canvas) return; + if (!img) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; - const { page, release } = pdf.page(pageNum, `${width}:${height}:${scale}`); - setPdfPage(page); + canvas.width = img.width; + canvas.height = img.height; + ctx.drawImage(img, 0, 0); + }, [img]); - return release; - }, [pdf, width, height, pageNum, scale]); + useEffect(() => { + if (!visibility) return; + if (!page) return; + + const width = size.width; + const height = size.height; + + page.render({ + width, + height, + scale, + }); + + return () => { + page.render.unsubscribe(); + }; + }, [visibility, page, size, scale]); useEffect(() => { - if (width * height === 0) return; + if (!visibility) return; + if (!pdf) return; - pdfPage?.render({ width, height, scale }); + const width = size.width; + const height = size.height; + const key = `${width}:${height}:${scale}`; + const { page, release } = pdf.page(pageNum, key); - return pdfPage?.render.unsubscribe; - }, [pdfPage, width, height, scale]); + setPage(page); + + return () => { + release(); + setPage(null); + }; + }, [visibility, pdf, pageNum, size, scale]); useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - if (!img) return; - const ctx = canvas.getContext('2d'); - if (!ctx) return; - if (width * height === 0) return; + const pageView = pageViewRef.current; + if (!pageView) 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 observeIntersection( + pageView, + debounce( + entry => { + setVisibility(entry.isIntersecting); + }, + 377, + { + trailing: true, + } + ) ); - } + }, []); return (
onSelect?.(pageNum)} > - {img === null ? : } +
); }; + +interface PageRendererInnerProps { + img: ImageBitmap | null; + err: string | null; +} + +const PageRendererInner = forwardRef( + ({ img, err }, ref) => { + if (img) { + return ( + + ); + } + + if (err) { + return

{err}

; + } + + return ; + } +); + +PageRendererInner.displayName = 'pdf-page-renderer-inner'; diff --git a/packages/frontend/core/src/modules/pdf/views/styles.css.ts b/packages/frontend/core/src/modules/pdf/views/styles.css.ts index 6584895a85514..44b53a0607fc7 100644 --- a/packages/frontend/core/src/modules/pdf/views/styles.css.ts +++ b/packages/frontend/core/src/modules/pdf/views/styles.css.ts @@ -10,6 +10,7 @@ export const virtuosoList = style({ flexDirection: 'column', alignItems: 'center', justifyContent: 'center', + minHeight: 'calc(100% - 40px)', gap: '20px', selectors: { '&.small-gap': { @@ -40,15 +41,14 @@ export const pdfPageError = style({ }); export const pdfPageCanvas = style({ - width: '100%', + maxWidth: '100%', }); export const pdfLoading = style({ display: 'flex', alignSelf: 'center', - margin: 'auto', - width: '100%', - height: '100%', - maxWidth: '537px', + width: '179.66px', + height: '253px', + aspectRatio: '539 / 759', overflow: 'hidden', }); diff --git a/packages/frontend/core/src/utils/clipboard/index.ts b/packages/frontend/core/src/utils/clipboard/index.ts index aa60ded16d3f9..f82283cd1d116 100644 --- a/packages/frontend/core/src/utils/clipboard/index.ts +++ b/packages/frontend/core/src/utils/clipboard/index.ts @@ -30,9 +30,7 @@ export const copyLinkToBlockStdScopeClipboard = async ( ) => { let success = false; - if (!clipboard) return success; - - if (clipboardWriteIsSupported) { + if (clipboardWriteIsSupported && clipboard) { try { await clipboard.writeToClipboard(items => { items['text/plain'] = text;