Skip to content

Commit

Permalink
feat: pdf embed view component
Browse files Browse the repository at this point in the history
  • Loading branch information
fundon committed Nov 15, 2024
1 parent c712e87 commit e4393af
Show file tree
Hide file tree
Showing 18 changed files with 737 additions and 249 deletions.
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,260 @@
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(() => {
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(oldPageEntity => {
if (oldPageEntity) {
oldPageEntity.page.render.unsubscribe();
oldPageEntity.release();
}
return pageEntity;
});

return () => {
setPageEntity(oldPageEntity => {
if (oldPageEntity) {
oldPageEntity.page.render.unsubscribe();
oldPageEntity.release();
}
return null;
});
};
}, [visibility, setPageEntity, pdfEntity, cursor, 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(() => {
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 && pdfEntity) {
return;
}

if (visibility && !pdfEntity) {
const pdfEntity = pdfService.get(model);
setPdfEntity(pdfEntity);
return;
}

if (pdfEntity) {
pdfEntity.release();
setPdfEntity(null);
}
}, [model, pdfService, pdfEntity, 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

0 comments on commit e4393af

Please sign in to comment.