mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-15 00:56:26 +08:00
feat(core): pdf preview (#8569)
Co-authored-by: forehalo <forehalo@gmail.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,3 @@
|
||||
export { PDFRenderer } from './renderer';
|
||||
export type { PDFMeta, RenderedPage, RenderPageOpts } from './types';
|
||||
export { downloadBlobToBuffer } from './utils';
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user