mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
feat(core): pdf embed view component (#8671)
Closes: [AF-1445](https://linear.app/affine-design/issue/AF-1445/添加-pdf-embed-view-component) Upstreams: https://github.com/toeverything/blocksuite/pull/8658, https://github.com/toeverything/blocksuite/pull/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>
This commit is contained in:
@@ -2,7 +2,7 @@ import { ViewBody, ViewHeader } from '@affine/core/modules/workbench';
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
|
||||
|
||||
import { AttachmentPreviewErrorBoundary, Error } from './error';
|
||||
import { PDFViewer } from './pdf-viewer';
|
||||
import { PDFViewer, type PDFViewerProps } from './pdf-viewer';
|
||||
import * as styles from './styles.css';
|
||||
import { Titlebar } from './titlebar';
|
||||
import { buildAttachmentProps } from './utils';
|
||||
@@ -18,13 +18,7 @@ export const AttachmentViewer = ({ model }: AttachmentViewerProps) => {
|
||||
return (
|
||||
<div className={styles.viewerContainer}>
|
||||
<Titlebar {...props} />
|
||||
{model.type.endsWith('pdf') ? (
|
||||
<AttachmentPreviewErrorBoundary>
|
||||
<PDFViewer {...props} />
|
||||
</AttachmentPreviewErrorBoundary>
|
||||
) : (
|
||||
<Error {...props} />
|
||||
)}
|
||||
<AttachmentViewerInner {...props} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -39,14 +33,18 @@ export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => {
|
||||
<Titlebar {...props} />
|
||||
</ViewHeader>
|
||||
<ViewBody>
|
||||
{model.type.endsWith('pdf') ? (
|
||||
<AttachmentPreviewErrorBoundary>
|
||||
<PDFViewer {...props} />
|
||||
</AttachmentPreviewErrorBoundary>
|
||||
) : (
|
||||
<Error {...props} />
|
||||
)}
|
||||
<AttachmentViewerInner {...props} />
|
||||
</ViewBody>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AttachmentViewerInner = (props: PDFViewerProps) => {
|
||||
return props.model.type.endsWith('pdf') ? (
|
||||
<AttachmentPreviewErrorBoundary>
|
||||
<PDFViewer {...props} />
|
||||
</AttachmentPreviewErrorBoundary>
|
||||
) : (
|
||||
<Error {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { IconButton, observeResize } from '@affine/component';
|
||||
import type {
|
||||
PDF,
|
||||
PDFRendererState,
|
||||
PDFStatus,
|
||||
} from '@affine/core/modules/pdf';
|
||||
import {
|
||||
Item,
|
||||
List,
|
||||
ListPadding,
|
||||
ListWithSmallGap,
|
||||
PDFPageRenderer,
|
||||
type PDFVirtuosoContext,
|
||||
type PDFVirtuosoProps,
|
||||
Scroller,
|
||||
ScrollSeekPlaceholder,
|
||||
} from '@affine/core/modules/pdf/views';
|
||||
import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type ScrollSeekConfiguration,
|
||||
Virtuoso,
|
||||
type VirtuosoHandle,
|
||||
} from 'react-virtuoso';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
import { calculatePageNum } from './utils';
|
||||
|
||||
const THUMBNAIL_WIDTH = 94;
|
||||
|
||||
export interface PDFViewerInnerProps {
|
||||
pdf: PDF;
|
||||
state: Extract<PDFRendererState, { status: PDFStatus.Opened }>;
|
||||
}
|
||||
|
||||
export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [viewportInfo, setViewportInfo] = useState({ width: 0, height: 0 });
|
||||
|
||||
const viewerRef = useRef<HTMLDivElement>(null);
|
||||
const pagesScrollerRef = useRef<HTMLElement | null>(null);
|
||||
const pagesScrollerHandleRef = useRef<VirtuosoHandle>(null);
|
||||
const thumbnailsScrollerHandleRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const updateScrollerRef = useCallback(
|
||||
(scroller: HTMLElement | Window | null) => {
|
||||
pagesScrollerRef.current = scroller as HTMLElement;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onScroll = useCallback(() => {
|
||||
const el = pagesScrollerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const { pageCount } = state.meta;
|
||||
if (!pageCount) return;
|
||||
|
||||
const cursor = calculatePageNum(el, pageCount);
|
||||
|
||||
setCursor(cursor);
|
||||
}, [pagesScrollerRef, state]);
|
||||
|
||||
const onPageSelect = useCallback(
|
||||
(index: number) => {
|
||||
const scroller = pagesScrollerHandleRef.current;
|
||||
if (!scroller) return;
|
||||
|
||||
scroller.scrollToIndex({
|
||||
index,
|
||||
align: 'center',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
},
|
||||
[pagesScrollerHandleRef]
|
||||
);
|
||||
|
||||
const pageContent = useCallback(
|
||||
(
|
||||
index: number,
|
||||
_: unknown,
|
||||
{ width, height, onPageSelect, pageClassName }: PDFVirtuosoContext
|
||||
) => {
|
||||
return (
|
||||
<PDFPageRenderer
|
||||
key={index}
|
||||
pdf={pdf}
|
||||
width={width}
|
||||
height={height}
|
||||
pageNum={index}
|
||||
onSelect={onPageSelect}
|
||||
className={pageClassName}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[pdf]
|
||||
);
|
||||
|
||||
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 {
|
||||
context: {
|
||||
width: pw,
|
||||
height: ph,
|
||||
onPageSelect,
|
||||
pageClassName: styles.pdfThumbnail,
|
||||
},
|
||||
style: { height },
|
||||
};
|
||||
}, [state, viewportInfo, onPageSelect]);
|
||||
|
||||
const scrollSeekConfig = useMemo<ScrollSeekConfiguration>(() => {
|
||||
return {
|
||||
enter: velocity => Math.abs(velocity) > 1024,
|
||||
exit: velocity => Math.abs(velocity) < 10,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const viewer = viewerRef.current;
|
||||
if (!viewer) return;
|
||||
return observeResize(viewer, ({ contentRect: { width, height } }) =>
|
||||
setViewportInfo({ width, height })
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={viewerRef}
|
||||
data-testid="pdf-viewer"
|
||||
className={clsx([styles.viewer, { gridding: true, scrollable: true }])}
|
||||
>
|
||||
<Virtuoso<PDFVirtuosoProps>
|
||||
key={pdf.id}
|
||||
ref={pagesScrollerHandleRef}
|
||||
scrollerRef={updateScrollerRef}
|
||||
onScroll={onScroll}
|
||||
className={styles.virtuoso}
|
||||
totalCount={state.meta.pageCount}
|
||||
itemContent={pageContent}
|
||||
components={{
|
||||
Item,
|
||||
List,
|
||||
Scroller,
|
||||
Header: ListPadding,
|
||||
Footer: ListPadding,
|
||||
ScrollSeekPlaceholder,
|
||||
}}
|
||||
context={{
|
||||
width: state.meta.width,
|
||||
height: state.meta.height,
|
||||
pageClassName: styles.pdfPage,
|
||||
}}
|
||||
scrollSeekConfiguration={scrollSeekConfig}
|
||||
/>
|
||||
<div className={clsx(['thumbnails', styles.pdfThumbnails])}>
|
||||
<div className={clsx([styles.pdfThumbnailsList, { collapsed }])}>
|
||||
<Virtuoso<PDFVirtuosoProps>
|
||||
key={`${pdf.id}-thumbnail`}
|
||||
ref={thumbnailsScrollerHandleRef}
|
||||
className={styles.virtuoso}
|
||||
totalCount={state.meta.pageCount}
|
||||
itemContent={pageContent}
|
||||
components={{
|
||||
Item,
|
||||
List: ListWithSmallGap,
|
||||
Scroller,
|
||||
ScrollSeekPlaceholder,
|
||||
}}
|
||||
scrollSeekConfiguration={scrollSeekConfig}
|
||||
style={thumbnailsConfig.style}
|
||||
context={thumbnailsConfig.context}
|
||||
/>
|
||||
</div>
|
||||
<div className={clsx(['indicator', styles.pdfIndicator])}>
|
||||
<div>
|
||||
<span className="page-cursor">
|
||||
{state.meta.pageCount > 0 ? cursor + 1 : 0}
|
||||
</span>
|
||||
/<span className="page-count">{state.meta.pageCount}</span>
|
||||
</div>
|
||||
<IconButton
|
||||
icon={collapsed ? <CollapseIcon /> : <ExpandIcon />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,206 +1,13 @@
|
||||
import { IconButton, observeResize } from '@affine/component';
|
||||
import {
|
||||
type PDF,
|
||||
type PDFRendererState,
|
||||
PDFService,
|
||||
PDFStatus,
|
||||
} from '@affine/core/modules/pdf';
|
||||
import {
|
||||
Item,
|
||||
List,
|
||||
ListPadding,
|
||||
ListWithSmallGap,
|
||||
LoadingSvg,
|
||||
PDFPageRenderer,
|
||||
type PDFVirtuosoContext,
|
||||
type PDFVirtuosoProps,
|
||||
Scroller,
|
||||
ScrollSeekPlaceholder,
|
||||
} from '@affine/core/modules/pdf/views';
|
||||
import { type PDF, PDFService, PDFStatus } from '@affine/core/modules/pdf';
|
||||
import { LoadingSvg } from '@affine/core/modules/pdf/views';
|
||||
import track from '@affine/track';
|
||||
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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type ScrollSeekConfiguration,
|
||||
Virtuoso,
|
||||
type VirtuosoHandle,
|
||||
} from 'react-virtuoso';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
import { calculatePageNum } from './utils';
|
||||
import { PDFViewerInner } from './pdf-viewer-inner';
|
||||
|
||||
const THUMBNAIL_WIDTH = 94;
|
||||
|
||||
interface ViewerProps {
|
||||
model: AttachmentBlockModel;
|
||||
}
|
||||
|
||||
interface PDFViewerInnerProps {
|
||||
pdf: PDF;
|
||||
state: Extract<PDFRendererState, { status: PDFStatus.Opened }>;
|
||||
}
|
||||
|
||||
const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => {
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [viewportInfo, setViewportInfo] = useState({ width: 0, height: 0 });
|
||||
|
||||
const viewerRef = useRef<HTMLDivElement>(null);
|
||||
const pagesScrollerRef = useRef<HTMLElement | null>(null);
|
||||
const pagesScrollerHandleRef = useRef<VirtuosoHandle>(null);
|
||||
const thumbnailsScrollerHandleRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const onScroll = useCallback(() => {
|
||||
const el = pagesScrollerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const { pageCount } = state.meta;
|
||||
if (!pageCount) return;
|
||||
|
||||
const cursor = calculatePageNum(el, pageCount);
|
||||
|
||||
setCursor(cursor);
|
||||
}, [pagesScrollerRef, state]);
|
||||
|
||||
const onPageSelect = useCallback(
|
||||
(index: number) => {
|
||||
const scroller = pagesScrollerHandleRef.current;
|
||||
if (!scroller) return;
|
||||
|
||||
scroller.scrollToIndex({
|
||||
index,
|
||||
align: 'center',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
},
|
||||
[pagesScrollerHandleRef]
|
||||
);
|
||||
|
||||
const pageContent = useCallback(
|
||||
(
|
||||
index: number,
|
||||
_: unknown,
|
||||
{ width, height, onPageSelect, pageClassName }: PDFVirtuosoContext
|
||||
) => {
|
||||
return (
|
||||
<PDFPageRenderer
|
||||
key={index}
|
||||
pdf={pdf}
|
||||
width={width}
|
||||
height={height}
|
||||
pageNum={index}
|
||||
onSelect={onPageSelect}
|
||||
className={pageClassName}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[pdf]
|
||||
);
|
||||
|
||||
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 {
|
||||
context: {
|
||||
width: pw,
|
||||
height: ph,
|
||||
onPageSelect,
|
||||
pageClassName: styles.pdfThumbnail,
|
||||
},
|
||||
style: { height },
|
||||
};
|
||||
}, [state, viewportInfo, onPageSelect]);
|
||||
|
||||
const scrollSeekConfig = useMemo<ScrollSeekConfiguration>(() => {
|
||||
return {
|
||||
enter: velocity => Math.abs(velocity) > 1024,
|
||||
exit: velocity => Math.abs(velocity) < 10,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const viewer = viewerRef.current;
|
||||
if (!viewer) return;
|
||||
return observeResize(viewer, ({ contentRect: { width, height } }) =>
|
||||
setViewportInfo({ width, height })
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={viewerRef}
|
||||
data-testid="pdf-viewer"
|
||||
className={clsx([styles.viewer, { gridding: true, scrollable: true }])}
|
||||
>
|
||||
<Virtuoso<PDFVirtuosoProps>
|
||||
key={pdf.id}
|
||||
ref={pagesScrollerHandleRef}
|
||||
scrollerRef={scroller => {
|
||||
pagesScrollerRef.current = scroller as HTMLElement;
|
||||
}}
|
||||
onScroll={onScroll}
|
||||
className={styles.virtuoso}
|
||||
totalCount={state.meta.pageCount}
|
||||
itemContent={pageContent}
|
||||
components={{
|
||||
Item,
|
||||
List,
|
||||
Scroller,
|
||||
Header: ListPadding,
|
||||
Footer: ListPadding,
|
||||
ScrollSeekPlaceholder,
|
||||
}}
|
||||
context={{
|
||||
width: state.meta.width,
|
||||
height: state.meta.height,
|
||||
pageClassName: styles.pdfPage,
|
||||
}}
|
||||
scrollSeekConfiguration={scrollSeekConfig}
|
||||
/>
|
||||
<div className={clsx(['thumbnails', styles.pdfThumbnails])}>
|
||||
<div className={clsx([styles.pdfThumbnailsList, { collapsed }])}>
|
||||
<Virtuoso<PDFVirtuosoProps>
|
||||
key={`${pdf.id}-thumbnail`}
|
||||
ref={thumbnailsScrollerHandleRef}
|
||||
className={styles.virtuoso}
|
||||
totalCount={state.meta.pageCount}
|
||||
itemContent={pageContent}
|
||||
components={{
|
||||
Item,
|
||||
List: ListWithSmallGap,
|
||||
Scroller,
|
||||
ScrollSeekPlaceholder,
|
||||
}}
|
||||
scrollSeekConfiguration={scrollSeekConfig}
|
||||
style={thumbnailsConfig.style}
|
||||
context={thumbnailsConfig.context}
|
||||
/>
|
||||
</div>
|
||||
<div className={clsx(['indicator', styles.pdfIndicator])}>
|
||||
<div>
|
||||
<span className="page-cursor">
|
||||
{state.meta.pageCount > 0 ? cursor + 1 : 0}
|
||||
</span>
|
||||
/<span className="page-count">{state.meta.pageCount}</span>
|
||||
</div>
|
||||
<IconButton
|
||||
icon={collapsed ? <CollapseIcon /> : <ExpandIcon />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function PDFViewerStatus({ pdf }: { pdf: PDF }) {
|
||||
function PDFViewerStatus({ pdf, ...props }: PDFViewerProps & { pdf: PDF }) {
|
||||
const state = useLiveData(pdf.state$);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -213,10 +20,17 @@ function PDFViewerStatus({ pdf }: { pdf: PDF }) {
|
||||
return <LoadingSvg />;
|
||||
}
|
||||
|
||||
return <PDFViewerInner pdf={pdf} state={state} />;
|
||||
return <PDFViewerInner {...props} pdf={pdf} state={state} />;
|
||||
}
|
||||
|
||||
export function PDFViewer({ model }: ViewerProps) {
|
||||
export interface PDFViewerProps {
|
||||
model: AttachmentBlockModel;
|
||||
name: string;
|
||||
ext: string;
|
||||
size: string;
|
||||
}
|
||||
|
||||
export function PDFViewer({ model, ...props }: PDFViewerProps) {
|
||||
const pdfService = useService(PDFService);
|
||||
const [pdf, setPdf] = useState<PDF | null>(null);
|
||||
|
||||
@@ -231,5 +45,5 @@ export function PDFViewer({ model }: ViewerProps) {
|
||||
return <LoadingSvg />;
|
||||
}
|
||||
|
||||
return <PDFViewerStatus pdf={pdf} />;
|
||||
return <PDFViewerStatus {...props} model={model} pdf={pdf} />;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ export const titlebarName = style({
|
||||
wordWrap: 'break-word',
|
||||
});
|
||||
|
||||
export const virtuoso = style({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const error = style({
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
@@ -106,10 +110,6 @@ export const viewer = style({
|
||||
},
|
||||
});
|
||||
|
||||
export const virtuoso = style({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const pdfIndicator = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -127,6 +127,7 @@ export const pdfPage = style({
|
||||
boxShadow:
|
||||
'0px 4px 20px 0px var(--transparent-black-200, rgba(0, 0, 0, 0.10))',
|
||||
overflow: 'hidden',
|
||||
maxHeight: 'max-content',
|
||||
});
|
||||
|
||||
export const pdfThumbnails = style({
|
||||
@@ -156,7 +157,6 @@ export const pdfThumbnailsList = style({
|
||||
flexDirection: 'column',
|
||||
maxHeight: '100%',
|
||||
overflow: 'hidden',
|
||||
resize: 'both',
|
||||
selectors: {
|
||||
'&.collapsed': {
|
||||
display: 'none',
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const pdfContainer = style({
|
||||
width: '100%',
|
||||
borderRadius: '8px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
background: cssVar('--affine-background-primary-color'),
|
||||
userSelect: 'none',
|
||||
contentVisibility: 'visible',
|
||||
});
|
||||
|
||||
export const pdfViewer = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '12px',
|
||||
overflow: 'hidden',
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
});
|
||||
|
||||
export const pdfPlaceholder = style({
|
||||
position: 'absolute',
|
||||
maxWidth: 'calc(100% - 24px)',
|
||||
overflow: 'hidden',
|
||||
height: 'auto',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
export const pdfControls = style({
|
||||
position: 'absolute',
|
||||
bottom: '16px',
|
||||
right: '14px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
});
|
||||
|
||||
export const pdfControlButton = style({
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: cssVar('--affine-border-color'),
|
||||
background: cssVar('--affine-white'),
|
||||
});
|
||||
|
||||
export const pdfFooter = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
textWrap: 'nowrap',
|
||||
});
|
||||
|
||||
export const pdfFooterItem = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
selectors: {
|
||||
'&.truncate': {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const pdfTitle = style({
|
||||
marginLeft: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
lineHeight: '22px',
|
||||
color: cssVarV2('text/primary'),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const pdfPageCount = style({
|
||||
fontSize: '12px',
|
||||
fontWeight: 400,
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
|
||||
import { filesize } from 'filesize';
|
||||
|
||||
import { downloadBlob } from '../../utils/resource';
|
||||
import type { PDFViewerProps } from './pdf-viewer';
|
||||
|
||||
export async function getAttachmentBlob(model: AttachmentBlockModel) {
|
||||
const sourceId = model.sourceId;
|
||||
@@ -26,7 +27,9 @@ export async function download(model: AttachmentBlockModel) {
|
||||
await downloadBlob(blob, model.name);
|
||||
}
|
||||
|
||||
export function buildAttachmentProps(model: AttachmentBlockModel) {
|
||||
export function buildAttachmentProps(
|
||||
model: AttachmentBlockModel
|
||||
): PDFViewerProps {
|
||||
const pieces = model.name.split('.');
|
||||
const ext = pieces.pop() || '';
|
||||
const name = pieces.join('.');
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
patchDocModeService,
|
||||
patchEdgelessClipboard,
|
||||
patchEmbedLinkedDocBlockConfig,
|
||||
patchForAttachmentEmbedViews,
|
||||
patchForMobile,
|
||||
patchForSharedPage,
|
||||
patchGenerateDocUrlExtension,
|
||||
@@ -148,6 +149,7 @@ const usePatchSpecs = (shared: boolean, mode: DocMode) => {
|
||||
let patched = specs.concat(
|
||||
patchReferenceRenderer(reactToLit, referenceRenderer)
|
||||
);
|
||||
patched = patched.concat(patchForAttachmentEmbedViews(reactToLit));
|
||||
patched = patched.concat(patchNotificationService(confirmModal));
|
||||
patched = patched.concat(patchPeekViewService(peekViewService));
|
||||
patched = patched.concat(patchEdgelessClipboard());
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
toReactNode,
|
||||
type useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import { AttachmentPreviewErrorBoundary } from '@affine/core/components/attachment-viewer/error';
|
||||
import { PDFViewerEmbedded } from '@affine/core/components/attachment-viewer/pdf-viewer-embedded';
|
||||
import { buildAttachmentProps } from '@affine/core/components/attachment-viewer/utils';
|
||||
import { WorkspaceServerService } from '@affine/core/modules/cloud';
|
||||
import { type DocService, DocsService } from '@affine/core/modules/doc';
|
||||
import type { EditorService } from '@affine/core/modules/editor';
|
||||
@@ -47,6 +50,7 @@ import type {
|
||||
} from '@blocksuite/affine/blocks';
|
||||
import {
|
||||
AffineSlashMenuWidget,
|
||||
AttachmentEmbedConfigIdentifier,
|
||||
DocModeExtension,
|
||||
EdgelessRootBlockComponent,
|
||||
EmbedLinkedDocBlockComponent,
|
||||
@@ -59,6 +63,7 @@ import {
|
||||
QuickSearchExtension,
|
||||
ReferenceNodeConfigExtension,
|
||||
} from '@blocksuite/affine/blocks';
|
||||
import { Bound } from '@blocksuite/affine/global/utils';
|
||||
import { type BlockSnapshot, Text } from '@blocksuite/affine/store';
|
||||
import type { ReferenceParams } from '@blocksuite/affine-model';
|
||||
import {
|
||||
@@ -597,3 +602,33 @@ export function patchForMobile() {
|
||||
];
|
||||
return extensions;
|
||||
}
|
||||
|
||||
export function patchForAttachmentEmbedViews(
|
||||
reactToLit: (element: ElementOrFactory) => TemplateResult
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.override(AttachmentEmbedConfigIdentifier('pdf'), () => ({
|
||||
name: 'pdf',
|
||||
check: (model, maxFileSize) =>
|
||||
model.type === 'application/pdf' && model.size <= maxFileSize,
|
||||
action: model => {
|
||||
const bound = Bound.deserialize(model.xywh);
|
||||
bound.w = 537 + 24 + 2;
|
||||
bound.h = 759 + 46 + 24 + 2;
|
||||
model.doc.updateBlock(model, {
|
||||
embed: true,
|
||||
style: 'pdf',
|
||||
xywh: bound.serialize(),
|
||||
});
|
||||
},
|
||||
template: (model, _blobUrl) =>
|
||||
reactToLit(
|
||||
<AttachmentPreviewErrorBoundary key={model.id}>
|
||||
<PDFViewerEmbedded {...buildAttachmentProps(model)} />
|
||||
</AttachmentPreviewErrorBoundary>
|
||||
),
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,8 +103,9 @@ class PDFRendererBackend extends OpConsumer<ClientOps> {
|
||||
|
||||
if (!page) return;
|
||||
|
||||
const width = Math.ceil(opts.width * (opts.scale ?? 1));
|
||||
const height = Math.ceil(opts.height * (opts.scale ?? 1));
|
||||
const scale = opts.scale ?? 1;
|
||||
const width = Math.ceil(opts.width * scale);
|
||||
const height = Math.ceil(opts.height * scale);
|
||||
|
||||
const bitmap = viewer.createBitmap(width, height, 0);
|
||||
bitmap.fill(0, 0, width, height);
|
||||
@@ -119,9 +120,8 @@ class PDFRendererBackend extends OpConsumer<ClientOps> {
|
||||
);
|
||||
|
||||
const data = new Uint8ClampedArray(bitmap.toUint8Array());
|
||||
const imageBitmap = await createImageBitmap(
|
||||
new ImageData(data, width, height)
|
||||
);
|
||||
const imageData = new ImageData(data, width, height);
|
||||
const imageBitmap = await createImageBitmap(imageData);
|
||||
|
||||
bitmap.close();
|
||||
page.close();
|
||||
|
||||
@@ -113,3 +113,9 @@ export const LoadingSvg = memo(
|
||||
);
|
||||
|
||||
LoadingSvg.displayName = 'pdf-loading';
|
||||
|
||||
export const PDFPageCanvas = forwardRef<HTMLCanvasElement>((props, ref) => {
|
||||
return <canvas className={styles.pdfPageCanvas} ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
PDFPageCanvas.displayName = 'pdf-page-canvas';
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
ListPadding,
|
||||
ListWithSmallGap,
|
||||
LoadingSvg,
|
||||
PDFPageCanvas,
|
||||
type PDFVirtuosoContext,
|
||||
type PDFVirtuosoProps,
|
||||
Scroller,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { PDF } from '../entities/pdf';
|
||||
import type { PDFPage } from '../entities/pdf-page';
|
||||
import { LoadingSvg } from './components';
|
||||
import { LoadingSvg, PDFPageCanvas } from './components';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
interface PDFPageProps {
|
||||
@@ -34,6 +34,8 @@ export const PDFPageRenderer = ({
|
||||
const style = { width, aspectRatio: `${width} / ${height}` };
|
||||
|
||||
useEffect(() => {
|
||||
if (width * height === 0) return;
|
||||
|
||||
const { page, release } = pdf.page(pageNum, `${width}:${height}:${scale}`);
|
||||
setPdfPage(page);
|
||||
|
||||
@@ -41,6 +43,8 @@ export const PDFPageRenderer = ({
|
||||
}, [pdf, width, height, pageNum, scale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (width * height === 0) return;
|
||||
|
||||
pdfPage?.render({ width, height, scale });
|
||||
|
||||
return pdfPage?.render.unsubscribe;
|
||||
@@ -52,6 +56,7 @@ export const PDFPageRenderer = ({
|
||||
if (!img) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
if (width * height === 0) return;
|
||||
|
||||
canvas.width = width * scale;
|
||||
canvas.height = height * scale;
|
||||
@@ -74,11 +79,7 @@ export const PDFPageRenderer = ({
|
||||
style={style}
|
||||
onClick={() => onSelect?.(pageNum)}
|
||||
>
|
||||
{img === null ? (
|
||||
<LoadingSvg />
|
||||
) : (
|
||||
<canvas className={styles.pdfPageCanvas} ref={canvasRef} />
|
||||
)}
|
||||
{img === null ? <LoadingSvg /> : <PDFPageCanvas ref={canvasRef} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,18 +25,6 @@ export const virtuosoItem = style({
|
||||
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',
|
||||
@@ -62,4 +50,5 @@ export const pdfLoading = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '537px',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
SurfaceRefBlockModel,
|
||||
} from '@blocksuite/affine/blocks';
|
||||
import { AffineReference } from '@blocksuite/affine/blocks';
|
||||
import type { BlockModel } from '@blocksuite/affine/store';
|
||||
import type { Block, BlockModel } from '@blocksuite/affine/store';
|
||||
import type { AIChatBlockModel } from '@toeverything/infra';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
import type { TemplateResult } from 'lit';
|
||||
@@ -36,7 +36,8 @@ export type PeekViewElement =
|
||||
| HTMLElement
|
||||
| BlockComponent
|
||||
| AffineReference
|
||||
| HTMLAnchorElement;
|
||||
| HTMLAnchorElement
|
||||
| Block;
|
||||
|
||||
export interface PeekViewTarget {
|
||||
element?: PeekViewElement;
|
||||
@@ -184,7 +185,7 @@ function resolvePeekInfoFromPeekTarget(
|
||||
blockIds: [blockModel.id],
|
||||
},
|
||||
};
|
||||
} else if (isAIChatBlockModel(blockModel)) {
|
||||
} else if (isAIChatBlockModel(blockModel) && 'host' in element) {
|
||||
return {
|
||||
type: 'ai-chat-block',
|
||||
docRef: {
|
||||
|
||||
Reference in New Issue
Block a user