mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
feat(core): pdf preview (#8569)
Co-authored-by: forehalo <forehalo@gmail.com>
This commit is contained in:
@@ -39,7 +39,7 @@ consumer.register('subscribeStatus', (id: number) => {
|
||||
|
||||
// subscribe
|
||||
const client: OpClient<Ops>;
|
||||
client.subscribe('subscribeStatus', 123, {
|
||||
client.ob$('subscribeStatus', 123).subscribe({
|
||||
next: status => {
|
||||
ui.setServerStatus(status);
|
||||
},
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('op client', () => {
|
||||
|
||||
// @ts-expect-error internal api
|
||||
const subscriptions = ctx.producer.obs;
|
||||
ctx.producer.subscribe('sub', new Uint8Array([1, 2, 3]), ob);
|
||||
ctx.producer.ob$('sub', new Uint8Array([1, 2, 3])).subscribe(ob);
|
||||
|
||||
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
|
||||
{
|
||||
@@ -160,7 +160,7 @@ describe('op client', () => {
|
||||
error: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
};
|
||||
ctx.producer.subscribe('sub', new Uint8Array([1, 2, 3]), ob);
|
||||
ctx.producer.ob$('sub', new Uint8Array([1, 2, 3])).subscribe(ob);
|
||||
|
||||
expect(subscriptions.has('sub:2')).toBe(true);
|
||||
|
||||
@@ -179,29 +179,23 @@ describe('op client', () => {
|
||||
|
||||
it('should transfer transferables with subscribe op', async ctx => {
|
||||
const data = new Uint8Array([1, 2, 3]);
|
||||
const unsubscribe = ctx.producer.subscribe(
|
||||
'bin',
|
||||
transfer(data, [data.buffer]),
|
||||
{
|
||||
const sub = ctx.producer
|
||||
.ob$('bin', transfer(data, [data.buffer]))
|
||||
.subscribe({
|
||||
next: vi.fn(),
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
expect(data.byteLength).toBe(0);
|
||||
|
||||
unsubscribe();
|
||||
sub.unsubscribe();
|
||||
});
|
||||
|
||||
it('should unsubscribe subscription op', ctx => {
|
||||
const unsubscribe = ctx.producer.subscribe(
|
||||
'sub',
|
||||
new Uint8Array([1, 2, 3]),
|
||||
{
|
||||
next: vi.fn(),
|
||||
}
|
||||
);
|
||||
const sub = ctx.producer.ob$('sub', new Uint8Array([1, 2, 3])).subscribe({
|
||||
next: vi.fn(),
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
sub.unsubscribe();
|
||||
|
||||
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
|
||||
[
|
||||
|
||||
@@ -22,7 +22,7 @@ interface PendingCall extends PromiseWithResolvers<any> {
|
||||
timeout: number | NodeJS.Timeout;
|
||||
}
|
||||
|
||||
interface OpClientOptions {
|
||||
export interface OpClientOptions {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
@@ -155,15 +155,11 @@ export class OpClient<Ops extends OpSchema> extends AutoMessageHandler {
|
||||
return promise;
|
||||
}
|
||||
|
||||
subscribe<Op extends OpNames<Ops>, Out extends OpOutput<Ops, Op>>(
|
||||
ob$<Op extends OpNames<Ops>, Out extends OpOutput<Ops, Op>>(
|
||||
op: Op,
|
||||
...args: [
|
||||
...OpInput<Ops, Op>,
|
||||
Partial<Observer<Out>> | ((value: Out) => void),
|
||||
]
|
||||
): () => void {
|
||||
...args: OpInput<Ops, Op>
|
||||
): Observable<Out> {
|
||||
const payload = args[0];
|
||||
const observer = args[1] as Partial<Observer<Out>> | ((value: Out) => void);
|
||||
|
||||
const msg = {
|
||||
type: 'subscribe',
|
||||
@@ -172,24 +168,23 @@ export class OpClient<Ops extends OpSchema> extends AutoMessageHandler {
|
||||
payload,
|
||||
} satisfies SubscribeMessage;
|
||||
|
||||
const sub = new Observable<Out>(ob => {
|
||||
const sub$ = new Observable<Out>(ob => {
|
||||
this.obs.set(msg.id, ob);
|
||||
}).subscribe(observer);
|
||||
|
||||
sub.add(() => {
|
||||
this.obs.delete(msg.id);
|
||||
this.port.postMessage({
|
||||
type: 'unsubscribe',
|
||||
id: msg.id,
|
||||
} satisfies UnsubscribeMessage);
|
||||
return () => {
|
||||
ob.complete();
|
||||
this.obs.delete(msg.id);
|
||||
this.port.postMessage({
|
||||
type: 'unsubscribe',
|
||||
id: msg.id,
|
||||
} satisfies UnsubscribeMessage);
|
||||
};
|
||||
});
|
||||
|
||||
const transferables = fetchTransferables(payload);
|
||||
this.port.postMessage(msg, { transfer: transferables });
|
||||
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
return sub$;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import EventEmitter2 from 'eventemitter2';
|
||||
import {
|
||||
defer,
|
||||
from,
|
||||
fromEvent,
|
||||
Observable,
|
||||
of,
|
||||
share,
|
||||
take,
|
||||
takeUntil,
|
||||
} from 'rxjs';
|
||||
import { defer, from, fromEvent, Observable, of, take, takeUntil } from 'rxjs';
|
||||
|
||||
import {
|
||||
AutoMessageHandler,
|
||||
@@ -172,7 +163,7 @@ export class OpConsumer<Ops extends OpSchema> extends AutoMessageHandler {
|
||||
ob$ = of(ret$);
|
||||
}
|
||||
|
||||
return ob$.pipe(share(), takeUntil(fromEvent(signal, 'abort')));
|
||||
return ob$.pipe(takeUntil(fromEvent(signal, 'abort')));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ export type MessageCommunicapable = Pick<
|
||||
> & {
|
||||
start?(): void;
|
||||
close?(): void;
|
||||
terminate?(): void; // For Worker
|
||||
};
|
||||
|
||||
export function ignoreUnknownEvent(handler: (data: Messages) => void) {
|
||||
@@ -130,6 +131,7 @@ export function fetchTransferables(data: any): Transferable[] | undefined {
|
||||
}
|
||||
|
||||
export abstract class AutoMessageHandler {
|
||||
private listening = false;
|
||||
protected abstract handlers: Partial<MessageHandlers>;
|
||||
|
||||
constructor(protected readonly port: MessageCommunicapable) {}
|
||||
@@ -144,12 +146,21 @@ export abstract class AutoMessageHandler {
|
||||
});
|
||||
|
||||
listen() {
|
||||
if (this.listening) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.port.addEventListener('message', this.handleMessage);
|
||||
this.port.addEventListener('messageerror', console.error);
|
||||
this.port.start?.();
|
||||
this.listening = true;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.port.close?.();
|
||||
this.port.terminate?.(); // For Worker
|
||||
this.port.removeEventListener('message', this.handleMessage);
|
||||
this.port.removeEventListener('messageerror', console.error);
|
||||
this.listening = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export const workbenchViewIconNameSchema = z.enum([
|
||||
'page',
|
||||
'edgeless',
|
||||
'journal',
|
||||
'attachment',
|
||||
'pdf',
|
||||
]);
|
||||
|
||||
export const workbenchViewMetaSchema = z.object({
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@affine/i18n": "workspace:*",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
|
||||
"@blocksuite/icons": "2.1.69",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-toolbar": "^1.0.4",
|
||||
"@sentry/react": "^8.0.0",
|
||||
"@toeverything/pdf-viewer": "^0.1.1",
|
||||
"@toeverything/theme": "^1.0.17",
|
||||
"@vanilla-extract/dynamic": "^2.1.0",
|
||||
"animejs": "^3.2.2",
|
||||
@@ -45,6 +46,7 @@
|
||||
"core-js": "^3.36.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"file-type": "^19.1.0",
|
||||
"filesize": "^10.1.6",
|
||||
"foxact": "^0.2.33",
|
||||
"fuse.js": "^7.0.0",
|
||||
"graphemer": "^1.4.0",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
|
||||
import { ArrowDownBigIcon } from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
import { download } from './utils';
|
||||
|
||||
// https://github.com/toeverything/blocksuite/blob/master/packages/affine/components/src/icons/file-icons.ts
|
||||
// TODO: should move file icons to icons repo
|
||||
const FileIcon = () => (
|
||||
<svg
|
||||
width="96"
|
||||
height="96"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.80781 1.875L10.8332 1.875C10.9989 1.875 11.1579 1.94085 11.2751 2.05806L16.2751 7.05806C16.3923 7.17527 16.4582 7.33424 16.4582 7.5V14.8587C16.4582 15.3038 16.4582 15.6754 16.4334 15.9789C16.4075 16.2955 16.3516 16.5927 16.2084 16.8737C15.9887 17.3049 15.6381 17.6555 15.2069 17.8752C14.9258 18.0184 14.6286 18.0744 14.3121 18.1002C14.0085 18.125 13.637 18.125 13.1919 18.125H6.80779C6.36267 18.125 5.99114 18.125 5.68761 18.1002C5.37104 18.0744 5.07383 18.0184 4.79278 17.8752C4.36157 17.6555 4.01099 17.3049 3.79128 16.8737C3.64808 16.5927 3.59215 16.2955 3.56629 15.9789C3.54149 15.6754 3.5415 15.3038 3.5415 14.8587V5.1413C3.5415 4.69618 3.54149 4.32464 3.56629 4.02111C3.59215 3.70454 3.64808 3.40732 3.79128 3.12627C4.01099 2.69507 4.36157 2.34449 4.79278 2.12478C5.07383 1.98157 5.37104 1.92565 5.68761 1.89978C5.99114 1.87498 6.36268 1.87499 6.80781 1.875ZM5.7894 3.14563C5.55013 3.16518 5.43573 3.20008 5.36026 3.23854C5.16426 3.3384 5.00491 3.49776 4.90504 3.69376C4.86659 3.76923 4.83168 3.88363 4.81214 4.1229C4.79199 4.36946 4.7915 4.68964 4.7915 5.16667V14.8333C4.7915 15.3104 4.79199 15.6305 4.81214 15.8771C4.83168 16.1164 4.86659 16.2308 4.90504 16.3062C5.00491 16.5022 5.16426 16.6616 5.36026 16.7615C5.43573 16.7999 5.55013 16.8348 5.7894 16.8544C6.03597 16.8745 6.35615 16.875 6.83317 16.875H13.1665C13.6435 16.875 13.9637 16.8745 14.2103 16.8544C14.4495 16.8348 14.5639 16.7999 14.6394 16.7615C14.8354 16.6616 14.9948 16.5022 15.0946 16.3062C15.1331 16.2308 15.168 16.1164 15.1875 15.8771C15.2077 15.6305 15.2082 15.3104 15.2082 14.8333V8.125H11.6665C10.8611 8.125 10.2082 7.47208 10.2082 6.66667V3.125H6.83317C6.35615 3.125 6.03597 3.12549 5.7894 3.14563ZM11.4582 4.00888L14.3243 6.875H11.6665C11.5514 6.875 11.4582 6.78173 11.4582 6.66667V4.00888Z"
|
||||
fill="#77757D"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PDFFileIcon = () => (
|
||||
<svg
|
||||
width="96"
|
||||
height="96"
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.75 4C7.75 2.20508 9.20508 0.75 11 0.75H27C27.1212 0.75 27.2375 0.798159 27.3232 0.883885L38.1161 11.6768C38.2018 11.7625 38.25 11.8788 38.25 12V36C38.25 37.7949 36.7949 39.25 35 39.25H11C9.20507 39.25 7.75 37.7949 7.75 36V4Z"
|
||||
fill="white"
|
||||
stroke="#D0D5DD"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M27 0.5V8C27 10.2091 28.7909 12 31 12H38.5"
|
||||
stroke="#D0D5DD"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<rect x="1" y="18" width="26" height="16" rx="2" fill="#D92D20" />
|
||||
<path
|
||||
d="M4.8323 30V22.7273H7.70162C8.25323 22.7273 8.72316 22.8326 9.11142 23.0433C9.49967 23.2517 9.7956 23.5417 9.9992 23.9134C10.2052 24.2827 10.3082 24.7088 10.3082 25.1918C10.3082 25.6747 10.204 26.1009 9.99565 26.4702C9.78732 26.8395 9.48547 27.1271 9.09011 27.3331C8.69712 27.5391 8.22127 27.642 7.66255 27.642H5.83372V26.4098H7.41397C7.7099 26.4098 7.95375 26.3589 8.14551 26.2571C8.33964 26.1529 8.48405 26.0097 8.57875 25.8274C8.67581 25.6428 8.72434 25.4309 8.72434 25.1918C8.72434 24.9503 8.67581 24.7396 8.57875 24.5597C8.48405 24.3774 8.33964 24.2365 8.14551 24.1371C7.95138 24.0353 7.70517 23.9844 7.40687 23.9844H6.36994V30H4.8323ZM13.885 30H11.3069V22.7273H13.9063C14.6379 22.7273 15.2676 22.8729 15.7955 23.1641C16.3235 23.4529 16.7295 23.8684 17.0136 24.4105C17.3 24.9527 17.4433 25.6013 17.4433 26.3565C17.4433 27.1141 17.3 27.7652 17.0136 28.3097C16.7295 28.8542 16.3211 29.272 15.7884 29.5632C15.2581 29.8544 14.6237 30 13.885 30ZM12.8445 28.6825H13.8211C14.2757 28.6825 14.658 28.602 14.9681 28.4411C15.2806 28.2777 15.515 28.0256 15.6713 27.6847C15.8299 27.3414 15.9092 26.8987 15.9092 26.3565C15.9092 25.8191 15.8299 25.38 15.6713 25.0391C15.515 24.6982 15.2818 24.4472 14.9717 24.2862C14.6615 24.1252 14.2792 24.0447 13.8247 24.0447H12.8445V28.6825ZM18.5823 30V22.7273H23.3976V23.995H20.1199V25.728H23.078V26.9957H20.1199V30H18.5823Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FILE_ICONS: Record<string, () => ReactElement> = {
|
||||
'application/pdf': PDFFileIcon,
|
||||
};
|
||||
|
||||
interface ErrorBaseProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: ReactElement;
|
||||
buttons?: ReactElement[];
|
||||
}
|
||||
|
||||
export const ErrorBase = ({
|
||||
title,
|
||||
subtitle,
|
||||
icon = <FileIcon />,
|
||||
buttons = [],
|
||||
}: ErrorBaseProps) => {
|
||||
return (
|
||||
<div className={clsx([styles.viewer, styles.error])}>
|
||||
{icon}
|
||||
<h3 className={styles.errorTitle}>{title}</h3>
|
||||
<p className={styles.errorMessage}>{subtitle}</p>
|
||||
<div className={styles.errorBtns}>{buttons}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ErrorProps {
|
||||
model: AttachmentBlockModel;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
export const Error = ({ model, ext }: ErrorProps) => {
|
||||
const t = useI18n();
|
||||
const Icon = FILE_ICONS[model.type] ?? FileIcon;
|
||||
const title = t['com.affine.attachment.preview.error.title']();
|
||||
const subtitle = `.${ext} ${t['com.affine.attachment.preview.error.subtitle']()}`;
|
||||
|
||||
return (
|
||||
<ErrorBase
|
||||
icon={<Icon />}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
buttons={[
|
||||
<Button
|
||||
key="download"
|
||||
variant="primary"
|
||||
prefix={<ArrowDownBigIcon />}
|
||||
onClick={() => {
|
||||
download(model).catch(console.error);
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorBoundaryInner = (props: FallbackProps): ReactElement => {
|
||||
const t = useI18n();
|
||||
const title = t['com.affine.attachment.preview.error.title']();
|
||||
const subtitle = `${props.error}`;
|
||||
return <ErrorBase title={title} subtitle={subtitle} />;
|
||||
};
|
||||
|
||||
export const AttachmentPreviewErrorBoundary = (props: PropsWithChildren) => {
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorBoundaryInner}>
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
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 * as styles from './styles.css';
|
||||
import { Titlebar } from './titlebar';
|
||||
import { buildAttachmentProps } from './utils';
|
||||
|
||||
export type AttachmentViewerProps = {
|
||||
model: AttachmentBlockModel;
|
||||
};
|
||||
|
||||
// In Peek view
|
||||
export const AttachmentViewer = ({ model }: AttachmentViewerProps) => {
|
||||
const props = buildAttachmentProps(model);
|
||||
|
||||
return (
|
||||
<div className={styles.viewerContainer}>
|
||||
<Titlebar {...props} />
|
||||
{model.type.endsWith('pdf') ? (
|
||||
<AttachmentPreviewErrorBoundary>
|
||||
<PDFViewer {...props} />
|
||||
</AttachmentPreviewErrorBoundary>
|
||||
) : (
|
||||
<Error {...props} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// In View container
|
||||
export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => {
|
||||
const props = buildAttachmentProps(model);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewHeader>
|
||||
<Titlebar {...props} />
|
||||
</ViewHeader>
|
||||
<ViewBody>
|
||||
{model.type.endsWith('pdf') ? (
|
||||
<AttachmentPreviewErrorBoundary>
|
||||
<PDFViewer {...props} />
|
||||
</AttachmentPreviewErrorBoundary>
|
||||
) : (
|
||||
<Error {...props} />
|
||||
)}
|
||||
</ViewBody>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
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,
|
||||
} from '@affine/core/modules/pdf/views';
|
||||
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 { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
import { calculatePageNum } from './utils';
|
||||
|
||||
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]);
|
||||
|
||||
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,
|
||||
}}
|
||||
context={{
|
||||
width: state.meta.width,
|
||||
height: state.meta.height,
|
||||
pageClassName: styles.pdfPage,
|
||||
}}
|
||||
/>
|
||||
<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,
|
||||
Scroller,
|
||||
List: ListWithSmallGap,
|
||||
}}
|
||||
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$);
|
||||
|
||||
if (state?.status !== PDFStatus.Opened) {
|
||||
return <LoadingSvg />;
|
||||
}
|
||||
|
||||
return <PDFViewerInner pdf={pdf} state={state} />;
|
||||
}
|
||||
|
||||
export function PDFViewer({ model }: ViewerProps) {
|
||||
const pdfService = useService(PDFService);
|
||||
const [pdf, setPdf] = useState<PDF | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const { pdf, release } = pdfService.get(model);
|
||||
setPdf(pdf);
|
||||
|
||||
return release;
|
||||
}, [model, pdfService, setPdf]);
|
||||
|
||||
if (!pdf) {
|
||||
return <LoadingSvg />;
|
||||
}
|
||||
|
||||
return <PDFViewerStatus pdf={pdf} />;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const viewerContainer = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
export const titlebar = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
height: '52px',
|
||||
padding: '10px 8px',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
fontSize: '12px',
|
||||
fontWeight: 400,
|
||||
color: cssVarV2('text/secondary'),
|
||||
borderTopWidth: '0.5px',
|
||||
borderTopStyle: 'solid',
|
||||
borderTopColor: cssVarV2('layer/insideBorder/border'),
|
||||
textWrap: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const titlebarChild = style({
|
||||
overflow: 'hidden',
|
||||
selectors: {
|
||||
[`${titlebar} > &`]: {
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '12px',
|
||||
},
|
||||
'&.zoom:not(.show)': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const titlebarName = style({
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'pre',
|
||||
wordWrap: 'break-word',
|
||||
});
|
||||
|
||||
export const error = style({
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '4px',
|
||||
});
|
||||
|
||||
export const errorTitle = style({
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
lineHeight: '24px',
|
||||
color: cssVarV2('text/primary'),
|
||||
marginTop: '12px',
|
||||
});
|
||||
|
||||
export const errorMessage = style({
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/tertiary'),
|
||||
});
|
||||
|
||||
export const errorBtns = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
marginTop: '28px',
|
||||
});
|
||||
|
||||
export const viewer = style({
|
||||
position: 'relative',
|
||||
zIndex: 0,
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
resize: 'both',
|
||||
selectors: {
|
||||
'&:before': {
|
||||
position: 'absolute',
|
||||
content: '',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: -1,
|
||||
},
|
||||
'&:not(.gridding):before': {
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
},
|
||||
'&.gridding:before': {
|
||||
opacity: 0.25,
|
||||
backgroundSize: '20px 20px',
|
||||
backgroundImage: `linear-gradient(${cssVarV2('button/grabber/default')} 1px, transparent 1px), linear-gradient(to right, ${cssVarV2('button/grabber/default')} 1px, transparent 1px)`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const virtuoso = style({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const pdfIndicator = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 12px',
|
||||
});
|
||||
|
||||
export const pdfPage = style({
|
||||
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))',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const pdfThumbnails = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'absolute',
|
||||
boxSizing: 'border-box',
|
||||
width: '120px',
|
||||
padding: '12px 0',
|
||||
right: '30px',
|
||||
bottom: '30px',
|
||||
maxHeight: 'calc(100% - 60px)',
|
||||
borderRadius: '8px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
backgroundColor: cssVarV2('layer/background/primary'),
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
export const pdfThumbnailsList = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: '100%',
|
||||
overflow: 'hidden',
|
||||
resize: 'both',
|
||||
selectors: {
|
||||
'&.collapsed': {
|
||||
display: 'none',
|
||||
},
|
||||
'&:not(.collapsed)': {
|
||||
marginBottom: '8px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const pdfThumbnail = style({
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
// width: '100%',
|
||||
borderRadius: '4px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
selectors: {
|
||||
'&.selected': {
|
||||
borderColor: '#29A3FA',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { IconButton, Menu, MenuItem } from '@affine/component';
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
|
||||
import {
|
||||
//EditIcon,
|
||||
LocalDataIcon,
|
||||
MoreHorizontalIcon,
|
||||
ZoomDownIcon,
|
||||
ZoomUpIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
import { download } from './utils';
|
||||
|
||||
const items = [
|
||||
/*
|
||||
{
|
||||
name: 'Rename',
|
||||
icon: <EditIcon />,
|
||||
action(_model: AttachmentBlockModel) {},
|
||||
},
|
||||
*/
|
||||
{
|
||||
name: 'Download',
|
||||
icon: <LocalDataIcon />,
|
||||
action: download,
|
||||
},
|
||||
];
|
||||
|
||||
export const MenuItems = ({ model }: { model: AttachmentBlockModel }) =>
|
||||
items.map(({ name, icon, action }) => (
|
||||
<MenuItem
|
||||
key={name}
|
||||
onClick={() => {
|
||||
action(model).catch(console.error);
|
||||
}}
|
||||
prefixIcon={icon}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
));
|
||||
|
||||
export interface TitlebarProps {
|
||||
model: AttachmentBlockModel;
|
||||
name: string;
|
||||
ext: string;
|
||||
size: string;
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
export const Titlebar = ({
|
||||
model,
|
||||
name,
|
||||
ext,
|
||||
size,
|
||||
zoom = 100,
|
||||
}: TitlebarProps) => {
|
||||
const [openMenu, setOpenMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={styles.titlebar}>
|
||||
<div className={styles.titlebarChild}>
|
||||
<div className={styles.titlebarName}>
|
||||
<div>{name}</div>
|
||||
<span>.{ext}</span>
|
||||
</div>
|
||||
<div>{size}</div>
|
||||
<IconButton
|
||||
icon={<LocalDataIcon />}
|
||||
onClick={() => {
|
||||
download(model).catch(console.error);
|
||||
}}
|
||||
></IconButton>
|
||||
<Menu
|
||||
items={<MenuItems model={model} />}
|
||||
rootOptions={{
|
||||
open: openMenu,
|
||||
onOpenChange: setOpenMenu,
|
||||
}}
|
||||
contentOptions={{
|
||||
side: 'bottom',
|
||||
align: 'center',
|
||||
avoidCollisions: false,
|
||||
}}
|
||||
>
|
||||
<IconButton icon={<MoreHorizontalIcon />}></IconButton>
|
||||
</Menu>
|
||||
</div>
|
||||
<div
|
||||
className={clsx([
|
||||
styles.titlebarChild,
|
||||
'zoom',
|
||||
{
|
||||
show: false,
|
||||
},
|
||||
])}
|
||||
>
|
||||
<IconButton icon={<ZoomDownIcon />}></IconButton>
|
||||
<div>{zoom}%</div>
|
||||
<IconButton icon={<ZoomUpIcon />}></IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
|
||||
import { filesize } from 'filesize';
|
||||
|
||||
import { downloadBlob } from '../../utils/resource';
|
||||
|
||||
export async function getAttachmentBlob(model: AttachmentBlockModel) {
|
||||
const sourceId = model.sourceId;
|
||||
if (!sourceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = model.doc;
|
||||
let blob = await doc.blobSync.get(sourceId);
|
||||
|
||||
if (blob) {
|
||||
blob = new Blob([blob], { type: model.type });
|
||||
}
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
export async function download(model: AttachmentBlockModel) {
|
||||
const blob = await getAttachmentBlob(model);
|
||||
if (!blob) return;
|
||||
|
||||
await downloadBlob(blob, model.name);
|
||||
}
|
||||
|
||||
export function buildAttachmentProps(model: AttachmentBlockModel) {
|
||||
const pieces = model.name.split('.');
|
||||
const ext = pieces.pop() || '';
|
||||
const name = pieces.join('.');
|
||||
const size = filesize(model.size);
|
||||
return { model, name, ext, size };
|
||||
}
|
||||
|
||||
export function calculatePageNum(el: HTMLElement, pageCount: number) {
|
||||
const { scrollTop, scrollHeight } = el;
|
||||
const pageHeight = scrollHeight / pageCount;
|
||||
const n = scrollTop / pageHeight;
|
||||
const t = n / pageCount;
|
||||
const index = Math.floor(n + t);
|
||||
const cursor = Math.min(index, pageCount - 1);
|
||||
return cursor;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const attachmentSkeletonStyle = style({
|
||||
margin: '20px',
|
||||
});
|
||||
|
||||
export const attachmentSkeletonItemStyle = style({
|
||||
marginTop: '20px',
|
||||
marginBottom: '20px',
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Skeleton } from '@affine/component';
|
||||
import { type AttachmentBlockModel } from '@blocksuite/affine/blocks';
|
||||
import {
|
||||
type Doc,
|
||||
DocsService,
|
||||
FrameworkScope,
|
||||
useLiveData,
|
||||
useService,
|
||||
} from '@toeverything/infra';
|
||||
import { type ReactElement, useLayoutEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { AttachmentViewerView } from '../../../../components/attachment-viewer';
|
||||
import { ViewIcon, ViewTitle } from '../../../../modules/workbench';
|
||||
import { PageNotFound } from '../../404';
|
||||
import * as styles from './index.css';
|
||||
|
||||
type AttachmentPageProps = {
|
||||
pageId: string;
|
||||
attachmentId: string;
|
||||
};
|
||||
|
||||
const useLoadAttachment = (pageId: string, attachmentId: string) => {
|
||||
const docsService = useService(DocsService);
|
||||
const docRecord = useLiveData(docsService.list.doc$(pageId));
|
||||
const [doc, setDoc] = useState<Doc | null>(null);
|
||||
const [model, setModel] = useState<AttachmentBlockModel | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!docRecord) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { doc, release } = docsService.open(pageId);
|
||||
|
||||
setDoc(doc);
|
||||
|
||||
if (!doc.blockSuiteDoc.ready) {
|
||||
doc.blockSuiteDoc.load();
|
||||
}
|
||||
doc.setPriorityLoad(10);
|
||||
|
||||
doc
|
||||
.waitForSyncReady()
|
||||
.then(() => {
|
||||
const block = doc.blockSuiteDoc.getBlock(attachmentId);
|
||||
if (block) {
|
||||
setModel(block.model as AttachmentBlockModel);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
return () => {
|
||||
release();
|
||||
};
|
||||
}, [docRecord, docsService, pageId, attachmentId]);
|
||||
|
||||
return { doc, model };
|
||||
};
|
||||
|
||||
export const AttachmentPage = ({
|
||||
pageId,
|
||||
attachmentId,
|
||||
}: AttachmentPageProps): ReactElement => {
|
||||
const { doc, model } = useLoadAttachment(pageId, attachmentId);
|
||||
|
||||
if (!doc) {
|
||||
return <PageNotFound noPermission={false} />;
|
||||
}
|
||||
|
||||
if (doc && model) {
|
||||
return (
|
||||
<FrameworkScope scope={doc.scope}>
|
||||
<ViewTitle title={model.name} />
|
||||
<ViewIcon icon={model.type.endsWith('pdf') ? 'pdf' : 'attachment'} />
|
||||
<AttachmentViewerView model={model} />
|
||||
</FrameworkScope>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.attachmentSkeletonStyle}>
|
||||
<Skeleton
|
||||
className={styles.attachmentSkeletonItemStyle}
|
||||
animation="wave"
|
||||
height={30}
|
||||
/>
|
||||
<Skeleton
|
||||
className={styles.attachmentSkeletonItemStyle}
|
||||
animation="wave"
|
||||
height={30}
|
||||
width="80%"
|
||||
/>
|
||||
<Skeleton
|
||||
className={styles.attachmentSkeletonItemStyle}
|
||||
animation="wave"
|
||||
height={30}
|
||||
/>
|
||||
<Skeleton
|
||||
className={styles.attachmentSkeletonItemStyle}
|
||||
animation="wave"
|
||||
height={30}
|
||||
width="70%"
|
||||
/>
|
||||
<Skeleton
|
||||
className={styles.attachmentSkeletonItemStyle}
|
||||
animation="wave"
|
||||
height={30}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Component = () => {
|
||||
const { pageId, attachmentId } = useParams();
|
||||
|
||||
if (!pageId || !attachmentId) {
|
||||
return <PageNotFound noPermission />;
|
||||
}
|
||||
|
||||
return <AttachmentPage pageId={pageId} attachmentId={attachmentId} />;
|
||||
};
|
||||
@@ -29,6 +29,10 @@ export const workbenchRoutes = [
|
||||
path: '/:pageId',
|
||||
lazy: () => import('./pages/workspace/detail-page/detail-page'),
|
||||
},
|
||||
{
|
||||
path: '/:pageId/attachments/:attachmentId',
|
||||
lazy: () => import('./pages/workspace/attachment/index'),
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
lazy: () => import('./pages/404'),
|
||||
|
||||
@@ -19,6 +19,7 @@ import { configureJournalModule } from './journal';
|
||||
import { configureNavigationModule } from './navigation';
|
||||
import { configureOpenInApp } from './open-in-app';
|
||||
import { configureOrganizeModule } from './organize';
|
||||
import { configurePDFModule } from './pdf';
|
||||
import { configurePeekViewModule } from './peek-view';
|
||||
import { configurePermissionsModule } from './permissions';
|
||||
import { configureQuickSearchModule } from './quicksearch';
|
||||
@@ -44,6 +45,7 @@ export function configureCommonModules(framework: Framework) {
|
||||
configureShareDocsModule(framework);
|
||||
configureShareSettingModule(framework);
|
||||
configureTelemetryModule(framework);
|
||||
configurePDFModule(framework);
|
||||
configurePeekViewModule(framework);
|
||||
configureDocDisplayMetaModule(framework);
|
||||
configureQuickSearchModule(framework);
|
||||
|
||||
39
packages/frontend/core/src/modules/pdf/entities/pdf-page.ts
Normal file
39
packages/frontend/core/src/modules/pdf/entities/pdf-page.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import {
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
LiveData,
|
||||
mapInto,
|
||||
} from '@toeverything/infra';
|
||||
import { map, switchMap } from 'rxjs';
|
||||
|
||||
import type { RenderPageOpts } from '../renderer';
|
||||
import type { PDF } from './pdf';
|
||||
|
||||
const logger = new DebugLogger('affine:pdf:page:render');
|
||||
|
||||
export class PDFPage extends Entity<{ pdf: PDF; pageNum: number }> {
|
||||
readonly pageNum: number = this.props.pageNum;
|
||||
bitmap$ = new LiveData<ImageBitmap | null>(null);
|
||||
error$ = new LiveData<any>(null);
|
||||
|
||||
render = effect(
|
||||
switchMap((opts: Omit<RenderPageOpts, 'pageNum'>) =>
|
||||
this.props.pdf.renderer.ob$('render', {
|
||||
...opts,
|
||||
pageNum: this.pageNum,
|
||||
})
|
||||
),
|
||||
map(data => data.bitmap),
|
||||
mapInto(this.bitmap$),
|
||||
catchErrorInto(this.error$, error => {
|
||||
logger.error('Failed to render page', error);
|
||||
})
|
||||
);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.disposables.push(() => this.render.unsubscribe);
|
||||
}
|
||||
}
|
||||
74
packages/frontend/core/src/modules/pdf/entities/pdf.ts
Normal file
74
packages/frontend/core/src/modules/pdf/entities/pdf.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
|
||||
import { Entity, LiveData, ObjectPool } from '@toeverything/infra';
|
||||
import { catchError, from, map, of, startWith, switchMap } from 'rxjs';
|
||||
|
||||
import type { PDFMeta } from '../renderer';
|
||||
import { downloadBlobToBuffer, PDFRenderer } from '../renderer';
|
||||
import { PDFPage } from './pdf-page';
|
||||
|
||||
export enum PDFStatus {
|
||||
IDLE = 0,
|
||||
Opening,
|
||||
Opened,
|
||||
Error,
|
||||
}
|
||||
|
||||
export type PDFRendererState =
|
||||
| {
|
||||
status: PDFStatus.IDLE | PDFStatus.Opening;
|
||||
}
|
||||
| {
|
||||
status: PDFStatus.Opened;
|
||||
meta: PDFMeta;
|
||||
}
|
||||
| {
|
||||
status: PDFStatus.Error;
|
||||
error: Error;
|
||||
};
|
||||
|
||||
export class PDF extends Entity<AttachmentBlockModel> {
|
||||
public readonly id: string = this.props.id;
|
||||
readonly renderer = new PDFRenderer();
|
||||
readonly pages = new ObjectPool<string, PDFPage>({
|
||||
onDelete: page => page.dispose(),
|
||||
});
|
||||
|
||||
readonly state$ = LiveData.from<PDFRendererState>(
|
||||
// @ts-expect-error type alias
|
||||
from(downloadBlobToBuffer(this.props)).pipe(
|
||||
switchMap(buffer => {
|
||||
return this.renderer.ob$('open', { data: buffer });
|
||||
}),
|
||||
map(meta => ({ status: PDFStatus.Opened, meta })),
|
||||
// @ts-expect-error type alias
|
||||
startWith({ status: PDFStatus.Opening }),
|
||||
catchError((error: Error) => of({ status: PDFStatus.Error, error }))
|
||||
),
|
||||
{ status: PDFStatus.IDLE }
|
||||
);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.renderer.listen();
|
||||
this.disposables.push(() => this.pages.clear());
|
||||
}
|
||||
|
||||
page(pageNum: number, size: string) {
|
||||
const key = `${pageNum}:${size}`;
|
||||
let rc = this.pages.get(key);
|
||||
|
||||
if (!rc) {
|
||||
rc = this.pages.put(
|
||||
key,
|
||||
this.framework.createEntity(PDFPage, { pdf: this, pageNum })
|
||||
);
|
||||
}
|
||||
|
||||
return { page: rc.obj, release: rc.release };
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
this.renderer.destroy();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
19
packages/frontend/core/src/modules/pdf/index.ts
Normal file
19
packages/frontend/core/src/modules/pdf/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Framework } from '@toeverything/infra';
|
||||
import { WorkspaceScope } from '@toeverything/infra';
|
||||
|
||||
import { PDF } from './entities/pdf';
|
||||
import { PDFPage } from './entities/pdf-page';
|
||||
import { PDFService } from './services/pdf';
|
||||
|
||||
export function configurePDFModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(PDFService)
|
||||
.entity(PDF)
|
||||
.entity(PDFPage);
|
||||
}
|
||||
|
||||
export { PDF, type PDFRendererState, PDFStatus } from './entities/pdf';
|
||||
export { PDFPage } from './entities/pdf-page';
|
||||
export { PDFRenderer } from './renderer';
|
||||
export { PDFService } from './services/pdf';
|
||||
3
packages/frontend/core/src/modules/pdf/renderer/index.ts
Normal file
3
packages/frontend/core/src/modules/pdf/renderer/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { PDFRenderer } from './renderer';
|
||||
export type { PDFMeta, RenderedPage, RenderPageOpts } from './types';
|
||||
export { downloadBlobToBuffer } from './utils';
|
||||
8
packages/frontend/core/src/modules/pdf/renderer/ops.ts
Normal file
8
packages/frontend/core/src/modules/pdf/renderer/ops.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { OpSchema } from '@toeverything/infra/op';
|
||||
|
||||
import type { PDFMeta, RenderedPage, RenderPageOpts } from './types';
|
||||
|
||||
export interface ClientOps extends OpSchema {
|
||||
open: [{ data: ArrayBuffer }, PDFMeta];
|
||||
render: [RenderPageOpts, RenderedPage];
|
||||
}
|
||||
28
packages/frontend/core/src/modules/pdf/renderer/renderer.ts
Normal file
28
packages/frontend/core/src/modules/pdf/renderer/renderer.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { OpClient } from '@toeverything/infra/op';
|
||||
|
||||
import type { ClientOps } from './ops';
|
||||
|
||||
export class PDFRenderer extends OpClient<ClientOps> {
|
||||
private readonly worker: Worker;
|
||||
|
||||
constructor() {
|
||||
const worker = new Worker(
|
||||
/* webpackChunkName: "pdf.worker" */ new URL(
|
||||
'./worker.ts',
|
||||
import.meta.url
|
||||
)
|
||||
);
|
||||
super(worker);
|
||||
|
||||
this.worker = worker;
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
super.destroy();
|
||||
this.worker.terminate();
|
||||
}
|
||||
|
||||
[Symbol.dispose]() {
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
16
packages/frontend/core/src/modules/pdf/renderer/types.ts
Normal file
16
packages/frontend/core/src/modules/pdf/renderer/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type PDFMeta = {
|
||||
pageCount: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type RenderPageOpts = {
|
||||
pageNum: number;
|
||||
width: number;
|
||||
height: number;
|
||||
scale?: number;
|
||||
};
|
||||
|
||||
export type RenderedPage = RenderPageOpts & {
|
||||
bitmap: ImageBitmap;
|
||||
};
|
||||
15
packages/frontend/core/src/modules/pdf/renderer/utils.ts
Normal file
15
packages/frontend/core/src/modules/pdf/renderer/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
|
||||
|
||||
export async function downloadBlobToBuffer(model: AttachmentBlockModel) {
|
||||
const sourceId = model.sourceId;
|
||||
if (!sourceId) {
|
||||
throw new Error('Attachment not found');
|
||||
}
|
||||
|
||||
const blob = await model.doc.blobSync.get(sourceId);
|
||||
if (!blob) {
|
||||
throw new Error('Attachment not found');
|
||||
}
|
||||
|
||||
return await blob.arrayBuffer();
|
||||
}
|
||||
140
packages/frontend/core/src/modules/pdf/renderer/worker.ts
Normal file
140
packages/frontend/core/src/modules/pdf/renderer/worker.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { OpConsumer, transfer } from '@toeverything/infra/op';
|
||||
import type { Document } from '@toeverything/pdf-viewer';
|
||||
import {
|
||||
createPDFium,
|
||||
PageRenderingflags,
|
||||
Runtime,
|
||||
Viewer,
|
||||
} from '@toeverything/pdf-viewer';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatestWith,
|
||||
filter,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
ReplaySubject,
|
||||
share,
|
||||
switchMap,
|
||||
} from 'rxjs';
|
||||
|
||||
import type { ClientOps } from './ops';
|
||||
import type { PDFMeta, RenderPageOpts } from './types';
|
||||
|
||||
class PDFRendererBackend extends OpConsumer<ClientOps> {
|
||||
private readonly viewer$: Observable<Viewer> = from(
|
||||
createPDFium().then(pdfium => {
|
||||
return new Viewer(new Runtime(pdfium));
|
||||
})
|
||||
);
|
||||
|
||||
private readonly binary$ = new BehaviorSubject<Uint8Array | null>(null);
|
||||
|
||||
private readonly doc$ = this.binary$.pipe(
|
||||
filter(Boolean),
|
||||
combineLatestWith(this.viewer$),
|
||||
switchMap(([buffer, viewer]) => {
|
||||
return new Observable<Document | undefined>(observer => {
|
||||
const doc = viewer.open(buffer);
|
||||
|
||||
if (!doc) {
|
||||
observer.error(new Error('Document not opened'));
|
||||
return;
|
||||
}
|
||||
|
||||
observer.next(doc);
|
||||
|
||||
return () => {
|
||||
doc.close();
|
||||
};
|
||||
});
|
||||
}),
|
||||
share({
|
||||
connector: () => new ReplaySubject(1),
|
||||
})
|
||||
);
|
||||
|
||||
private readonly docInfo$: Observable<PDFMeta> = this.doc$.pipe(
|
||||
map(doc => {
|
||||
if (!doc) {
|
||||
throw new Error('Document not opened');
|
||||
}
|
||||
|
||||
const firstPage = doc.page(0);
|
||||
if (!firstPage) {
|
||||
throw new Error('Document has no pages');
|
||||
}
|
||||
|
||||
return {
|
||||
pageCount: doc.pageCount(),
|
||||
width: firstPage.width(),
|
||||
height: firstPage.height(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
open({ data }: { data: ArrayBuffer }) {
|
||||
this.binary$.next(new Uint8Array(data));
|
||||
return this.docInfo$;
|
||||
}
|
||||
|
||||
render(opts: RenderPageOpts) {
|
||||
return this.doc$.pipe(
|
||||
combineLatestWith(this.viewer$),
|
||||
switchMap(([doc, viewer]) => {
|
||||
if (!doc) {
|
||||
throw new Error('Document not opened');
|
||||
}
|
||||
|
||||
return from(this.renderPage(viewer, doc, opts));
|
||||
}),
|
||||
map(bitmap => {
|
||||
if (!bitmap) {
|
||||
throw new Error('Failed to render page');
|
||||
}
|
||||
|
||||
return transfer({ ...opts, bitmap }, [bitmap]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async renderPage(viewer: Viewer, doc: Document, opts: RenderPageOpts) {
|
||||
const page = doc.page(opts.pageNum);
|
||||
|
||||
if (!page) return;
|
||||
|
||||
const width = Math.ceil(opts.width * (opts.scale ?? 1));
|
||||
const height = Math.ceil(opts.height * (opts.scale ?? 1));
|
||||
|
||||
const bitmap = viewer.createBitmap(width, height, 0);
|
||||
bitmap.fill(0, 0, width, height);
|
||||
page.render(
|
||||
bitmap,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT
|
||||
);
|
||||
|
||||
const data = new Uint8ClampedArray(bitmap.toUint8Array());
|
||||
const imageBitmap = await createImageBitmap(
|
||||
new ImageData(data, width, height)
|
||||
);
|
||||
|
||||
bitmap.close();
|
||||
page.close();
|
||||
|
||||
return imageBitmap;
|
||||
}
|
||||
|
||||
override listen(): void {
|
||||
this.register('open', this.open.bind(this));
|
||||
this.register('render', this.render.bind(this));
|
||||
super.listen();
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error how could we get correct postMessage signature for worker, exclude `window.postMessage`
|
||||
new PDFRendererBackend(self).listen();
|
||||
31
packages/frontend/core/src/modules/pdf/services/pdf.ts
Normal file
31
packages/frontend/core/src/modules/pdf/services/pdf.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
|
||||
import { ObjectPool, Service } from '@toeverything/infra';
|
||||
|
||||
import { PDF } from '../entities/pdf';
|
||||
|
||||
// One PDF document one worker.
|
||||
|
||||
export class PDFService extends Service {
|
||||
PDFs = new ObjectPool<string, PDF>({
|
||||
onDelete: pdf => {
|
||||
pdf.dispose();
|
||||
},
|
||||
});
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.disposables.push(() => {
|
||||
this.PDFs.clear();
|
||||
});
|
||||
}
|
||||
|
||||
get(model: AttachmentBlockModel) {
|
||||
let rc = this.PDFs.get(model.id);
|
||||
|
||||
if (!rc) {
|
||||
rc = this.PDFs.put(model.id, this.framework.createEntity(PDF, model));
|
||||
}
|
||||
|
||||
return { pdf: rc.obj, release: rc.release };
|
||||
}
|
||||
}
|
||||
185
packages/frontend/core/src/modules/pdf/views/components.tsx
Normal file
185
packages/frontend/core/src/modules/pdf/views/components.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { Scrollable } from '@affine/component';
|
||||
import clsx from 'clsx';
|
||||
import { type CSSProperties, forwardRef, memo } from 'react';
|
||||
import type { VirtuosoProps } from 'react-virtuoso';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export type PDFVirtuosoContext = {
|
||||
width: number;
|
||||
height: number;
|
||||
pageClassName?: string;
|
||||
onPageSelect?: (index: number) => void;
|
||||
};
|
||||
|
||||
export type PDFVirtuosoProps = VirtuosoProps<unknown, PDFVirtuosoContext>;
|
||||
|
||||
export const Scroller = forwardRef<HTMLDivElement, PDFVirtuosoProps>(
|
||||
({ context: _, ...props }, ref) => {
|
||||
return (
|
||||
<Scrollable.Root>
|
||||
<Scrollable.Viewport ref={ref} {...props} />
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Root>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Scroller.displayName = 'pdf-virtuoso-scroller';
|
||||
|
||||
export const List = forwardRef<HTMLDivElement, PDFVirtuosoProps>(
|
||||
({ context: _, className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx([styles.virtuosoList, className])}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
List.displayName = 'pdf-virtuoso-list';
|
||||
|
||||
export const ListWithSmallGap = forwardRef<HTMLDivElement, PDFVirtuosoProps>(
|
||||
({ context: _, className, ...props }, ref) => {
|
||||
return (
|
||||
<List className={clsx([className, 'small-gap'])} ref={ref} {...props} />
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ListWithSmallGap.displayName = 'pdf-virtuoso-small-gap-list';
|
||||
|
||||
export const Item = forwardRef<HTMLDivElement, PDFVirtuosoProps>(
|
||||
({ context: _, ...props }, ref) => {
|
||||
return <div className={styles.virtuosoItem} ref={ref} {...props} />;
|
||||
}
|
||||
);
|
||||
|
||||
Item.displayName = 'pdf-virtuoso-item';
|
||||
|
||||
export const ListPadding = () => (
|
||||
<div style={{ width: '100%', height: '20px' }} />
|
||||
);
|
||||
|
||||
export const LoadingSvg = memo(function LoadingSvg({
|
||||
style,
|
||||
className,
|
||||
}: {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className={clsx([styles.pdfLoading, className])}
|
||||
style={style}
|
||||
width="16"
|
||||
height="24"
|
||||
viewBox="0 0 537 759"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect width="537" height="759" fill="white" />
|
||||
<rect
|
||||
x="32"
|
||||
y="82"
|
||||
width="361"
|
||||
height="30"
|
||||
rx="4"
|
||||
fill="black"
|
||||
fillOpacity="0.07"
|
||||
/>
|
||||
<rect
|
||||
x="32"
|
||||
y="142"
|
||||
width="444"
|
||||
height="30"
|
||||
rx="4"
|
||||
fill="black"
|
||||
fillOpacity="0.07"
|
||||
/>
|
||||
<rect
|
||||
x="32"
|
||||
y="202"
|
||||
width="387"
|
||||
height="30"
|
||||
rx="4"
|
||||
fill="black"
|
||||
fillOpacity="0.07"
|
||||
/>
|
||||
<rect
|
||||
x="32"
|
||||
y="262"
|
||||
width="461"
|
||||
height="30"
|
||||
rx="4"
|
||||
fill="black"
|
||||
fillOpacity="0.07"
|
||||
/>
|
||||
<rect
|
||||
x="32"
|
||||
y="322"
|
||||
width="282"
|
||||
height="30"
|
||||
rx="4"
|
||||
fill="black"
|
||||
fillOpacity="0.07"
|
||||
/>
|
||||
<rect
|
||||
x="32"
|
||||
y="382"
|
||||
width="361"
|
||||
height="30"
|
||||
rx="4"
|
||||
fill="black"
|
||||
fillOpacity="0.07"
|
||||
/>
|
||||
<rect
|
||||
x="32"
|
||||
y="442"
|
||||
width="444"
|
||||
height="30"
|
||||
rx="4"
|
||||
fill="black"
|
||||
fillOpacity="0.07"
|
||||
/>
|
||||
<rect
|
||||
x="32"
|
||||
y="502"
|
||||
width="240"
|
||||
height="30"
|
||||
rx="4"
|
||||
fill="black"
|
||||
fillOpacity="0.07"
|
||||
/>
|
||||
<rect
|
||||
x="32"
|
||||
y="562"
|
||||
width="201"
|
||||
height="30"
|
||||
rx="4"
|
||||
fill="black"
|
||||
fillOpacity="0.07"
|
||||
/>
|
||||
<rect
|
||||
x="32"
|
||||
y="622"
|
||||
width="224"
|
||||
height="30"
|
||||
rx="4"
|
||||
fill="black"
|
||||
fillOpacity="0.07"
|
||||
/>
|
||||
<rect
|
||||
x="314"
|
||||
y="502"
|
||||
width="191"
|
||||
height="166"
|
||||
rx="4"
|
||||
fill="black"
|
||||
fillOpacity="0.07"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
11
packages/frontend/core/src/modules/pdf/views/index.ts
Normal file
11
packages/frontend/core/src/modules/pdf/views/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
Item,
|
||||
List,
|
||||
ListPadding,
|
||||
ListWithSmallGap,
|
||||
LoadingSvg,
|
||||
type PDFVirtuosoContext,
|
||||
type PDFVirtuosoProps,
|
||||
Scroller,
|
||||
} from './components';
|
||||
export { PDFPageRenderer } from './page-renderer';
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData } from '@toeverything/infra';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { PDF } from '../entities/pdf';
|
||||
import type { PDFPage } from '../entities/pdf-page';
|
||||
import { LoadingSvg } from './components';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
interface PDFPageProps {
|
||||
pdf: PDF;
|
||||
width: number;
|
||||
height: number;
|
||||
pageNum: number;
|
||||
scale?: number;
|
||||
className?: string;
|
||||
onSelect?: (pageNum: number) => void;
|
||||
}
|
||||
|
||||
export const PDFPageRenderer = ({
|
||||
pdf,
|
||||
width,
|
||||
height,
|
||||
pageNum,
|
||||
className,
|
||||
onSelect,
|
||||
scale = window.devicePixelRatio,
|
||||
}: PDFPageProps) => {
|
||||
const t = useI18n();
|
||||
const [pdfPage, setPdfPage] = useState<PDFPage | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const img = useLiveData(pdfPage?.bitmap$ ?? null);
|
||||
const error = useLiveData(pdfPage?.error$ ?? null);
|
||||
const style = { width, aspectRatio: `${width} / ${height}` };
|
||||
|
||||
useEffect(() => {
|
||||
const { page, release } = pdf.page(pageNum, `${width}:${height}:${scale}`);
|
||||
setPdfPage(page);
|
||||
|
||||
return release;
|
||||
}, [pdf, width, height, pageNum, scale]);
|
||||
|
||||
useEffect(() => {
|
||||
pdfPage?.render({ width, height, scale });
|
||||
|
||||
return pdfPage?.render.unsubscribe;
|
||||
}, [pdfPage, width, height, scale]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
if (!img) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = width * scale;
|
||||
canvas.height = height * scale;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
}, [img, width, height, scale]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<p className={styles.pdfPageError}>
|
||||
{t['com.affine.pdf.page.render.error']()}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={() => onSelect?.(pageNum)}
|
||||
>
|
||||
{img === null ? (
|
||||
<LoadingSvg />
|
||||
) : (
|
||||
<canvas className={styles.pdfPageCanvas} ref={canvasRef} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
64
packages/frontend/core/src/modules/pdf/views/styles.css.ts
Normal file
64
packages/frontend/core/src/modules/pdf/views/styles.css.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const virtuoso = style({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const virtuosoList = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '20px',
|
||||
selectors: {
|
||||
'&.small-gap': {
|
||||
gap: '12px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const virtuosoItem = style({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: '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({
|
||||
display: 'flex',
|
||||
alignSelf: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
textWrap: 'wrap',
|
||||
width: '100%',
|
||||
wordBreak: 'break-word',
|
||||
fontSize: 14,
|
||||
lineHeight: '22px',
|
||||
fontWeight: 400,
|
||||
color: cssVarV2('text/primary'),
|
||||
});
|
||||
|
||||
export const pdfPageCanvas = style({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const pdfLoading = style({
|
||||
display: 'flex',
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '537px',
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BlockComponent, EditorHost } from '@blocksuite/affine/block-std';
|
||||
import type {
|
||||
AttachmentBlockModel,
|
||||
DocMode,
|
||||
EmbedLinkedDocModel,
|
||||
EmbedSyncedDocModel,
|
||||
@@ -51,6 +52,11 @@ export type ImagePeekViewInfo = {
|
||||
docRef: DocReferenceInfo;
|
||||
};
|
||||
|
||||
export type AttachmentPeekViewInfo = {
|
||||
type: 'attachment';
|
||||
docRef: DocReferenceInfo;
|
||||
};
|
||||
|
||||
export type AIChatBlockPeekViewInfo = {
|
||||
type: 'ai-chat-block';
|
||||
docRef: DocReferenceInfo;
|
||||
@@ -68,6 +74,7 @@ export type ActivePeekView = {
|
||||
info:
|
||||
| DocPeekViewInfo
|
||||
| ImagePeekViewInfo
|
||||
| AttachmentPeekViewInfo
|
||||
| CustomTemplatePeekViewInfo
|
||||
| AIChatBlockPeekViewInfo;
|
||||
};
|
||||
@@ -90,6 +97,12 @@ const isImageBlockModel = (
|
||||
return blockModel.flavour === 'affine:image';
|
||||
};
|
||||
|
||||
const isAttachmentBlockModel = (
|
||||
blockModel: BlockModel
|
||||
): blockModel is AttachmentBlockModel => {
|
||||
return blockModel.flavour === 'affine:attachment';
|
||||
};
|
||||
|
||||
const isSurfaceRefModel = (
|
||||
blockModel: BlockModel
|
||||
): blockModel is SurfaceRefBlockModel => {
|
||||
@@ -153,6 +166,14 @@ function resolvePeekInfoFromPeekTarget(
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (isAttachmentBlockModel(blockModel)) {
|
||||
return {
|
||||
type: 'attachment',
|
||||
docRef: {
|
||||
docId: blockModel.doc.id,
|
||||
blockIds: [blockModel.id],
|
||||
},
|
||||
};
|
||||
} else if (isImageBlockModel(blockModel)) {
|
||||
return {
|
||||
type: 'image',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/blocks';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { AttachmentViewer } from '../../../../components/attachment-viewer';
|
||||
import { useEditor } from '../utils';
|
||||
|
||||
export type AttachmentPreviewModalProps = {
|
||||
docId: string;
|
||||
blockId: string;
|
||||
};
|
||||
|
||||
export const AttachmentPreviewPeekView = ({
|
||||
docId,
|
||||
blockId,
|
||||
}: AttachmentPreviewModalProps) => {
|
||||
const { doc } = useEditor(docId);
|
||||
const blocksuiteDoc = doc?.blockSuiteDoc;
|
||||
const model = useMemo(() => {
|
||||
const model = blocksuiteDoc?.getBlock(blockId)?.model;
|
||||
if (!model) return null;
|
||||
return model as AttachmentBlockModel;
|
||||
}, [blockId, blocksuiteDoc]);
|
||||
|
||||
return model === null ? null : <AttachmentViewer model={model} />;
|
||||
};
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import { fileTypeFromBuffer } from 'file-type';
|
||||
import { useErrorBoundary } from 'foxact/use-error-boundary';
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import {
|
||||
@@ -32,6 +31,10 @@ import type { FallbackProps } from 'react-error-boundary';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import {
|
||||
downloadResourceWithUrl,
|
||||
resourceUrlToBlob,
|
||||
} from '../../../../utils/resource';
|
||||
import { PeekViewService } from '../../services/peek-view';
|
||||
import { useEditor } from '../utils';
|
||||
import { useZoomControls } from './hooks/use-zoom';
|
||||
@@ -41,30 +44,8 @@ const filterImageBlock = (block: BlockModel): block is ImageBlockModel => {
|
||||
return block.flavour === 'affine:image';
|
||||
};
|
||||
|
||||
async function imageUrlToBlob(url: string): Promise<Blob | undefined> {
|
||||
const buffer = await fetch(url).then(response => {
|
||||
return response.arrayBuffer();
|
||||
});
|
||||
|
||||
if (!buffer) {
|
||||
console.warn('Could not get blob');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const type = await fileTypeFromBuffer(buffer);
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([buffer], { type: type.mime });
|
||||
return blob;
|
||||
} catch (error) {
|
||||
console.error('Error converting image to blob', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async function copyImageToClipboard(url: string) {
|
||||
const blob = await imageUrlToBlob(url);
|
||||
const blob = await resourceUrlToBlob(url);
|
||||
if (!blob) {
|
||||
return;
|
||||
}
|
||||
@@ -77,23 +58,6 @@ async function copyImageToClipboard(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBufferToFile(url: string, filename: string) {
|
||||
// given input url may not have correct mime type
|
||||
const blob = await imageUrlToBlob(url);
|
||||
if (!blob) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
|
||||
export type ImagePreviewModalProps = {
|
||||
docId: string;
|
||||
blockId: string;
|
||||
@@ -193,7 +157,7 @@ const ImagePreviewModalImpl = ({
|
||||
const downloadHandler = useAsyncCallback(async () => {
|
||||
const url = imageRef.current?.src;
|
||||
if (url) {
|
||||
await saveBufferToFile(url, caption || blockModel?.id || 'image');
|
||||
await downloadResourceWithUrl(url, caption || blockModel?.id || 'image');
|
||||
}
|
||||
}, [caption, blockModel?.id]);
|
||||
|
||||
|
||||
@@ -152,3 +152,68 @@ export const DocPeekViewControls = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AttachmentPeekViewControls = ({
|
||||
docRef,
|
||||
className,
|
||||
...rest
|
||||
}: DocPeekViewControlsProps) => {
|
||||
const peekView = useService(PeekViewService).peekView;
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
const t = useI18n();
|
||||
const controls = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
icon: <CloseIcon />,
|
||||
nameKey: 'close',
|
||||
name: t['com.affine.peek-view-controls.close'](),
|
||||
onClick: () => peekView.close(),
|
||||
},
|
||||
{
|
||||
icon: <ExpandFullIcon />,
|
||||
name: t['com.affine.peek-view-controls.open-attachment'](),
|
||||
nameKey: 'open',
|
||||
onClick: () => {
|
||||
const { docId, blockIds: [blockId] = [] } = docRef;
|
||||
if (docId && blockId) {
|
||||
workbench.openAttachment(docId, blockId);
|
||||
}
|
||||
peekView.close('none');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <OpenInNewIcon />,
|
||||
nameKey: 'new-tab',
|
||||
name: t['com.affine.peek-view-controls.open-attachment-in-new-tab'](),
|
||||
onClick: () => {
|
||||
const { docId, blockIds: [blockId] = [] } = docRef;
|
||||
if (docId && blockId) {
|
||||
workbench.openAttachment(docId, blockId, { at: 'new-tab' });
|
||||
}
|
||||
peekView.close('none');
|
||||
},
|
||||
},
|
||||
BUILD_CONFIG.isElectron && {
|
||||
icon: <SplitViewIcon />,
|
||||
nameKey: 'split-view',
|
||||
name: t[
|
||||
'com.affine.peek-view-controls.open-attachment-in-split-view'
|
||||
](),
|
||||
onClick: () => {
|
||||
const { docId, blockIds: [blockId] = [] } = docRef;
|
||||
if (docId && blockId) {
|
||||
workbench.openAttachment(docId, blockId, { at: 'beside' });
|
||||
}
|
||||
peekView.close('none');
|
||||
},
|
||||
},
|
||||
].filter((opt): opt is ControlButtonProps => Boolean(opt));
|
||||
}, [t, peekView, workbench, docRef]);
|
||||
return (
|
||||
<div {...rest} className={clsx(styles.root, className)}>
|
||||
{controls.map(option => (
|
||||
<ControlButton key={option.nameKey} {...option} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEffect, useMemo } from 'react';
|
||||
|
||||
import type { ActivePeekView } from '../entities/peek-view';
|
||||
import { PeekViewService } from '../services/peek-view';
|
||||
import { AttachmentPreviewPeekView } from './attachment-preview';
|
||||
import { DocPeekPreview } from './doc-preview';
|
||||
import { ImagePreviewPeekView } from './image-preview';
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
type PeekViewModalContainerProps,
|
||||
} from './modal-container';
|
||||
import {
|
||||
AttachmentPeekViewControls,
|
||||
DefaultPeekViewControls,
|
||||
DocPeekViewControls,
|
||||
} from './peek-view-controls';
|
||||
@@ -25,6 +27,15 @@ function renderPeekView({ info }: ActivePeekView) {
|
||||
return <DocPeekPreview docRef={info.docRef} />;
|
||||
}
|
||||
|
||||
if (info.type === 'attachment' && info.docRef.blockIds?.[0]) {
|
||||
return (
|
||||
<AttachmentPreviewPeekView
|
||||
docId={info.docRef.docId}
|
||||
blockId={info.docRef.blockIds?.[0]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (info.type === 'image' && info.docRef.blockIds?.[0]) {
|
||||
return (
|
||||
<ImagePreviewPeekView
|
||||
@@ -47,6 +58,10 @@ const renderControls = ({ info }: ActivePeekView) => {
|
||||
return <DocPeekViewControls docRef={info.docRef} />;
|
||||
}
|
||||
|
||||
if (info.type === 'attachment') {
|
||||
return <AttachmentPeekViewControls docRef={info.docRef} />;
|
||||
}
|
||||
|
||||
if (info.type === 'image') {
|
||||
return null; // image controls are rendered in the image preview
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
AllDocsIcon,
|
||||
AttachmentIcon,
|
||||
DeleteIcon,
|
||||
EdgelessIcon,
|
||||
ExportToPdfIcon,
|
||||
PageIcon,
|
||||
TagIcon,
|
||||
TodayIcon,
|
||||
@@ -18,6 +20,8 @@ export const iconNameToIcon = {
|
||||
journal: <TodayIcon />,
|
||||
tag: <TagIcon />,
|
||||
trash: <DeleteIcon />,
|
||||
attachment: <AttachmentIcon />,
|
||||
pdf: <ExportToPdfIcon />,
|
||||
} satisfies Record<string, ReactNode>;
|
||||
|
||||
export type ViewIconName = keyof typeof iconNameToIcon;
|
||||
|
||||
@@ -141,6 +141,14 @@ export class Workbench extends Entity {
|
||||
this.open(`/${docId}${query}`, options);
|
||||
}
|
||||
|
||||
openAttachment(
|
||||
docId: string,
|
||||
blockId: string,
|
||||
options?: WorkbenchOpenOptions
|
||||
) {
|
||||
this.open(`/${docId}/attachments/${blockId}`, options);
|
||||
}
|
||||
|
||||
openCollections(options?: WorkbenchOpenOptions) {
|
||||
this.open('/collection', options);
|
||||
}
|
||||
|
||||
42
packages/frontend/core/src/utils/resource.ts
Normal file
42
packages/frontend/core/src/utils/resource.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { fileTypeFromBuffer } from 'file-type';
|
||||
|
||||
export async function resourceUrlToBlob(
|
||||
url: string
|
||||
): Promise<Blob | undefined> {
|
||||
const buffer = await fetch(url).then(response => response.arrayBuffer());
|
||||
|
||||
if (!buffer) {
|
||||
console.warn('Could not get blob');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const type = await fileTypeFromBuffer(buffer);
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([buffer], { type: type.mime });
|
||||
return blob;
|
||||
} catch (error) {
|
||||
console.error('Error converting resource to blob', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
export async function downloadBlob(blob: Blob, filename: string) {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
|
||||
export async function downloadResourceWithUrl(url: string, filename: string) {
|
||||
// given input url may not have correct mime type
|
||||
const blob = await resourceUrlToBlob(url);
|
||||
if (!blob) return;
|
||||
|
||||
await downloadBlob(blob, filename);
|
||||
}
|
||||
@@ -19,4 +19,4 @@
|
||||
"ur": 3,
|
||||
"zh-Hans": 95,
|
||||
"zh-Hant": 92
|
||||
}
|
||||
}
|
||||
|
||||
@@ -955,6 +955,9 @@
|
||||
"com.affine.peek-view-controls.open-doc-in-new-tab": "Open in new tab",
|
||||
"com.affine.peek-view-controls.open-doc-in-split-view": "Open in split view",
|
||||
"com.affine.peek-view-controls.open-info": "Open doc info",
|
||||
"com.affine.peek-view-controls.open-attachment": "Open this attachment",
|
||||
"com.affine.peek-view-controls.open-attachment-in-new-tab": "Open in new tab",
|
||||
"com.affine.peek-view-controls.open-attachment-in-split-view": "Open in split view",
|
||||
"com.affine.quicksearch.group.creation": "New",
|
||||
"com.affine.quicksearch.group.searchfor": "Search for \"{{query}}\"",
|
||||
"com.affine.resetSyncStatus.button": "Reset sync",
|
||||
@@ -1458,5 +1461,8 @@
|
||||
"com.affine.m.selector.remove-warning.where-tag": "tag",
|
||||
"com.affine.m.selector.remove-warning.where-folder": "folder",
|
||||
"com.affine.m.selector.journal-menu.today-activity": "Today's activity",
|
||||
"com.affine.m.selector.journal-menu.conflicts": "Duplicate Entries in Today's Journal"
|
||||
"com.affine.m.selector.journal-menu.conflicts": "Duplicate Entries in Today's Journal",
|
||||
"com.affine.attachment.preview.error.title": "Unable to preview this file",
|
||||
"com.affine.attachment.preview.error.subtitle": "file type not supported.",
|
||||
"com.affine.pdf.page.render.error": "Failed to render page."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user