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 10, 2024
1 parent 3b60650 commit 1e59de5
Show file tree
Hide file tree
Showing 9 changed files with 444 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
import { IconButton } from '@affine/component';
import { type PDFChannel, PDFService } from '@affine/core/modules/pdf';
import { MessageOp, RenderKind } from '@affine/core/modules/pdf/workers/types';
import { PeekViewService } from '@affine/core/modules/peek-view';
import { stopPropagation } from '@affine/core/utils';
import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
import {
ArrowDownSmallIcon,
ArrowUpSmallIcon,
AttachmentIcon,
CenterPeekIcon,
} from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
type MouseEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useErrorBoundary } from 'react-error-boundary';

import loading from '../loading.svg';
import { getAttachmentBlob } from '../utils';
import * as styles from './styles.css';

type AttachmentEmbedProps = {
model: AttachmentBlockModel;
blobUrl?: string;
};

export function AttachmentEmbedWithPDF({ model }: AttachmentEmbedProps) {
const { showBoundary } = useErrorBoundary();
const pdfService = useService(PDFService);
const peekView = useService(PeekViewService).peekView;
const pageRef = useRef<HTMLDivElement | null>(null);
const [docInfo, setDocInfo] = useState({
total: 0,
width: 1,
height: 1,
});
const [channel, setChannel] = useState<PDFChannel | null>(null);
const [opened, setOpened] = useState(false);
const [cursor, setCursor] = useState(0);
const viewerRef = useRef<HTMLDivElement | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null);

const render = useCallback(
(
index: number,
kind: RenderKind,
width: number,
height: number,
buffer: Uint8ClampedArray
) => {
const el = pageRef.current;
if (!el) return;

let canvas = el.firstElementChild as HTMLCanvasElement | null;
if (!canvas) {
canvas = document.createElement('canvas');
el.append(canvas);
}

if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
canvas.style.width = '100%';
canvas.style.aspectRatio = `${width} / ${height}`;
}
canvas.dataset.index = `${index}`;
canvas.dataset.kind = `${kind}`;

const ctx = canvas.getContext('2d');
if (!ctx) return;

const imageData = new ImageData(buffer, width, height);

ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.putImageData(imageData, 0, 0);
},
[pageRef]
);

const goto = useCallback(
(index: number, scale = 1, kind = RenderKind.Page) => {
if (!channel) return;

setCursor(index);
channel.post(MessageOp.Render, {
index,
kind,
scale: scale * window.devicePixelRatio,
});
},
[channel]
);

const peek = useCallback(() => {
const target = viewerRef.current?.closest(
`affine-attachment[data-block-id="${model.id}"]`
);
if (!target) return;
peekView.open({ element: target as HTMLElement }).catch(console.error);
}, [viewerRef, peekView, model]);

const navigator = useMemo(() => {
const disconnected = !channel;
const s = 1;
const p = cursor - 1;
const n = cursor + 1;

return {
prev: {
disabled: disconnected || p < 0,
onClick: (e: MouseEvent) => {
e.stopPropagation();
goto(p, s);
},
},
next: {
disabled: disconnected || n >= docInfo.total,
onClick: (e: MouseEvent) => {
e.stopPropagation();
goto(n, s);
},
},
peek: {
onClick: (e: MouseEvent) => {
e.stopPropagation();
peek();
},
},
};
}, [cursor, docInfo, goto, peek, channel]);

const init = useCallback(() => {
const { worker, release } = pdfService.get(model.id);

const channel = worker.channel();
channel
.on(({ index, kind, height, width, buffer }) => {
render(index, kind, width, height, buffer);
})
.start();

const disposables = worker.on({
ready: () => {
if (worker.docInfo$.value.total) {
return;
}

getAttachmentBlob(model)
.then(blob => {
if (!blob) return;
return blob.arrayBuffer();
})
.then(buffer => {
if (!buffer) return;
worker.open(buffer);
})
.catch(showBoundary);
},

opened: () => {
if (worker.docInfo$.value.total) {
setDocInfo({ ...worker.docInfo$.value });
setOpened(true);
}
},
});

setChannel(channel);

return () => {
channel.dispose();
disposables[Symbol.dispose]();
release();
setChannel(null);
};
}, [showBoundary, render, pdfService, model]);

useEffect(() => {
const el = pageRef.current;
if (!el) return;

if (!channel) return;
if (!opened) return;

const canvas = el.firstElementChild as HTMLCanvasElement | null;
if (canvas) return;

goto(cursor);
}, [pageRef, channel, goto, opened, cursor]);

useLayoutEffect(() => {
const el = viewerRef.current;
if (!el) return;

let etid = 0;
let ltid = 0;
let release: () => void | undefined;

function enter() {
if (ltid) {
clearTimeout(ltid);
ltid = 0;
}
if (etid) {
clearTimeout(etid);
etid = 0;
}
etid = window.setTimeout(() => {
release = init();
}, 1000);
}

function leave() {
if (etid) {
clearTimeout(etid);
etid = 0;
}
ltid = window.setTimeout(() => {
release?.();
}, 1000);
}

el.addEventListener('pointerenter', enter);
el.addEventListener('pointerleave', leave);

return () => {
release?.();
el.removeEventListener('pointerleave', leave);
el.removeEventListener('pointerenter', enter);
};
}, [viewerRef, init]);

return (
<div ref={viewerRef} className={styles.embedWithPDF}>
<main className={styles.pdfMain}>
<img
ref={imgRef}
className={styles.pdfPlaceholder}
src={`${loading}?t=${model.id}`}
loading="lazy"
/>
<div
className={styles.pdfPage}
ref={pageRef}
style={{
width: `${docInfo.width}px`,
aspectRatio: `${docInfo.width} / ${docInfo.height}`,
}}
></div>
<div className={styles.pdfControls}>
<IconButton
size={16}
className={styles.pdfControlButton}
icon={<ArrowUpSmallIcon />}
onDoubleClick={stopPropagation}
{...navigator.prev}
/>
<IconButton
size={16}
className={styles.pdfControlButton}
icon={<ArrowDownSmallIcon />}
onDoubleClick={stopPropagation}
{...navigator.next}
/>
<IconButton
size={16}
className={styles.pdfControlButton}
icon={<CenterPeekIcon />}
onDoubleClick={stopPropagation}
{...navigator.peek}
/>
</div>
</main>
<footer className={styles.pdfFooter}>
<div className={clsx([styles.pdfFooterItem, { truncate: true }])}>
<AttachmentIcon />
<span className={styles.pdfTitle}>{model.name}</span>
</div>
<div className={clsx([styles.pdfFooterItem, styles.pdfPageCount])}>
<span>{docInfo.total > 0 ? cursor + 1 : '-'}</span>/
<span>{docInfo.total > 0 ? docInfo.total : '-'}</span>
</div>
</footer>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';

export const embedWithPDF = style({
width: '100%',
borderRadius: '8px',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: cssVarV2('layer/insideBorder/border'),
background: cssVar('--affine-background-primary-color'),
userSelect: 'none',
});

export const pdfMain = style({
position: 'relative',
minHeight: '160px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '12px',
overflow: 'hidden',
background: cssVarV2('layer/background/secondary'),
});

export const pdfPage = style({
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
maxWidth: 'calc(100% - 24px)',
minHeight: '595px',
});

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

0 comments on commit 1e59de5

Please sign in to comment.