mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +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 type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
|
||||||
|
|
||||||
import { AttachmentPreviewErrorBoundary, Error } from './error';
|
import { AttachmentPreviewErrorBoundary, Error } from './error';
|
||||||
import { PDFViewer } from './pdf-viewer';
|
import { PDFViewer, type PDFViewerProps } from './pdf-viewer';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
import { Titlebar } from './titlebar';
|
import { Titlebar } from './titlebar';
|
||||||
import { buildAttachmentProps } from './utils';
|
import { buildAttachmentProps } from './utils';
|
||||||
@@ -18,13 +18,7 @@ export const AttachmentViewer = ({ model }: AttachmentViewerProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.viewerContainer}>
|
<div className={styles.viewerContainer}>
|
||||||
<Titlebar {...props} />
|
<Titlebar {...props} />
|
||||||
{model.type.endsWith('pdf') ? (
|
<AttachmentViewerInner {...props} />
|
||||||
<AttachmentPreviewErrorBoundary>
|
|
||||||
<PDFViewer {...props} />
|
|
||||||
</AttachmentPreviewErrorBoundary>
|
|
||||||
) : (
|
|
||||||
<Error {...props} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -39,14 +33,18 @@ export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => {
|
|||||||
<Titlebar {...props} />
|
<Titlebar {...props} />
|
||||||
</ViewHeader>
|
</ViewHeader>
|
||||||
<ViewBody>
|
<ViewBody>
|
||||||
{model.type.endsWith('pdf') ? (
|
<AttachmentViewerInner {...props} />
|
||||||
<AttachmentPreviewErrorBoundary>
|
|
||||||
<PDFViewer {...props} />
|
|
||||||
</AttachmentPreviewErrorBoundary>
|
|
||||||
) : (
|
|
||||||
<Error {...props} />
|
|
||||||
)}
|
|
||||||
</ViewBody>
|
</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, PDFService, PDFStatus } from '@affine/core/modules/pdf';
|
||||||
import {
|
import { LoadingSvg } from '@affine/core/modules/pdf/views';
|
||||||
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 track from '@affine/track';
|
import track from '@affine/track';
|
||||||
import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
|
import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
|
||||||
import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc';
|
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import clsx from 'clsx';
|
import { useEffect, useState } from 'react';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import {
|
|
||||||
type ScrollSeekConfiguration,
|
|
||||||
Virtuoso,
|
|
||||||
type VirtuosoHandle,
|
|
||||||
} from 'react-virtuoso';
|
|
||||||
|
|
||||||
import * as styles from './styles.css';
|
import { PDFViewerInner } from './pdf-viewer-inner';
|
||||||
import { calculatePageNum } from './utils';
|
|
||||||
|
|
||||||
const THUMBNAIL_WIDTH = 94;
|
function PDFViewerStatus({ pdf, ...props }: PDFViewerProps & { pdf: PDF }) {
|
||||||
|
|
||||||
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 }) {
|
|
||||||
const state = useLiveData(pdf.state$);
|
const state = useLiveData(pdf.state$);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -213,10 +20,17 @@ function PDFViewerStatus({ pdf }: { pdf: PDF }) {
|
|||||||
return <LoadingSvg />;
|
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 pdfService = useService(PDFService);
|
||||||
const [pdf, setPdf] = useState<PDF | null>(null);
|
const [pdf, setPdf] = useState<PDF | null>(null);
|
||||||
|
|
||||||
@@ -231,5 +45,5 @@ export function PDFViewer({ model }: ViewerProps) {
|
|||||||
return <LoadingSvg />;
|
return <LoadingSvg />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PDFViewerStatus pdf={pdf} />;
|
return <PDFViewerStatus {...props} model={model} pdf={pdf} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ export const titlebarName = style({
|
|||||||
wordWrap: 'break-word',
|
wordWrap: 'break-word',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const virtuoso = style({
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
export const error = style({
|
export const error = style({
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -106,10 +110,6 @@ export const viewer = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const virtuoso = style({
|
|
||||||
width: '100%',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const pdfIndicator = style({
|
export const pdfIndicator = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -127,6 +127,7 @@ export const pdfPage = style({
|
|||||||
boxShadow:
|
boxShadow:
|
||||||
'0px 4px 20px 0px var(--transparent-black-200, rgba(0, 0, 0, 0.10))',
|
'0px 4px 20px 0px var(--transparent-black-200, rgba(0, 0, 0, 0.10))',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
maxHeight: 'max-content',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pdfThumbnails = style({
|
export const pdfThumbnails = style({
|
||||||
@@ -156,7 +157,6 @@ export const pdfThumbnailsList = style({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
resize: 'both',
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'&.collapsed': {
|
'&.collapsed': {
|
||||||
display: 'none',
|
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 { filesize } from 'filesize';
|
||||||
|
|
||||||
import { downloadBlob } from '../../utils/resource';
|
import { downloadBlob } from '../../utils/resource';
|
||||||
|
import type { PDFViewerProps } from './pdf-viewer';
|
||||||
|
|
||||||
export async function getAttachmentBlob(model: AttachmentBlockModel) {
|
export async function getAttachmentBlob(model: AttachmentBlockModel) {
|
||||||
const sourceId = model.sourceId;
|
const sourceId = model.sourceId;
|
||||||
@@ -26,7 +27,9 @@ export async function download(model: AttachmentBlockModel) {
|
|||||||
await downloadBlob(blob, model.name);
|
await downloadBlob(blob, model.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAttachmentProps(model: AttachmentBlockModel) {
|
export function buildAttachmentProps(
|
||||||
|
model: AttachmentBlockModel
|
||||||
|
): PDFViewerProps {
|
||||||
const pieces = model.name.split('.');
|
const pieces = model.name.split('.');
|
||||||
const ext = pieces.pop() || '';
|
const ext = pieces.pop() || '';
|
||||||
const name = pieces.join('.');
|
const name = pieces.join('.');
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
patchDocModeService,
|
patchDocModeService,
|
||||||
patchEdgelessClipboard,
|
patchEdgelessClipboard,
|
||||||
patchEmbedLinkedDocBlockConfig,
|
patchEmbedLinkedDocBlockConfig,
|
||||||
|
patchForAttachmentEmbedViews,
|
||||||
patchForMobile,
|
patchForMobile,
|
||||||
patchForSharedPage,
|
patchForSharedPage,
|
||||||
patchGenerateDocUrlExtension,
|
patchGenerateDocUrlExtension,
|
||||||
@@ -148,6 +149,7 @@ const usePatchSpecs = (shared: boolean, mode: DocMode) => {
|
|||||||
let patched = specs.concat(
|
let patched = specs.concat(
|
||||||
patchReferenceRenderer(reactToLit, referenceRenderer)
|
patchReferenceRenderer(reactToLit, referenceRenderer)
|
||||||
);
|
);
|
||||||
|
patched = patched.concat(patchForAttachmentEmbedViews(reactToLit));
|
||||||
patched = patched.concat(patchNotificationService(confirmModal));
|
patched = patched.concat(patchNotificationService(confirmModal));
|
||||||
patched = patched.concat(patchPeekViewService(peekViewService));
|
patched = patched.concat(patchPeekViewService(peekViewService));
|
||||||
patched = patched.concat(patchEdgelessClipboard());
|
patched = patched.concat(patchEdgelessClipboard());
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
toReactNode,
|
toReactNode,
|
||||||
type useConfirmModal,
|
type useConfirmModal,
|
||||||
} from '@affine/component';
|
} 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 { WorkspaceServerService } from '@affine/core/modules/cloud';
|
||||||
import { type DocService, DocsService } from '@affine/core/modules/doc';
|
import { type DocService, DocsService } from '@affine/core/modules/doc';
|
||||||
import type { EditorService } from '@affine/core/modules/editor';
|
import type { EditorService } from '@affine/core/modules/editor';
|
||||||
@@ -47,6 +50,7 @@ import type {
|
|||||||
} from '@blocksuite/affine/blocks';
|
} from '@blocksuite/affine/blocks';
|
||||||
import {
|
import {
|
||||||
AffineSlashMenuWidget,
|
AffineSlashMenuWidget,
|
||||||
|
AttachmentEmbedConfigIdentifier,
|
||||||
DocModeExtension,
|
DocModeExtension,
|
||||||
EdgelessRootBlockComponent,
|
EdgelessRootBlockComponent,
|
||||||
EmbedLinkedDocBlockComponent,
|
EmbedLinkedDocBlockComponent,
|
||||||
@@ -59,6 +63,7 @@ import {
|
|||||||
QuickSearchExtension,
|
QuickSearchExtension,
|
||||||
ReferenceNodeConfigExtension,
|
ReferenceNodeConfigExtension,
|
||||||
} from '@blocksuite/affine/blocks';
|
} from '@blocksuite/affine/blocks';
|
||||||
|
import { Bound } from '@blocksuite/affine/global/utils';
|
||||||
import { type BlockSnapshot, Text } from '@blocksuite/affine/store';
|
import { type BlockSnapshot, Text } from '@blocksuite/affine/store';
|
||||||
import type { ReferenceParams } from '@blocksuite/affine-model';
|
import type { ReferenceParams } from '@blocksuite/affine-model';
|
||||||
import {
|
import {
|
||||||
@@ -597,3 +602,33 @@ export function patchForMobile() {
|
|||||||
];
|
];
|
||||||
return extensions;
|
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;
|
if (!page) return;
|
||||||
|
|
||||||
const width = Math.ceil(opts.width * (opts.scale ?? 1));
|
const scale = opts.scale ?? 1;
|
||||||
const height = Math.ceil(opts.height * (opts.scale ?? 1));
|
const width = Math.ceil(opts.width * scale);
|
||||||
|
const height = Math.ceil(opts.height * scale);
|
||||||
|
|
||||||
const bitmap = viewer.createBitmap(width, height, 0);
|
const bitmap = viewer.createBitmap(width, height, 0);
|
||||||
bitmap.fill(0, 0, width, height);
|
bitmap.fill(0, 0, width, height);
|
||||||
@@ -119,9 +120,8 @@ class PDFRendererBackend extends OpConsumer<ClientOps> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const data = new Uint8ClampedArray(bitmap.toUint8Array());
|
const data = new Uint8ClampedArray(bitmap.toUint8Array());
|
||||||
const imageBitmap = await createImageBitmap(
|
const imageData = new ImageData(data, width, height);
|
||||||
new ImageData(data, width, height)
|
const imageBitmap = await createImageBitmap(imageData);
|
||||||
);
|
|
||||||
|
|
||||||
bitmap.close();
|
bitmap.close();
|
||||||
page.close();
|
page.close();
|
||||||
|
|||||||
@@ -113,3 +113,9 @@ export const LoadingSvg = memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
LoadingSvg.displayName = 'pdf-loading';
|
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,
|
ListPadding,
|
||||||
ListWithSmallGap,
|
ListWithSmallGap,
|
||||||
LoadingSvg,
|
LoadingSvg,
|
||||||
|
PDFPageCanvas,
|
||||||
type PDFVirtuosoContext,
|
type PDFVirtuosoContext,
|
||||||
type PDFVirtuosoProps,
|
type PDFVirtuosoProps,
|
||||||
Scroller,
|
Scroller,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import type { PDF } from '../entities/pdf';
|
import type { PDF } from '../entities/pdf';
|
||||||
import type { PDFPage } from '../entities/pdf-page';
|
import type { PDFPage } from '../entities/pdf-page';
|
||||||
import { LoadingSvg } from './components';
|
import { LoadingSvg, PDFPageCanvas } from './components';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
interface PDFPageProps {
|
interface PDFPageProps {
|
||||||
@@ -34,6 +34,8 @@ export const PDFPageRenderer = ({
|
|||||||
const style = { width, aspectRatio: `${width} / ${height}` };
|
const style = { width, aspectRatio: `${width} / ${height}` };
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (width * height === 0) return;
|
||||||
|
|
||||||
const { page, release } = pdf.page(pageNum, `${width}:${height}:${scale}`);
|
const { page, release } = pdf.page(pageNum, `${width}:${height}:${scale}`);
|
||||||
setPdfPage(page);
|
setPdfPage(page);
|
||||||
|
|
||||||
@@ -41,6 +43,8 @@ export const PDFPageRenderer = ({
|
|||||||
}, [pdf, width, height, pageNum, scale]);
|
}, [pdf, width, height, pageNum, scale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (width * height === 0) return;
|
||||||
|
|
||||||
pdfPage?.render({ width, height, scale });
|
pdfPage?.render({ width, height, scale });
|
||||||
|
|
||||||
return pdfPage?.render.unsubscribe;
|
return pdfPage?.render.unsubscribe;
|
||||||
@@ -52,6 +56,7 @@ export const PDFPageRenderer = ({
|
|||||||
if (!img) return;
|
if (!img) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
if (width * height === 0) return;
|
||||||
|
|
||||||
canvas.width = width * scale;
|
canvas.width = width * scale;
|
||||||
canvas.height = height * scale;
|
canvas.height = height * scale;
|
||||||
@@ -74,11 +79,7 @@ export const PDFPageRenderer = ({
|
|||||||
style={style}
|
style={style}
|
||||||
onClick={() => onSelect?.(pageNum)}
|
onClick={() => onSelect?.(pageNum)}
|
||||||
>
|
>
|
||||||
{img === null ? (
|
{img === null ? <LoadingSvg /> : <PDFPageCanvas ref={canvasRef} />}
|
||||||
<LoadingSvg />
|
|
||||||
) : (
|
|
||||||
<canvas className={styles.pdfPageCanvas} ref={canvasRef} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,18 +25,6 @@ export const virtuosoItem = style({
|
|||||||
justifyContent: '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({
|
export const pdfPageError = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
@@ -62,4 +50,5 @@ export const pdfLoading = style({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
maxWidth: '537px',
|
maxWidth: '537px',
|
||||||
|
overflow: 'hidden',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
SurfaceRefBlockModel,
|
SurfaceRefBlockModel,
|
||||||
} from '@blocksuite/affine/blocks';
|
} from '@blocksuite/affine/blocks';
|
||||||
import { AffineReference } 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 type { AIChatBlockModel } from '@toeverything/infra';
|
||||||
import { Entity, LiveData } from '@toeverything/infra';
|
import { Entity, LiveData } from '@toeverything/infra';
|
||||||
import type { TemplateResult } from 'lit';
|
import type { TemplateResult } from 'lit';
|
||||||
@@ -36,7 +36,8 @@ export type PeekViewElement =
|
|||||||
| HTMLElement
|
| HTMLElement
|
||||||
| BlockComponent
|
| BlockComponent
|
||||||
| AffineReference
|
| AffineReference
|
||||||
| HTMLAnchorElement;
|
| HTMLAnchorElement
|
||||||
|
| Block;
|
||||||
|
|
||||||
export interface PeekViewTarget {
|
export interface PeekViewTarget {
|
||||||
element?: PeekViewElement;
|
element?: PeekViewElement;
|
||||||
@@ -184,7 +185,7 @@ function resolvePeekInfoFromPeekTarget(
|
|||||||
blockIds: [blockModel.id],
|
blockIds: [blockModel.id],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else if (isAIChatBlockModel(blockModel)) {
|
} else if (isAIChatBlockModel(blockModel) && 'host' in element) {
|
||||||
return {
|
return {
|
||||||
type: 'ai-chat-block',
|
type: 'ai-chat-block',
|
||||||
docRef: {
|
docRef: {
|
||||||
|
|||||||
Reference in New Issue
Block a user