Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: pdf embed view component #8671

Open
wants to merge 1 commit into
base: canary
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/frontend/component/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './observe-intersection';
export * from './observe-resize';
export { startScopedViewTransition } from './view-transition';
export * from './with-unit';
83 changes: 83 additions & 0 deletions packages/frontend/component/src/utils/observe-intersection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
type ObserveIntersection = {
callback: (entity: IntersectionObserverEntry) => void;
dispose: () => void;
};

let _intersectionObserver: IntersectionObserver | null = null;
const elementsMap = new WeakMap<Element, Array<ObserveIntersection>>();

// for debugging
if (typeof window !== 'undefined') {
(window as any)._intersectionObserverElementsMap = elementsMap;
}

/**
* @internal get or initialize the IntersectionObserver instance
*/
const getIntersectionObserver = () =>
(_intersectionObserver ??= new IntersectionObserver(
entries => {
entries.forEach(entry => {
const listeners = elementsMap.get(entry.target) ?? [];
listeners.forEach(({ callback }) => callback(entry));
});
},
{
rootMargin: '20px 20px 20px 20px',
threshold: 0.233,
}
));

/**
* @internal remove element's specific listener
*/
const removeListener = (element: Element, listener: ObserveIntersection) => {
if (!element) return;
const listeners = elementsMap.get(element) ?? [];
const observer = getIntersectionObserver();
// remove the listener from the element
if (listeners.includes(listener)) {
elementsMap.set(
element,
listeners.filter(l => l !== listener)
);
}
// if no more listeners, unobserve the element
if (elementsMap.get(element)?.length === 0) {
observer.unobserve(element);
elementsMap.delete(element);
}
};

/**
* A function to observe the intersection of an element use global IntersectionObserver.
*
* ```ts
* useEffect(() => {
* const dispose1 = observeIntersection(elRef1.current, (entry) => {});
* const dispose2 = observeIntersection(elRef2.current, (entry) => {});
*
* return () => {
* dispose1();
* dispose2();
* };
* }, [])
* ```
* @return A function to dispose the observer.
*/
export const observeIntersection = (
element: Element,
callback: ObserveIntersection['callback']
) => {
const observer = getIntersectionObserver();
if (!elementsMap.has(element)) {
observer.observe(element);
}
const prevListeners = elementsMap.get(element) ?? [];
const listener = { callback, dispose: () => {} };
listener.dispose = () => removeListener(element, listener);

elementsMap.set(element, [...prevListeners, listener]);

return listener.dispose;
};
28 changes: 13 additions & 15 deletions packages/frontend/core/src/components/attachment-viewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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>
);
};
Expand All @@ -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} />
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import { IconButton, observeIntersection } from '@affine/component';
import {
type PDF,
type PDFPage,
PDFService,
PDFStatus,
} from '@affine/core/modules/pdf';
import { LoadingSvg, PDFPageCanvas } from '@affine/core/modules/pdf/views';
import { PeekViewService } from '@affine/core/modules/peek-view';
import { stopPropagation } from '@affine/core/utils';
import {
ArrowDownSmallIcon,
ArrowUpSmallIcon,
AttachmentIcon,
CenterPeekIcon,
} from '@blocksuite/icons/rc';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { debounce } from 'lodash-es';
import {
type MouseEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';

import type { PDFViewerProps } from './pdf-viewer';
import * as styles from './styles.css';
import * as embeddedStyles from './styles.embedded.css';

type PDFViewerEmbeddedInnerProps = PDFViewerProps;

export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) {
const peekView = useService(PeekViewService).peekView;
const pdfService = useService(PDFService);
const [pdfEntity, setPdfEntity] = useState<{
pdf: PDF;
release: () => void;
} | null>(null);
const [pageEntity, setPageEntity] = useState<{
page: PDFPage;
release: () => void;
} | null>(null);

const meta = useLiveData(
useMemo(() => {
return pdfEntity
? pdfEntity.pdf.state$.map(s => {
return s.status === PDFStatus.Opened
? s.meta
: { pageCount: 0, width: 0, height: 0 };
})
: new LiveData({ pageCount: 0, width: 0, height: 0 });
}, [pdfEntity])
);
const img = useLiveData(
useMemo(() => {
return pageEntity ? pageEntity.page.bitmap$ : null;
}, [pageEntity])
);

const [isLoading, setIsLoading] = useState(true);
const [cursor, setCursor] = useState(0);
const viewerRef = useRef<HTMLDivElement>(null);
const [visibility, setVisibility] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);

const peek = useCallback(() => {
const target = model.doc.getBlock(model.id);
if (!target) return;
peekView.open({ element: target }).catch(console.error);
}, [peekView, model]);

const navigator = useMemo(() => {
const p = cursor - 1;
const n = cursor + 1;

return {
prev: {
disabled: p < 0,
onClick: (e: MouseEvent) => {
e.stopPropagation();
setCursor(p);
},
},
next: {
disabled: n >= meta.pageCount,
onClick: (e: MouseEvent) => {
e.stopPropagation();
setCursor(n);
},
},
peek: {
onClick: (e: MouseEvent) => {
e.stopPropagation();
peek();
},
},
};
}, [cursor, meta, peek]);

useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
if (!img) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const { width, height } = meta;
if (width * height === 0) return;

setIsLoading(false);

canvas.width = width * 2;
canvas.height = height * 2;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
}, [img, meta]);

useEffect(() => {
if (!visibility) return;
if (!pageEntity) return;

const { width, height } = meta;
if (width * height === 0) return;

pageEntity.page.render({ width, height, scale: 2 });

return () => {
pageEntity.page.render.unsubscribe();
};
}, [visibility, pageEntity, meta]);

useEffect(() => {
if (!visibility) return;
if (!pdfEntity) return;

const { width, height } = meta;
if (width * height === 0) return;

const pageEntity = pdfEntity.pdf.page(cursor, `${width}:${height}:2`);

setPageEntity(pageEntity);

return () => {
pageEntity.release();
setPageEntity(null);
};
}, [visibility, pdfEntity, cursor, meta]);

useEffect(() => {
if (!visibility) return;

const pdfEntity = pdfService.get(model);

setPdfEntity(pdfEntity);

return () => {
pdfEntity.release();
setPdfEntity(null);
};
}, [model, pdfService, visibility]);

useEffect(() => {
const viewer = viewerRef.current;
if (!viewer) return;

return observeIntersection(
viewer,
debounce(
entry => {
setVisibility(entry.isIntersecting);
},
377,
{
trailing: true,
}
)
);
}, []);

return (
<div ref={viewerRef} className={embeddedStyles.pdfContainer}>
<main className={embeddedStyles.pdfViewer}>
<div
className={styles.pdfPage}
style={{
position: 'relative',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
minHeight: '759px',
}}
>
<PDFPageCanvas ref={canvasRef} />
<LoadingSvg
style={{
position: 'absolute',
visibility: isLoading ? 'visible' : 'hidden',
}}
/>
</div>

<div className={embeddedStyles.pdfControls}>
<IconButton
size={16}
icon={<ArrowUpSmallIcon />}
className={embeddedStyles.pdfControlButton}
onDoubleClick={stopPropagation}
{...navigator.prev}
/>
<IconButton
size={16}
icon={<ArrowDownSmallIcon />}
className={embeddedStyles.pdfControlButton}
onDoubleClick={stopPropagation}
{...navigator.next}
/>
<IconButton
size={16}
icon={<CenterPeekIcon />}
className={embeddedStyles.pdfControlButton}
onDoubleClick={stopPropagation}
{...navigator.peek}
/>
</div>
</main>
<footer className={embeddedStyles.pdfFooter}>
<div
className={clsx([embeddedStyles.pdfFooterItem, { truncate: true }])}
>
<AttachmentIcon />
<span className={embeddedStyles.pdfTitle}>{model.name}</span>
</div>
<div
className={clsx([
embeddedStyles.pdfFooterItem,
embeddedStyles.pdfPageCount,
])}
>
<span>{meta.pageCount > 0 ? cursor + 1 : '-'}</span>/
<span>{meta.pageCount > 0 ? meta.pageCount : '-'}</span>
</div>
</footer>
</div>
);
}
Loading
Loading