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:
fundon
2024-12-26 05:28:17 +00:00
parent 90173b2fa3
commit bb43e4b664
16 changed files with 646 additions and 248 deletions

View File

@@ -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} />
);
};

View File

@@ -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>
);
}

View File

@@ -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} />;
}

View File

@@ -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>
);
};

View File

@@ -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} />;
}

View File

@@ -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',

View File

@@ -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'),
});

View File

@@ -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('.');

View File

@@ -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());

View File

@@ -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>
),
}));
},
};
}

View File

@@ -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();

View File

@@ -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';

View File

@@ -4,6 +4,7 @@ export {
ListPadding,
ListWithSmallGap,
LoadingSvg,
PDFPageCanvas,
type PDFVirtuosoContext,
type PDFVirtuosoProps,
Scroller,

View File

@@ -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>
);
};

View File

@@ -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',
});

View File

@@ -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: {