diff --git a/packages/common/infra/src/op/README.md b/packages/common/infra/src/op/README.md index 232dbb04fc6e3..8a1aced1b4a32 100644 --- a/packages/common/infra/src/op/README.md +++ b/packages/common/infra/src/op/README.md @@ -39,7 +39,7 @@ consumer.register('subscribeStatus', (id: number) => { // subscribe const client: OpClient; -client.subscribe('subscribeStatus', 123, { +client.ob$('subscribeStatus', 123).subscribe({ next: status => { ui.setServerStatus(status); }, diff --git a/packages/common/infra/src/op/__tests__/client.spec.ts b/packages/common/infra/src/op/__tests__/client.spec.ts index 2803d0ba5ddc1..ffc24a669060e 100644 --- a/packages/common/infra/src/op/__tests__/client.spec.ts +++ b/packages/common/infra/src/op/__tests__/client.spec.ts @@ -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(` [ diff --git a/packages/common/infra/src/op/client.ts b/packages/common/infra/src/op/client.ts index e07f0c73f728e..c469028f96c69 100644 --- a/packages/common/infra/src/op/client.ts +++ b/packages/common/infra/src/op/client.ts @@ -155,15 +155,11 @@ export class OpClient extends AutoMessageHandler { return promise; } - subscribe, Out extends OpOutput>( + ob$, Out extends OpOutput>( op: Op, - ...args: [ - ...OpInput, - Partial> | ((value: Out) => void), - ] - ): () => void { + ...args: OpInput + ): Observable { const payload = args[0]; - const observer = args[1] as Partial> | ((value: Out) => void); const msg = { type: 'subscribe', @@ -172,24 +168,23 @@ export class OpClient extends AutoMessageHandler { payload, } satisfies SubscribeMessage; - const sub = new Observable(ob => { + const sub$ = new Observable(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() { diff --git a/packages/common/infra/src/op/consumer.ts b/packages/common/infra/src/op/consumer.ts index 2f94300e34f27..0b24c4b5ea7c9 100644 --- a/packages/common/infra/src/op/consumer.ts +++ b/packages/common/infra/src/op/consumer.ts @@ -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 extends AutoMessageHandler { ob$ = of(ret$); } - return ob$.pipe(share(), takeUntil(fromEvent(signal, 'abort'))); + return ob$.pipe(takeUntil(fromEvent(signal, 'abort'))); }); } diff --git a/packages/common/infra/src/op/message.ts b/packages/common/infra/src/op/message.ts index bd06a9e58ed1d..aa58fd3b0cfc6 100644 --- a/packages/common/infra/src/op/message.ts +++ b/packages/common/infra/src/op/message.ts @@ -131,6 +131,7 @@ export function fetchTransferables(data: any): Transferable[] | undefined { } export abstract class AutoMessageHandler { + private listening = false; protected abstract handlers: Partial; constructor(protected readonly port: MessageCommunicapable) {} @@ -145,13 +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; } } diff --git a/packages/frontend/core/src/components/attachment-viewer/index.tsx b/packages/frontend/core/src/components/attachment-viewer/index.tsx index e336e03ede264..e9ccdfe589555 100644 --- a/packages/frontend/core/src/components/attachment-viewer/index.tsx +++ b/packages/frontend/core/src/components/attachment-viewer/index.tsx @@ -2,10 +2,10 @@ 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'; -import { Viewer } from './viewer'; export type AttachmentViewerProps = { model: AttachmentBlockModel; @@ -20,7 +20,7 @@ export const AttachmentViewer = ({ model }: AttachmentViewerProps) => { {props.isPDF ? ( - + ) : ( @@ -41,7 +41,7 @@ export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => { {props.isPDF ? ( - + ) : ( diff --git a/packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx new file mode 100644 index 0000000000000..6de3d15a08959 --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx @@ -0,0 +1,324 @@ +import { IconButton, Scrollable } from '@affine/component'; +import { + type PDF, + type PDFRendererState, + PDFService, + PDFStatus, +} from '@affine/core/modules/pdf'; +import type { PDFPage } from '@affine/core/modules/pdf/entities/pdf'; +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 React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; +import { Virtuoso } from 'react-virtuoso'; + +import * as styles from './styles.css'; + +type ItemProps = VirtuosoProps; + +const Page = React.memo( + ({ + width, + height, + className, + }: { + index: number; + width: number; + height: number; + className: string; + }) => { + return ( +
+ ); + } +); + +Page.displayName = 'viewer-page'; + +const THUMBNAIL_WIDTH = 94; + +const Thumbnail = React.memo( + ({ + index, + width, + height, + className, + onSelect, + }: { + index: number; + width: number; + height: number; + className: string; + onSelect: (index: number) => void; + }) => { + return ( +
onSelect(index)} + >
+ ); + } +); + +Thumbnail.displayName = 'viewer-thumbnail'; + +const Scroller = React.forwardRef( + ({ ...props }, ref) => { + return ( + + + + + ); + } +); + +Scroller.displayName = 'viewer-scroller'; + +const Item = React.forwardRef( + ({ ...props }, ref) => { + return
; + } +); + +Item.displayName = 'viewer-item'; + +interface ViewerProps { + model: AttachmentBlockModel; +} + +enum RenderKind { + Page = 'page', + Thumbnail = 'thumbnail', +} + +interface PDFPageProps { + className?: string; + pdf: PDF; + page: number; + width: number; + height: number; +} + +function PDFPageRenderer({ + pdf, + page, + className, + width, + height, +}: PDFPageProps) { + const [pdfPage, setPdfPage] = useState(null); + const canvasRef = useRef(null); + + const img = useLiveData(pdfPage?.bitmap$ ?? null); + + useEffect(() => { + const pdfPage = pdf.page(RenderKind.Page, page); + setPdfPage(pdfPage.page); + + return () => { + pdfPage.release(); + }; + }, [pdf, page, width, height]); + + useEffect(() => { + pdfPage?.render({ width, height, scale: 1 }); + + return pdfPage?.render.unsubscribe; + }, [pdfPage, height, width]); + + useEffect(() => { + if (!canvasRef.current || !img) return; + const ctx = canvasRef.current.getContext('2d'); + if (!ctx) return; + ctx.drawImage(img, 0, 0); + }, [img, height, width]); + + return ( +
+ +
+ ); +} + +interface PDFViewerInnerProps { + pdf: PDF; + state: Extract; +} + +const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => { + const pdfMeta = state.meta; + const [cursor, setCursor] = useState(0); + const [viewportInfo, setViewportInfo] = useState({ + dpi: window.devicePixelRatio, + width: 1, + height: 1, + }); + const scrollerHandleRef = useRef(null); + const [collapsed, setCollapsed] = useState(true); + + const onSelect = useCallback( + (index: number) => { + scrollerHandleRef.current?.scrollToIndex({ + index, + align: 'start', + behavior: 'smooth', + }); + }, + [scrollerHandleRef] + ); + + const pageContent = useCallback( + (index: number) => { + return ( + + ); + }, + [pdf, pdfMeta] + ); + + const thumbnailContent = useCallback( + (index: number) => { + return ( + + ); + }, + [cursor, state, onSelect] + ); + + const mainComponents = useMemo(() => { + return { + Header: () =>
, + Footer: () =>
, + Item: (props: ItemProps) => ( + + ), + Scroller, + }; + }, []); + + const thumbnailsComponents = useMemo(() => { + return { + Item: (props: ItemProps) => ( + + ), + Scroller, + }; + }, []); + + const mainStyle = useMemo(() => { + const { height: vh } = viewportInfo; + const { pageCount: t, height: h, width: w } = state.meta; + const height = Math.min( + vh - 60 - 24 - 24 - 2 - 8, + t * THUMBNAIL_WIDTH * (h / w) + (t - 1) * 12 + ); + return { height: `${height}px` }; + }, [state, viewportInfo]); + + return ( +
+ + key={pdf.id} + ref={scrollerHandleRef} + className={styles.virtuoso} + totalCount={state.meta.pageCount} + itemContent={pageContent} + components={mainComponents} + /> +
+
+ + key={`${pdf.id}-thumbnails`} + style={mainStyle} + className={styles.virtuoso} + totalCount={state.meta.pageCount} + itemContent={thumbnailContent} + components={thumbnailsComponents} + /> +
+
+
+ + {state.meta.pageCount > 0 ? cursor + 1 : 0} + + /{state.meta.pageCount} +
+ : } + onClick={() => setCollapsed(!collapsed)} + /> +
+
+
+ ); +}; + +function PDFViewerStatus({ pdf }: { pdf: PDF }) { + const state = useLiveData(pdf.state$); + + if (state?.status !== PDFStatus.Opened) { + return null; + } + + return ; +} + +export function PDFViewer({ model }: ViewerProps) { + const pdfService = useService(PDFService); + const [pdf, setPdf] = useState(null); + + useEffect(() => { + const { pdf, release } = pdfService.get(model); + setPdf(pdf); + + return release; + }, [model, pdfService, setPdf]); + + if (!pdf) { + return null; + } + + return ; +} diff --git a/packages/frontend/core/src/components/attachment-viewer/viewer.tsx b/packages/frontend/core/src/components/attachment-viewer/viewer.tsx deleted file mode 100644 index a8fd26e4060de..0000000000000 --- a/packages/frontend/core/src/components/attachment-viewer/viewer.tsx +++ /dev/null @@ -1,500 +0,0 @@ -import { IconButton, observeResize, Scrollable } from '@affine/component'; -import type { Pdf, PdfSender } from '@affine/core/modules/pdf'; -import { PdfsService } from '@affine/core/modules/pdf'; -import { - defaultDocInfo, - RenderKind, - type RenderOut, - State, -} from '@affine/core/modules/pdf/workers/types'; -import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc'; -import { LiveData, useLiveData, useService } from '@toeverything/infra'; -import clsx from 'clsx'; -import { debounce } from 'lodash-es'; -import type { ReactElement } from 'react'; -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { useErrorBoundary } from 'react-error-boundary'; -import type { VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; -import { Virtuoso } from 'react-virtuoso'; - -import * as styles from './styles.css'; -import { genSeq, renderItem } from './utils'; - -type ItemProps = VirtuosoProps; - -const Page = React.memo( - ({ - width, - height, - className, - }: { - index: number; - width: number; - height: number; - className: string; - }) => { - return ( -
- ); - } -); - -Page.displayName = 'viewer-page'; - -const THUMBNAIL_WIDTH = 94; - -const Thumbnail = React.memo( - ({ - index, - width, - height, - className, - onSelect, - }: { - index: number; - width: number; - height: number; - className: string; - onSelect: (index: number) => void; - }) => { - return ( -
onSelect(index)} - >
- ); - } -); - -Thumbnail.displayName = 'viewer-thumbnail'; - -const Scroller = React.forwardRef( - ({ ...props }, ref) => { - return ( - - - - - ); - } -); - -Scroller.displayName = 'viewer-scroller'; - -const Item = React.forwardRef( - ({ ...props }, ref) => { - return
; - } -); - -Item.displayName = 'viewer-item'; - -interface ViewerProps { - model: AttachmentBlockModel; -} - -export const Viewer = ({ model }: ViewerProps): ReactElement => { - const { showBoundary } = useErrorBoundary(); - const pdfsService = useService(PdfsService); - const [pdf, setPdf] = useState(null); - const [sender, setSender] = useState(null); - const [cursor, setCursor] = useState(0); - const info = useLiveData( - useMemo( - () => - pdf - ? pdf.info$ - : new LiveData({ state: State.IDLE, ...defaultDocInfo() }), - [pdf] - ) - ); - - const [viewportInfo, setViewportInfo] = useState({ - dpi: window.devicePixelRatio, - width: 1, - height: 1, - }); - const viewerRef = useRef(null); - const scrollerRef = useRef(null); - const scrollerHandleRef = useRef(null); - - const [mainVisibleRange, setMainVisibleRange] = useState({ - startIndex: 0, - endIndex: 0, - }); - const mainRenderingSeq$ = useMemo( - () => - new LiveData<{ - seq: Set; - diff: Set; - }>({ - seq: new Set(), - diff: new Set(), - }), - [] - ); - - const [collapsed, setCollapsed] = useState(true); - const thumbnailsScrollerHandleRef = useRef(null); - const thumbnailsScrollerRef = useRef(null); - const [thumbnailsVisibleRange, setThumbnailsVisibleRange] = useState({ - startIndex: 0, - endIndex: 0, - }); - const thumbnailsRenderingSeq$ = useMemo( - () => - new LiveData<{ - seq: Set; - diff: Set; - }>({ - seq: new Set(), - diff: new Set(), - }), - [] - ); - - const render = useCallback( - (data: RenderOut) => { - const isPage = data.kind === RenderKind.Page; - const container = isPage ? scrollerRef : thumbnailsScrollerRef; - const name = isPage ? 'page' : 'thumbnail'; - renderItem(container.current, `pdf-${name}`, data); - }, - [scrollerRef, thumbnailsScrollerRef] - ); - - const onScroll = useCallback(() => { - const el = scrollerRef.current; - if (!el) return; - - const { total } = info; - if (!total) return; - - const { scrollTop, scrollHeight } = el; - const itemHeight = scrollHeight / total; - const n = scrollTop / itemHeight; - const t = n / total; - const index = Math.floor(n + t); - const cursor = Math.min(index, total - 1); - - setCursor(cursor); - }, [scrollerRef, info]); - - const onSelect = useCallback( - (index: number) => { - scrollerHandleRef.current?.scrollToIndex({ - index, - align: 'start', - behavior: 'smooth', - }); - }, - [scrollerHandleRef] - ); - - const updateMainVisibleRange = useMemo( - () => debounce(setMainVisibleRange, 233, { trailing: true }), - [setMainVisibleRange] - ); - - const updateThumbnailsVisibleRange = useMemo( - () => debounce(setThumbnailsVisibleRange, 233, { trailing: true }), - [setThumbnailsVisibleRange] - ); - - const updateSeq = useCallback( - ( - range: { startIndex: number; endIndex: number }, - seq$: LiveData<{ - seq: Set; - diff: Set; - }> - ) => { - if (!sender) return; - const { startIndex, endIndex } = range; - const seq = new Set(genSeq(startIndex, endIndex, info.total)); - seq$.next({ - seq, - diff: seq.difference(seq$.value.seq), - }); - }, - [info, sender] - ); - - const createRenderingSubscriber = useCallback( - ( - seq$: LiveData<{ - seq: Set; - diff: Set; - }>, - kind: RenderKind - ) => { - if (!sender) return; - - const scale = - (kind === RenderKind.Page ? 1 : THUMBNAIL_WIDTH / info.width) * - viewportInfo.dpi; - - let unsubscribe: () => void; - - const subscriber = seq$.subscribe(({ seq: _, diff }) => { - unsubscribe?.(); - - unsubscribe = sender.subscribe( - 'render', - { seq: Array.from(diff), kind, scale }, - { - next: data => { - if (!data) return; - render(data); - }, - error: err => { - console.error(err); - unsubscribe(); - }, - } - ); - }); - - return () => { - unsubscribe?.(); - subscriber.unsubscribe(); - }; - }, - [viewportInfo, info, render, sender] - ); - - const pageContent = useCallback( - (index: number) => { - return ( - - ); - }, - [info] - ); - - const thumbnailContent = useCallback( - (index: number) => { - return ( - - ); - }, - [cursor, info, onSelect] - ); - - const mainComponents = useMemo(() => { - return { - Header: () =>
, - Footer: () =>
, - Item: (props: ItemProps) => ( - - ), - Scroller, - }; - }, []); - - const thumbnailsComponents = useMemo(() => { - return { - Item: (props: ItemProps) => ( - - ), - Scroller, - }; - }, []); - - const increaseViewportBy = useMemo(() => { - const size = Math.min(5, info.total); - const itemHeight = info.height + 20; - const height = Math.ceil(size * itemHeight); - return { top: height, bottom: height }; - }, [info]); - - const mainStyle = useMemo(() => { - const { height: vh } = viewportInfo; - const { total: t, height: h, width: w } = info; - const height = Math.min( - vh - 60 - 24 - 24 - 2 - 8, - t * THUMBNAIL_WIDTH * (h / w) + (t - 1) * 12 - ); - return { height: `${height}px` }; - }, [info, viewportInfo]); - - useEffect(() => { - const unsubscribe = createRenderingSubscriber( - mainRenderingSeq$, - RenderKind.Page - ); - return () => { - unsubscribe?.(); - }; - }, [scrollerRef, createRenderingSubscriber, mainRenderingSeq$]); - - useEffect(() => { - const unsubscribe = createRenderingSubscriber( - thumbnailsRenderingSeq$, - RenderKind.Thumbnail - ); - return () => { - unsubscribe?.(); - }; - }, [ - thumbnailsScrollerHandleRef, - createRenderingSubscriber, - thumbnailsRenderingSeq$, - ]); - - useEffect(() => { - const el = viewerRef.current; - if (!el) return; - - return observeResize(el, entry => { - const rect = entry.contentRect; - setViewportInfo(info => ({ - ...info, - width: rect.width, - height: rect.height, - })); - }); - }, [viewerRef]); - - useEffect(() => { - updateSeq(mainVisibleRange, mainRenderingSeq$); - }, [updateSeq, mainVisibleRange, mainRenderingSeq$]); - - useEffect(() => { - if (collapsed) return; - updateSeq(thumbnailsVisibleRange, thumbnailsRenderingSeq$); - }, [collapsed, updateSeq, thumbnailsVisibleRange, thumbnailsRenderingSeq$]); - - useEffect(() => { - scrollerHandleRef.current?.scrollToIndex({ - index: 0, - align: 'start', - }); - thumbnailsScrollerHandleRef.current?.scrollToIndex({ - index: 0, - align: 'start', - }); - setCursor(0); - mainRenderingSeq$.next({ seq: new Set(), diff: new Set() }); - thumbnailsRenderingSeq$.next({ seq: new Set(), diff: new Set() }); - setMainVisibleRange({ startIndex: 0, endIndex: 0 }); - setThumbnailsVisibleRange({ startIndex: 0, endIndex: 0 }); - }, [sender, mainRenderingSeq$, thumbnailsRenderingSeq$]); - - useLayoutEffect(() => { - if (!model.sourceId) { - showBoundary('Attachment not found'); - return; - } - - let unsubscribe: () => void; - - const { pdf, release } = pdfsService.get(model); - - setPdf(pdf); - - const subscriber = pdf.open(model).subscribe({ - error: error => { - console.log(error); - }, - complete: () => { - const { sender, release } = pdf.client.channel(); - setSender(sender); - unsubscribe = release; - }, - }); - - return () => { - unsubscribe?.(); - subscriber.unsubscribe(); - release(); - }; - }, [showBoundary, pdfsService, model]); - - return ( -
- - key={model.id} - onScroll={onScroll} - ref={scrollerHandleRef} - scrollerRef={scroller => { - scrollerRef.current = scroller as HTMLElement; - }} - className={styles.virtuoso} - rangeChanged={updateMainVisibleRange} - increaseViewportBy={increaseViewportBy} - totalCount={info.total} - itemContent={pageContent} - components={mainComponents} - /> -
-
- - key={model.id} - style={mainStyle} - ref={thumbnailsScrollerHandleRef} - scrollerRef={scroller => { - thumbnailsScrollerRef.current = scroller as HTMLElement; - }} - rangeChanged={updateThumbnailsVisibleRange} - className={styles.virtuoso} - totalCount={info.total} - itemContent={thumbnailContent} - components={thumbnailsComponents} - /> -
-
-
- - {info.total > 0 ? cursor + 1 : 0} - - /{info.total} -
- : } - onClick={() => setCollapsed(!collapsed)} - /> -
-
-
- ); -}; diff --git a/packages/frontend/core/src/modules/pdf/entities/pdf.ts b/packages/frontend/core/src/modules/pdf/entities/pdf.ts index dc994db07159f..3ad8a27433036 100644 --- a/packages/frontend/core/src/modules/pdf/entities/pdf.ts +++ b/packages/frontend/core/src/modules/pdf/entities/pdf.ts @@ -1,25 +1,128 @@ import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import { Entity, LiveData } from '@toeverything/infra'; +import { + effect, + Entity, + LiveData, + mapInto, + ObjectPool, +} from '@toeverything/infra'; +import { catchError, from, map, of, startWith, switchMap } from 'rxjs'; -import { createPdfClient } from '../workers/client'; -import { defaultDocInfo, type DocState, State } from '../workers/types'; +import type { PDFMeta, RenderPageOpts } from '../renderer'; +import { PDFRenderer } from '../renderer'; -export class Pdf extends Entity<{ id: string }> { - public readonly id: string = this.props.id; +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; + }; - public readonly info$ = new LiveData({ - state: State.IDLE, - ...defaultDocInfo(), +function resizeImageBitmap( + imageData: ImageData, + options: { + resizeWidth: number; + resizeHeight: number; + } +) { + return createImageBitmap(imageData, 0, 0, imageData.width, imageData.height, { + colorSpaceConversion: 'none', + resizeQuality: 'pixelated', + ...options, }); +} - public readonly client = createPdfClient(); +async function downloadBlobToBuffer(model: AttachmentBlockModel) { + const sourceId = model.sourceId; + if (!sourceId) { + throw new Error('Attachment not found'); + } - open(model: AttachmentBlockModel) { - return this.client.open(model, info => this.info$.next(info)); + const blob = await model.doc.blobSync.get(sourceId); + if (!blob) { + throw new Error('Attachment not found'); + } + + return await blob.arrayBuffer(); +} + +export class PDF extends Entity { + public readonly id: string = this.props.id; + readonly renderer = new PDFRenderer(); + readonly pages = new ObjectPool({ + onDelete: page => page.dispose(), + }); + + readonly state$ = LiveData.from( + // @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(type: string, page: number) { + const key = `${type}:${page}`; + let rc = this.pages.get(key); + + if (!rc) { + rc = this.pages.put( + key, + this.framework.createEntity(PDFPage, { pdf: this, page }) + ); + } + + return { page: rc.obj, release: rc.release }; } override dispose() { - this.client.destroy(); + this.renderer.destroy(); super.dispose(); } } + +export class PDFPage extends Entity<{ pdf: PDF; page: number }> { + readonly page: number = this.props.page; + bitmap$ = new LiveData(null); + + render = effect( + switchMap((opts: Omit) => + this.props.pdf.renderer.ob$('render', { + ...opts, + pageNum: this.props.page, + }) + ), + map(data => data.bitmap), + mapInto(this.bitmap$) + ); + + constructor() { + super(); + this.disposables.push(() => this.render.unsubscribe); + } +} diff --git a/packages/frontend/core/src/modules/pdf/entities/pdfs.ts b/packages/frontend/core/src/modules/pdf/entities/pdfs.ts deleted file mode 100644 index 063602ec33f1b..0000000000000 --- a/packages/frontend/core/src/modules/pdf/entities/pdfs.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import type { WorkspaceService } from '@toeverything/infra'; -import { Entity, ObjectPool } from '@toeverything/infra'; - -import { Pdf } from './pdf'; - -export class Pdfs extends Entity { - pdfs = new ObjectPool({ - onDelete: pdf => { - pdf.dispose(); - }, - }); - - constructor(private readonly workspaceService: WorkspaceService) { - super(); - } - - get(model: AttachmentBlockModel) { - const { id } = model; - - let result = this.pdfs.get(id); - - if (!result) { - const pdf = this.framework.createEntity(Pdf, { id }); - result = this.pdfs.put(id, pdf); - } - - const { obj: pdf, release } = result; - - return { pdf, release }; - } - - get name() { - return this.workspaceService.workspace.id; - } - - override dispose() { - this.pdfs.clear(); - super.dispose(); - } -} diff --git a/packages/frontend/core/src/modules/pdf/index.ts b/packages/frontend/core/src/modules/pdf/index.ts index d5b1596b11fbe..5b69edd691d4e 100644 --- a/packages/frontend/core/src/modules/pdf/index.ts +++ b/packages/frontend/core/src/modules/pdf/index.ts @@ -1,18 +1,17 @@ import type { Framework } from '@toeverything/infra'; -import { WorkspaceScope, WorkspaceService } from '@toeverything/infra'; +import { WorkspaceScope } from '@toeverything/infra'; -import { Pdf } from './entities/pdf'; -import { Pdfs } from './entities/pdfs'; -import { PdfsService } from './services/pdfs'; +import { PDF, PDFPage } from './entities/pdf'; +import { PDFService } from './services/pdf'; export function configurePDFModule(framework: Framework) { framework .scope(WorkspaceScope) - .service(PdfsService) - .entity(Pdfs, [WorkspaceService]) - .entity(Pdf); + .service(PDFService) + .entity(PDF) + .entity(PDFPage); } -export { Pdf } from './entities/pdf'; -export { PdfsService } from './services/pdfs'; -export { PdfClient, type PdfSender } from './workers/client'; +export { PDF, type PDFRendererState, PDFStatus } from './entities/pdf'; +export { PDFRenderer } from './renderer'; +export { PDFService } from './services/pdf'; diff --git a/packages/frontend/core/src/modules/pdf/renderer/index.ts b/packages/frontend/core/src/modules/pdf/renderer/index.ts new file mode 100644 index 0000000000000..f911a08237598 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/renderer/index.ts @@ -0,0 +1,30 @@ +import { OpClient } from '@toeverything/infra/op'; + +import type { ClientOps } from './ops'; + +export class PDFRenderer extends OpClient { + 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(); + } +} + +export type { PDFMeta, RenderedPage, RenderPageOpts } from './types'; diff --git a/packages/frontend/core/src/modules/pdf/renderer/ops.ts b/packages/frontend/core/src/modules/pdf/renderer/ops.ts new file mode 100644 index 0000000000000..946fa6b2454b3 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/renderer/ops.ts @@ -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]; +} diff --git a/packages/frontend/core/src/modules/pdf/renderer/types.ts b/packages/frontend/core/src/modules/pdf/renderer/types.ts new file mode 100644 index 0000000000000..3e79550a1d80f --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/renderer/types.ts @@ -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; +}; diff --git a/packages/frontend/core/src/modules/pdf/renderer/worker.ts b/packages/frontend/core/src/modules/pdf/renderer/worker.ts new file mode 100644 index 0000000000000..b435c8006ea03 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/renderer/worker.ts @@ -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 { + private readonly viewer$: Observable = from( + createPDFium().then(pdfium => { + return new Viewer(new Runtime(pdfium)); + }) + ); + + private readonly binary$ = new BehaviorSubject(null); + + private readonly doc$ = this.binary$.pipe( + filter(Boolean), + combineLatestWith(this.viewer$), + switchMap(([buffer, viewer]) => { + return new Observable(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 = 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(); diff --git a/packages/frontend/core/src/modules/pdf/services/pdf.ts b/packages/frontend/core/src/modules/pdf/services/pdf.ts new file mode 100644 index 0000000000000..7880300007352 --- /dev/null +++ b/packages/frontend/core/src/modules/pdf/services/pdf.ts @@ -0,0 +1,32 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { ObjectPool, Service } from '@toeverything/infra'; + +import { PDF } from '../entities/pdf'; + +// One PDF document one worker. +// Multiple channels correspond to multiple views. + +export class PDFService extends Service { + PDFs = new ObjectPool({ + 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 }; + } +} diff --git a/packages/frontend/core/src/modules/pdf/services/pdfs.ts b/packages/frontend/core/src/modules/pdf/services/pdfs.ts deleted file mode 100644 index bcc421b4938df..0000000000000 --- a/packages/frontend/core/src/modules/pdf/services/pdfs.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import { Service } from '@toeverything/infra'; - -import { Pdfs } from '../entities/pdfs'; - -// One PDF document one worker. -// Multiple channels correspond to multiple views. - -export class PdfsService extends Service { - pdfs = this.framework.createEntity(Pdfs); - - get(model: AttachmentBlockModel) { - return this.pdfs.get(model); - } - - override dispose() { - this.pdfs.dispose(); - super.dispose(); - } -} diff --git a/packages/frontend/core/src/modules/pdf/workers/client.ts b/packages/frontend/core/src/modules/pdf/workers/client.ts deleted file mode 100644 index 5e857a7580ea5..0000000000000 --- a/packages/frontend/core/src/modules/pdf/workers/client.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import { fromPromise, ObjectPool } from '@toeverything/infra'; -import { OpClient, transfer } from '@toeverything/infra/op'; -import { nanoid } from 'nanoid'; -import { Observable, type Observer } from 'rxjs'; - -import type { ChannelOps, ClientOps } from './ops'; -import { type DocState, State } from './types'; -import { downloadBlobToBuffer } from './utils'; - -export function createPdfClient() { - const worker = new Worker( - /* webpackChunkName: "pdf.worker" */ new URL('./worker.ts', import.meta.url) - ); - - const client = new PdfClient(worker); - client.listen(); - return client; -} - -export type PdfSender = OpClient; - -export class PdfClient extends OpClient { - channels = new ObjectPool({ - onDelete(client) { - client.destroy(); - }, - }); - - private _ping(id: string, subscriber: Observer) { - return this.subscribe('pingpong', { id }, subscriber); - } - - private _open( - id: string, - buffer: ArrayBuffer, - subscriber: Observer - ) { - return this.subscribe( - 'open', - transfer({ id, buffer }, [buffer]), - subscriber - ); - } - - private _downloadBlobToBuffer( - model: AttachmentBlockModel, - subscriber: Partial> - ) { - return fromPromise(downloadBlobToBuffer(model)).subscribe(subscriber); - } - - // Opens a PDF document. - open(model: AttachmentBlockModel, update?: (info: DocState) => void) { - const { id } = model; - const ob$ = new Observable(subscriber => { - const setInfo = (info: DocState) => { - update?.(info); - subscriber.next(info); - }; - const error = (err?: any) => subscriber.error(err); - const complete = () => subscriber.complete(); - - this._ping(id, { - next: info => { - setInfo(info); - - if (info.state === State.Opened) { - complete(); - return; - } - - if (info.state === State.Opening) { - return; - } - - if (info.state === State.Loaded) { - info.state = State.Opening; - setInfo(info); - - this._downloadBlobToBuffer(model, { - next: buffer => - this._open(id, buffer, { - next: info => setInfo(info), - error, - complete, - }), - error: err => subscriber.error(err), - }); - } - }, - error, - complete, - }); - }); - - return ob$; - } - - // Creates a channel. - channel(id = nanoid()) { - let result = this.channels.get(id); - - if (!result) { - const { port1, port2: port } = new MessageChannel(); - const sender = new OpClient(port1); - - this.call('channel', transfer({ id, port }, [port])).catch(err => { - console.error(err); - }); - - result = this.channels.put(id, sender); - - sender.listen(); - } - - const { obj: sender, release } = result; - - return { sender, release }; - } - - override destroy() { - this.channels.clear(); - super.destroy(); - } - - [Symbol.dispose]() { - this.destroy(); - } -} diff --git a/packages/frontend/core/src/modules/pdf/workers/ops.ts b/packages/frontend/core/src/modules/pdf/workers/ops.ts deleted file mode 100644 index 5ddd446cc6624..0000000000000 --- a/packages/frontend/core/src/modules/pdf/workers/ops.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { OpSchema } from '@toeverything/infra/op'; - -import type { DocState, RenderKind, RenderOut } from './types'; - -export interface ClientOps extends OpSchema { - // Ping-Pong - pingpong: [{ id: string }, DocState]; - // Opens a PDF document - open: [{ id: string; buffer: ArrayBuffer }, DocState]; - // Creates a channel - channel: [{ id: string; port: MessagePort }, boolean]; -} - -export interface ChannelOps extends OpSchema { - // Renders image data by page index - render: [ - { - seq: number[]; - kind: RenderKind; - scale?: number; - }, - RenderOut | void, - ]; -} diff --git a/packages/frontend/core/src/modules/pdf/workers/types.ts b/packages/frontend/core/src/modules/pdf/workers/types.ts deleted file mode 100644 index 92392361a91dd..0000000000000 --- a/packages/frontend/core/src/modules/pdf/workers/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -export enum State { - IDLE = 0, - Loading, - Loaded, // WASM has been loaded and initialized. - Opening, - Opened, // A document has been opened. -} - -export type DocInfo = { - total: number; - width: number; - height: number; -}; - -export enum RenderKind { - Page, - Thumbnail, -} - -export type RenderOut = { - index: number; - width: number; - height: number; - kind: RenderKind; - buffer: Uint8ClampedArray; -}; - -export type DocState = { state: State } & DocInfo; - -export function defaultDocInfo(total = 1, width = 1, height = 1) { - return { total, width, height }; -} diff --git a/packages/frontend/core/src/modules/pdf/workers/utils.ts b/packages/frontend/core/src/modules/pdf/workers/utils.ts deleted file mode 100644 index 40b612bf1e163..0000000000000 --- a/packages/frontend/core/src/modules/pdf/workers/utils.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import type { Document, Viewer } from '@toeverything/pdf-viewer'; - -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(); -} - -export function resizeImageBitmap( - imageData: ImageData, - options: { - resizeWidth: number; - resizeHeight: number; - } -) { - return createImageBitmap(imageData, 0, 0, imageData.width, imageData.height, { - colorSpaceConversion: 'none', - resizeQuality: 'pixelated', - ...options, - }); -} - -export function renderToUint8ClampedArray( - viewer: Viewer, - doc: Document, - flags: number, - index: number, - width: number, - height: number -) { - const page = doc.page(index); - - if (!page) return; - - const bitmap = viewer.createBitmap(width, height, 0); - bitmap.fill(0, 0, width, height); - page.render(bitmap, 0, 0, width, height, 0, flags); - - const data = new Uint8ClampedArray(bitmap.toUint8Array()); - - bitmap.close(); - page.close(); - - return data; -} diff --git a/packages/frontend/core/src/modules/pdf/workers/worker.ts b/packages/frontend/core/src/modules/pdf/workers/worker.ts deleted file mode 100644 index e7f95656cdfa1..0000000000000 --- a/packages/frontend/core/src/modules/pdf/workers/worker.ts +++ /dev/null @@ -1,125 +0,0 @@ -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, filter, from, map, switchMap, take } from 'rxjs'; - -import type { ChannelOps, ClientOps } from './ops'; -import type { DocInfo } from './types'; -import { defaultDocInfo, State } from './types'; -import { renderToUint8ClampedArray } from './utils'; - -let viewer: Viewer | null = null; -let doc: Document | undefined = undefined; -const info: DocInfo = defaultDocInfo(); -const state$ = new BehaviorSubject(State.IDLE); -const FLAGS = PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT; - -// Pipes -const statePipe$ = state$.pipe(map(state => ({ state, ...info }))); - -state$.next(State.Loading); - -createPDFium() - .then(pdfium => { - viewer = new Viewer(new Runtime(pdfium)); - state$.next(State.Loaded); - }) - .catch(err => { - state$.error(err); - }); - -// Multiple channels can be processed in a worker. - -// @ts-expect-error fixme -const consumer = new OpConsumer(self); - -consumer.register('pingpong', () => { - return statePipe$; -}); - -consumer.register('open', ({ id: _, buffer }) => { - if (!viewer) { - return statePipe$; - } - - return state$ - .pipe( - take(1), - filter(s => s === State.Loaded) - ) - .pipe( - switchMap(() => { - if (doc) { - doc?.close(); - } - - state$.next(State.Opening); - - doc = viewer?.open(new Uint8Array(buffer)); - - if (!doc) { - Object.assign(info, defaultDocInfo()); - state$.next(State.Loaded); - return statePipe$; - } - - const page = doc.page(0); - if (!page) { - doc.close(); - Object.assign(info, defaultDocInfo()); - state$.next(State.Loaded); - return statePipe$; - } - - const rect = page.size(); - page.close(); - - const total = doc.pageCount(); - - Object.assign(info, { total, ...rect }); - state$.next(State.Opened); - return statePipe$; - }) - ); -}); - -consumer.register('channel', ({ id: _, port }) => { - const receiver = new OpConsumer(port); - - receiver.register('render', ({ seq, kind, scale = 1 }) => { - if (!viewer || !doc) return from([]).pipe(); - - const width = Math.ceil(info.width * scale); - const height = Math.ceil(info.height * scale); - - return from(seq).pipe( - map(index => { - if (!viewer || !doc) return; - - const buffer = renderToUint8ClampedArray( - viewer, - doc, - FLAGS, - index, - width, - height - ); - if (!buffer) return; - - return transfer({ index, kind, width, height, buffer }, [ - buffer.buffer, - ]); - }) - ); - }); - - receiver.listen(); - return true; -}); - -consumer.listen();