-
-
Notifications
You must be signed in to change notification settings - Fork 3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): pdf embed view component (#8671)
Closes: [AF-1445](https://linear.app/affine-design/issue/AF-1445/添加-pdf-embed-view-component) Upstreams: toeverything/blocksuite#8658, toeverything/blocksuite#8752 ### What's Changed * uses `IntersectionObserver` to determine lazy loading * adds `PDFViewerEmbedded` component * automatically shut down workers when out of view <div class='graphite__hidden'> <div>🎥 Video uploaded on Graphite:</div> <a href="https://app.graphite.dev/media/video/8ypiIKZXudF5a0tIgIzf/4181bec6-83fd-42f1-bfea-87276e0bd9e6.mov"> <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/8ypiIKZXudF5a0tIgIzf/4181bec6-83fd-42f1-bfea-87276e0bd9e6.mov"> </a> </div> <video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/8ypiIKZXudF5a0tIgIzf/4181bec6-83fd-42f1-bfea-87276e0bd9e6.mov">Screen Recording 2024-11-15 at 08.14.34.mov</video>
- Loading branch information
Showing
16 changed files
with
646 additions
and
248 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
249 changes: 249 additions & 0 deletions
249
packages/frontend/core/src/components/attachment-viewer/pdf-viewer-embedded-inner.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,249 @@ | ||
import { IconButton, observeIntersection } from '@affine/component'; | ||
import { | ||
type PDF, | ||
type PDFPage, | ||
PDFService, | ||
PDFStatus, | ||
} from '@affine/core/modules/pdf'; | ||
import { LoadingSvg, PDFPageCanvas } from '@affine/core/modules/pdf/views'; | ||
import { PeekViewService } from '@affine/core/modules/peek-view'; | ||
import { stopPropagation } from '@affine/core/utils'; | ||
import { | ||
ArrowDownSmallIcon, | ||
ArrowUpSmallIcon, | ||
AttachmentIcon, | ||
CenterPeekIcon, | ||
} from '@blocksuite/icons/rc'; | ||
import { LiveData, useLiveData, useService } from '@toeverything/infra'; | ||
import clsx from 'clsx'; | ||
import { debounce } from 'lodash-es'; | ||
import { | ||
type MouseEvent, | ||
useCallback, | ||
useEffect, | ||
useMemo, | ||
useRef, | ||
useState, | ||
} from 'react'; | ||
|
||
import type { PDFViewerProps } from './pdf-viewer'; | ||
import * as styles from './styles.css'; | ||
import * as embeddedStyles from './styles.embedded.css'; | ||
|
||
type PDFViewerEmbeddedInnerProps = PDFViewerProps; | ||
|
||
export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) { | ||
const peekView = useService(PeekViewService).peekView; | ||
const pdfService = useService(PDFService); | ||
const [pdfEntity, setPdfEntity] = useState<{ | ||
pdf: PDF; | ||
release: () => void; | ||
} | null>(null); | ||
const [pageEntity, setPageEntity] = useState<{ | ||
page: PDFPage; | ||
release: () => void; | ||
} | null>(null); | ||
|
||
const meta = useLiveData( | ||
useMemo(() => { | ||
return pdfEntity | ||
? pdfEntity.pdf.state$.map(s => { | ||
return s.status === PDFStatus.Opened | ||
? s.meta | ||
: { pageCount: 0, width: 0, height: 0 }; | ||
}) | ||
: new LiveData({ pageCount: 0, width: 0, height: 0 }); | ||
}, [pdfEntity]) | ||
); | ||
const img = useLiveData( | ||
useMemo(() => { | ||
return pageEntity ? pageEntity.page.bitmap$ : null; | ||
}, [pageEntity]) | ||
); | ||
|
||
const [isLoading, setIsLoading] = useState(true); | ||
const [cursor, setCursor] = useState(0); | ||
const viewerRef = useRef<HTMLDivElement>(null); | ||
const [visibility, setVisibility] = useState(false); | ||
const canvasRef = useRef<HTMLCanvasElement>(null); | ||
|
||
const peek = useCallback(() => { | ||
const target = model.doc.getBlock(model.id); | ||
if (!target) return; | ||
peekView.open({ element: target }).catch(console.error); | ||
}, [peekView, model]); | ||
|
||
const navigator = useMemo(() => { | ||
const p = cursor - 1; | ||
const n = cursor + 1; | ||
|
||
return { | ||
prev: { | ||
disabled: p < 0, | ||
onClick: (e: MouseEvent) => { | ||
e.stopPropagation(); | ||
setCursor(p); | ||
}, | ||
}, | ||
next: { | ||
disabled: n >= meta.pageCount, | ||
onClick: (e: MouseEvent) => { | ||
e.stopPropagation(); | ||
setCursor(n); | ||
}, | ||
}, | ||
peek: { | ||
onClick: (e: MouseEvent) => { | ||
e.stopPropagation(); | ||
peek(); | ||
}, | ||
}, | ||
}; | ||
}, [cursor, meta, peek]); | ||
|
||
useEffect(() => { | ||
const canvas = canvasRef.current; | ||
if (!canvas) return; | ||
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; | ||
ctx.clearRect(0, 0, canvas.width, canvas.height); | ||
ctx.drawImage(img, 0, 0); | ||
}, [img, meta]); | ||
|
||
useEffect(() => { | ||
if (!visibility) return; | ||
if (!pageEntity) return; | ||
|
||
const { width, height } = meta; | ||
if (width * height === 0) return; | ||
|
||
pageEntity.page.render({ width, height, scale: 2 }); | ||
|
||
return () => { | ||
pageEntity.page.render.unsubscribe(); | ||
}; | ||
}, [visibility, pageEntity, meta]); | ||
|
||
useEffect(() => { | ||
if (!visibility) return; | ||
if (!pdfEntity) return; | ||
|
||
const { width, height } = meta; | ||
if (width * height === 0) return; | ||
|
||
const pageEntity = pdfEntity.pdf.page(cursor, `${width}:${height}:2`); | ||
|
||
setPageEntity(pageEntity); | ||
|
||
return () => { | ||
pageEntity.release(); | ||
setPageEntity(null); | ||
}; | ||
}, [visibility, pdfEntity, cursor, meta]); | ||
|
||
useEffect(() => { | ||
if (!visibility) return; | ||
|
||
const pdfEntity = pdfService.get(model); | ||
|
||
setPdfEntity(pdfEntity); | ||
|
||
return () => { | ||
pdfEntity.release(); | ||
setPdfEntity(null); | ||
}; | ||
}, [model, pdfService, visibility]); | ||
|
||
useEffect(() => { | ||
const viewer = viewerRef.current; | ||
if (!viewer) return; | ||
|
||
return observeIntersection( | ||
viewer, | ||
debounce( | ||
entry => { | ||
setVisibility(entry.isIntersecting); | ||
}, | ||
377, | ||
{ | ||
trailing: true, | ||
} | ||
) | ||
); | ||
}, []); | ||
|
||
return ( | ||
<div ref={viewerRef} className={embeddedStyles.pdfContainer}> | ||
<main className={embeddedStyles.pdfViewer}> | ||
<div | ||
className={styles.pdfPage} | ||
style={{ | ||
position: 'relative', | ||
display: 'flex', | ||
justifyContent: 'center', | ||
alignItems: 'center', | ||
width: '100%', | ||
minHeight: '759px', | ||
}} | ||
> | ||
<PDFPageCanvas ref={canvasRef} /> | ||
<LoadingSvg | ||
style={{ | ||
position: 'absolute', | ||
visibility: isLoading ? 'visible' : 'hidden', | ||
}} | ||
/> | ||
</div> | ||
|
||
<div className={embeddedStyles.pdfControls}> | ||
<IconButton | ||
size={16} | ||
icon={<ArrowUpSmallIcon />} | ||
className={embeddedStyles.pdfControlButton} | ||
onDoubleClick={stopPropagation} | ||
{...navigator.prev} | ||
/> | ||
<IconButton | ||
size={16} | ||
icon={<ArrowDownSmallIcon />} | ||
className={embeddedStyles.pdfControlButton} | ||
onDoubleClick={stopPropagation} | ||
{...navigator.next} | ||
/> | ||
<IconButton | ||
size={16} | ||
icon={<CenterPeekIcon />} | ||
className={embeddedStyles.pdfControlButton} | ||
onDoubleClick={stopPropagation} | ||
{...navigator.peek} | ||
/> | ||
</div> | ||
</main> | ||
<footer className={embeddedStyles.pdfFooter}> | ||
<div | ||
className={clsx([embeddedStyles.pdfFooterItem, { truncate: true }])} | ||
> | ||
<AttachmentIcon /> | ||
<span className={embeddedStyles.pdfTitle}>{model.name}</span> | ||
</div> | ||
<div | ||
className={clsx([ | ||
embeddedStyles.pdfFooterItem, | ||
embeddedStyles.pdfPageCount, | ||
])} | ||
> | ||
<span>{meta.pageCount > 0 ? cursor + 1 : '-'}</span>/ | ||
<span>{meta.pageCount > 0 ? meta.pageCount : '-'}</span> | ||
</div> | ||
</footer> | ||
</div> | ||
); | ||
} |
14 changes: 14 additions & 0 deletions
14
packages/frontend/core/src/components/attachment-viewer/pdf-viewer-embedded.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; | ||
|
||
import { PDFViewerEmbeddedInner } from './pdf-viewer-embedded-inner'; | ||
|
||
export interface PDFViewerEmbeddedProps { | ||
model: AttachmentBlockModel; | ||
name: string; | ||
ext: string; | ||
size: string; | ||
} | ||
|
||
export function PDFViewerEmbedded(props: PDFViewerEmbeddedProps) { | ||
return <PDFViewerEmbeddedInner {...props} />; | ||
} |
Oops, something went wrong.