feat(core): pdf preview (#8569)

Co-authored-by: forehalo <forehalo@gmail.com>
This commit is contained in:
Fangdun Tsai
2024-11-12 19:12:31 +08:00
committed by GitHub
parent 73283df3e1
commit f4abe39689
46 changed files with 1968 additions and 95 deletions
@@ -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);
}