From 3a018cecbe66eae44e27a5f77e9ddc08e3d9eec6 Mon Sep 17 00:00:00 2001 From: Fangdun Tsai Date: Sat, 2 Nov 2024 02:47:41 +0800 Subject: [PATCH] feat: pdf embed view component --- .../frontend/component/src/utils/index.ts | 1 + .../src/utils/observe-intersection.ts | 83 ++++++ .../components/attachment-viewer/index.tsx | 28 +- .../pdf-viewer-embedded-inner.tsx | 249 ++++++++++++++++++ .../attachment-viewer/pdf-viewer-embedded.tsx | 14 + .../attachment-viewer/pdf-viewer-inner.tsx | 197 ++++++++++++++ .../attachment-viewer/pdf-viewer.tsx | 216 ++------------- .../attachment-viewer/styles.css.ts | 10 +- .../attachment-viewer/styles.embedded.css.ts | 88 +++++++ .../src/components/attachment-viewer/utils.ts | 5 +- .../block-suite-editor/lit-adaper.tsx | 2 + .../specs/custom/attachment-block.ts | 2 +- .../specs/custom/spec-patchers.tsx | 36 +++ .../core/src/modules/pdf/renderer/worker.ts | 10 +- .../core/src/modules/pdf/views/components.tsx | 6 + .../core/src/modules/pdf/views/index.ts | 1 + .../src/modules/pdf/views/page-renderer.tsx | 13 +- .../core/src/modules/pdf/views/styles.css.ts | 13 +- .../modules/peek-view/entities/peek-view.ts | 7 +- 19 files changed, 732 insertions(+), 249 deletions(-) create mode 100644 packages/frontend/component/src/utils/observe-intersection.ts create mode 100644 packages/frontend/core/src/components/attachment-viewer/pdf-viewer-embedded-inner.tsx create mode 100644 packages/frontend/core/src/components/attachment-viewer/pdf-viewer-embedded.tsx create mode 100644 packages/frontend/core/src/components/attachment-viewer/pdf-viewer-inner.tsx create mode 100644 packages/frontend/core/src/components/attachment-viewer/styles.embedded.css.ts diff --git a/packages/frontend/component/src/utils/index.ts b/packages/frontend/component/src/utils/index.ts index 6029e656438b0..8aab883c19eb9 100644 --- a/packages/frontend/component/src/utils/index.ts +++ b/packages/frontend/component/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from './observe-intersection'; export * from './observe-resize'; export { startScopedViewTransition } from './view-transition'; export * from './with-unit'; diff --git a/packages/frontend/component/src/utils/observe-intersection.ts b/packages/frontend/component/src/utils/observe-intersection.ts new file mode 100644 index 0000000000000..a00ed94a7b2f3 --- /dev/null +++ b/packages/frontend/component/src/utils/observe-intersection.ts @@ -0,0 +1,83 @@ +type ObserveIntersection = { + callback: (entity: IntersectionObserverEntry) => void; + dispose: () => void; +}; + +let _intersectionObserver: IntersectionObserver | null = null; +const elementsMap = new WeakMap>(); + +// for debugging +if (typeof window !== 'undefined') { + (window as any)._intersectionObserverElementsMap = elementsMap; +} + +/** + * @internal get or initialize the IntersectionObserver instance + */ +const getIntersectionObserver = () => + (_intersectionObserver ??= new IntersectionObserver( + entries => { + entries.forEach(entry => { + const listeners = elementsMap.get(entry.target) ?? []; + listeners.forEach(({ callback }) => callback(entry)); + }); + }, + { + rootMargin: '20px 20px 20px 20px', + threshold: 0.233, + } + )); + +/** + * @internal remove element's specific listener + */ +const removeListener = (element: Element, listener: ObserveIntersection) => { + if (!element) return; + const listeners = elementsMap.get(element) ?? []; + const observer = getIntersectionObserver(); + // remove the listener from the element + if (listeners.includes(listener)) { + elementsMap.set( + element, + listeners.filter(l => l !== listener) + ); + } + // if no more listeners, unobserve the element + if (elementsMap.get(element)?.length === 0) { + observer.unobserve(element); + elementsMap.delete(element); + } +}; + +/** + * A function to observe the intersection of an element use global IntersectionObserver. + * + * ```ts + * useEffect(() => { + * const dispose1 = observeIntersection(elRef1.current, (entry) => {}); + * const dispose2 = observeIntersection(elRef2.current, (entry) => {}); + * + * return () => { + * dispose1(); + * dispose2(); + * }; + * }, []) + * ``` + * @return A function to dispose the observer. + */ +export const observeIntersection = ( + element: Element, + callback: ObserveIntersection['callback'] +) => { + const observer = getIntersectionObserver(); + if (!elementsMap.has(element)) { + observer.observe(element); + } + const prevListeners = elementsMap.get(element) ?? []; + const listener = { callback, dispose: () => {} }; + listener.dispose = () => removeListener(element, listener); + + elementsMap.set(element, [...prevListeners, listener]); + + return listener.dispose; +}; diff --git a/packages/frontend/core/src/components/attachment-viewer/index.tsx b/packages/frontend/core/src/components/attachment-viewer/index.tsx index 9ab69a85583c6..3ed74e0f91181 100644 --- a/packages/frontend/core/src/components/attachment-viewer/index.tsx +++ b/packages/frontend/core/src/components/attachment-viewer/index.tsx @@ -2,7 +2,7 @@ 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 { PDFViewer, type PDFViewerProps } from './pdf-viewer'; import * as styles from './styles.css'; import { Titlebar } from './titlebar'; import { buildAttachmentProps } from './utils'; @@ -18,13 +18,7 @@ export const AttachmentViewer = ({ model }: AttachmentViewerProps) => { return (
- {model.type.endsWith('pdf') ? ( - - - - ) : ( - - )} +
); }; @@ -39,14 +33,18 @@ export const AttachmentViewerView = ({ model }: AttachmentViewerProps) => { - {model.type.endsWith('pdf') ? ( - - - - ) : ( - - )} + ); }; + +const AttachmentViewerInner = (props: PDFViewerProps) => { + return props.model.type.endsWith('pdf') ? ( + + + + ) : ( + + ); +}; diff --git a/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-embedded-inner.tsx b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-embedded-inner.tsx new file mode 100644 index 0000000000000..7948f1b0b477f --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-embedded-inner.tsx @@ -0,0 +1,249 @@ +import { IconButton, observeIntersection } from '@affine/component'; +import { + type PDF, + type PDFPage, + PDFService, + PDFStatus, +} from '@affine/core/modules/pdf'; +import { LoadingSvg, PDFPageCanvas } from '@affine/core/modules/pdf/views'; +import { PeekViewService } from '@affine/core/modules/peek-view'; +import { stopPropagation } from '@affine/core/utils'; +import { + ArrowDownSmallIcon, + ArrowUpSmallIcon, + AttachmentIcon, + CenterPeekIcon, +} from '@blocksuite/icons/rc'; +import { LiveData, useLiveData, useService } from '@toeverything/infra'; +import clsx from 'clsx'; +import { debounce } from 'lodash-es'; +import { + type MouseEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import type { PDFViewerProps } from './pdf-viewer'; +import * as styles from './styles.css'; +import * as embeddedStyles from './styles.embedded.css'; + +type PDFViewerEmbeddedInnerProps = PDFViewerProps; + +export function PDFViewerEmbeddedInner({ model }: PDFViewerEmbeddedInnerProps) { + const peekView = useService(PeekViewService).peekView; + const pdfService = useService(PDFService); + const [pdfEntity, setPdfEntity] = useState<{ + pdf: PDF; + release: () => void; + } | null>(null); + const [pageEntity, setPageEntity] = useState<{ + page: PDFPage; + release: () => void; + } | null>(null); + + const meta = useLiveData( + useMemo(() => { + return pdfEntity + ? pdfEntity.pdf.state$.map(s => { + return s.status === PDFStatus.Opened + ? s.meta + : { pageCount: 0, width: 0, height: 0 }; + }) + : new LiveData({ pageCount: 0, width: 0, height: 0 }); + }, [pdfEntity]) + ); + const img = useLiveData( + useMemo(() => { + return pageEntity ? pageEntity.page.bitmap$ : null; + }, [pageEntity]) + ); + + const [isLoading, setIsLoading] = useState(true); + const [cursor, setCursor] = useState(0); + const viewerRef = useRef(null); + const [visibility, setVisibility] = useState(false); + const canvasRef = useRef(null); + + const peek = useCallback(() => { + const target = model.doc.getBlock(model.id); + if (!target) return; + peekView.open({ element: target }).catch(console.error); + }, [peekView, model]); + + const navigator = useMemo(() => { + const p = cursor - 1; + const n = cursor + 1; + + return { + prev: { + disabled: p < 0, + onClick: (e: MouseEvent) => { + e.stopPropagation(); + setCursor(p); + }, + }, + next: { + disabled: n >= meta.pageCount, + onClick: (e: MouseEvent) => { + e.stopPropagation(); + setCursor(n); + }, + }, + peek: { + onClick: (e: MouseEvent) => { + e.stopPropagation(); + peek(); + }, + }, + }; + }, [cursor, meta, peek]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + if (!img) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const { width, height } = meta; + if (width * height === 0) return; + + setIsLoading(false); + + canvas.width = width * 2; + canvas.height = height * 2; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, 0, 0); + }, [img, meta]); + + useEffect(() => { + if (!visibility) return; + if (!pageEntity) return; + + const { width, height } = meta; + if (width * height === 0) return; + + pageEntity.page.render({ width, height, scale: 2 }); + + return () => { + pageEntity.page.render.unsubscribe(); + }; + }, [visibility, pageEntity, meta]); + + useEffect(() => { + if (!visibility) return; + if (!pdfEntity) return; + + const { width, height } = meta; + if (width * height === 0) return; + + const pageEntity = pdfEntity.pdf.page(cursor, `${width}:${height}:2`); + + setPageEntity(pageEntity); + + return () => { + pageEntity.release(); + setPageEntity(null); + }; + }, [visibility, pdfEntity, cursor, meta]); + + useEffect(() => { + if (!visibility) return; + + const pdfEntity = pdfService.get(model); + + setPdfEntity(pdfEntity); + + return () => { + pdfEntity.release(); + setPdfEntity(null); + }; + }, [model, pdfService, visibility]); + + useEffect(() => { + const viewer = viewerRef.current; + if (!viewer) return; + + return observeIntersection( + viewer, + debounce( + entry => { + setVisibility(entry.isIntersecting); + }, + 377, + { + trailing: true, + } + ) + ); + }, []); + + return ( +
+
+
+ + +
+ +
+ } + className={embeddedStyles.pdfControlButton} + onDoubleClick={stopPropagation} + {...navigator.prev} + /> + } + className={embeddedStyles.pdfControlButton} + onDoubleClick={stopPropagation} + {...navigator.next} + /> + } + className={embeddedStyles.pdfControlButton} + onDoubleClick={stopPropagation} + {...navigator.peek} + /> +
+
+
+
+ + {model.name} +
+
+ {meta.pageCount > 0 ? cursor + 1 : '-'}/ + {meta.pageCount > 0 ? meta.pageCount : '-'} +
+
+
+ ); +} diff --git a/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-embedded.tsx b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-embedded.tsx new file mode 100644 index 0000000000000..a0846beb36536 --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-embedded.tsx @@ -0,0 +1,14 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; + +import { PDFViewerEmbeddedInner } from './pdf-viewer-embedded-inner'; + +export interface PDFViewerEmbeddedProps { + model: AttachmentBlockModel; + name: string; + ext: string; + size: string; +} + +export function PDFViewerEmbedded(props: PDFViewerEmbeddedProps) { + return ; +} diff --git a/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-inner.tsx b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-inner.tsx new file mode 100644 index 0000000000000..ac2eac56b1480 --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer-inner.tsx @@ -0,0 +1,197 @@ +import { IconButton, observeResize } from '@affine/component'; +import type { + PDF, + PDFRendererState, + PDFStatus, +} from '@affine/core/modules/pdf'; +import { + Item, + List, + ListPadding, + ListWithSmallGap, + PDFPageRenderer, + type PDFVirtuosoContext, + type PDFVirtuosoProps, + Scroller, + ScrollSeekPlaceholder, +} from '@affine/core/modules/pdf/views'; +import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc'; +import clsx from 'clsx'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + type ScrollSeekConfiguration, + Virtuoso, + type VirtuosoHandle, +} from 'react-virtuoso'; + +import * as styles from './styles.css'; +import { calculatePageNum } from './utils'; + +const THUMBNAIL_WIDTH = 94; + +export interface PDFViewerInnerProps { + pdf: PDF; + state: Extract; +} + +export const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => { + const [cursor, setCursor] = useState(0); + const [collapsed, setCollapsed] = useState(true); + const [viewportInfo, setViewportInfo] = useState({ width: 0, height: 0 }); + + const viewerRef = useRef(null); + const pagesScrollerRef = useRef(null); + const pagesScrollerHandleRef = useRef(null); + const thumbnailsScrollerHandleRef = useRef(null); + + const updateScrollerRef = useCallback( + (scroller: HTMLElement | Window | null) => { + pagesScrollerRef.current = scroller as HTMLElement; + }, + [] + ); + + const onScroll = useCallback(() => { + const el = pagesScrollerRef.current; + if (!el) return; + + const { pageCount } = state.meta; + if (!pageCount) return; + + const cursor = calculatePageNum(el, pageCount); + + setCursor(cursor); + }, [pagesScrollerRef, state]); + + const onPageSelect = useCallback( + (index: number) => { + const scroller = pagesScrollerHandleRef.current; + if (!scroller) return; + + scroller.scrollToIndex({ + index, + align: 'center', + behavior: 'smooth', + }); + }, + [pagesScrollerHandleRef] + ); + + const pageContent = useCallback( + ( + index: number, + _: unknown, + { width, height, onPageSelect, pageClassName }: PDFVirtuosoContext + ) => { + return ( + + ); + }, + [pdf] + ); + + const thumbnailsConfig = useMemo(() => { + const { height: vh } = viewportInfo; + const { pageCount: t, height: h, width: w } = state.meta; + const p = h / (w || 1); + const pw = THUMBNAIL_WIDTH; + const ph = Math.ceil(pw * p); + const height = Math.min(vh - 60 - 24 - 24 - 2 - 8, t * ph + (t - 1) * 12); + return { + context: { + width: pw, + height: ph, + onPageSelect, + pageClassName: styles.pdfThumbnail, + }, + style: { height }, + }; + }, [state, viewportInfo, onPageSelect]); + + const scrollSeekConfig = useMemo(() => { + return { + enter: velocity => Math.abs(velocity) > 1024, + exit: velocity => Math.abs(velocity) < 10, + }; + }, []); + + useEffect(() => { + const viewer = viewerRef.current; + if (!viewer) return; + return observeResize(viewer, ({ contentRect: { width, height } }) => + setViewportInfo({ width, height }) + ); + }, []); + + return ( +
+ + key={pdf.id} + ref={pagesScrollerHandleRef} + scrollerRef={updateScrollerRef} + onScroll={onScroll} + className={styles.virtuoso} + totalCount={state.meta.pageCount} + itemContent={pageContent} + components={{ + Item, + List, + Scroller, + Header: ListPadding, + Footer: ListPadding, + ScrollSeekPlaceholder, + }} + context={{ + width: state.meta.width, + height: state.meta.height, + pageClassName: styles.pdfPage, + }} + scrollSeekConfiguration={scrollSeekConfig} + /> +
+
+ + key={`${pdf.id}-thumbnail`} + ref={thumbnailsScrollerHandleRef} + className={styles.virtuoso} + totalCount={state.meta.pageCount} + itemContent={pageContent} + components={{ + Item, + List: ListWithSmallGap, + Scroller, + ScrollSeekPlaceholder, + }} + scrollSeekConfiguration={scrollSeekConfig} + style={thumbnailsConfig.style} + context={thumbnailsConfig.context} + /> +
+
+
+ + {state.meta.pageCount > 0 ? cursor + 1 : 0} + + /{state.meta.pageCount} +
+ : } + onClick={() => setCollapsed(!collapsed)} + /> +
+
+
+ ); +}; diff --git a/packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx index 4de7630160621..c93292162c6c1 100644 --- a/packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx +++ b/packages/frontend/core/src/components/attachment-viewer/pdf-viewer.tsx @@ -1,215 +1,29 @@ -import { IconButton, observeResize } from '@affine/component'; -import { - type PDF, - type PDFRendererState, - PDFService, - PDFStatus, -} from '@affine/core/modules/pdf'; -import { - Item, - List, - ListPadding, - ListWithSmallGap, - LoadingSvg, - PDFPageRenderer, - type PDFVirtuosoContext, - type PDFVirtuosoProps, - Scroller, - ScrollSeekPlaceholder, -} from '@affine/core/modules/pdf/views'; +import { type PDF, PDFService, PDFStatus } from '@affine/core/modules/pdf'; +import { LoadingSvg } from '@affine/core/modules/pdf/views'; import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; -import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; -import clsx from 'clsx'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - type ScrollSeekConfiguration, - Virtuoso, - type VirtuosoHandle, -} from 'react-virtuoso'; +import { useEffect, useState } from 'react'; -import * as styles from './styles.css'; -import { calculatePageNum } from './utils'; +import { PDFViewerInner } from './pdf-viewer-inner'; -const THUMBNAIL_WIDTH = 94; - -interface ViewerProps { - model: AttachmentBlockModel; -} - -interface PDFViewerInnerProps { - pdf: PDF; - state: Extract; -} - -const PDFViewerInner = ({ pdf, state }: PDFViewerInnerProps) => { - const [cursor, setCursor] = useState(0); - const [collapsed, setCollapsed] = useState(true); - const [viewportInfo, setViewportInfo] = useState({ width: 0, height: 0 }); - - const viewerRef = useRef(null); - const pagesScrollerRef = useRef(null); - const pagesScrollerHandleRef = useRef(null); - const thumbnailsScrollerHandleRef = useRef(null); - - const onScroll = useCallback(() => { - const el = pagesScrollerRef.current; - if (!el) return; - - const { pageCount } = state.meta; - if (!pageCount) return; - - const cursor = calculatePageNum(el, pageCount); - - setCursor(cursor); - }, [pagesScrollerRef, state]); - - const onPageSelect = useCallback( - (index: number) => { - const scroller = pagesScrollerHandleRef.current; - if (!scroller) return; - - scroller.scrollToIndex({ - index, - align: 'center', - behavior: 'smooth', - }); - }, - [pagesScrollerHandleRef] - ); - - const pageContent = useCallback( - ( - index: number, - _: unknown, - { width, height, onPageSelect, pageClassName }: PDFVirtuosoContext - ) => { - return ( - - ); - }, - [pdf] - ); - - const thumbnailsConfig = useMemo(() => { - const { height: vh } = viewportInfo; - const { pageCount: t, height: h, width: w } = state.meta; - const p = h / (w || 1); - const pw = THUMBNAIL_WIDTH; - const ph = Math.ceil(pw * p); - const height = Math.min(vh - 60 - 24 - 24 - 2 - 8, t * ph + (t - 1) * 12); - return { - context: { - width: pw, - height: ph, - onPageSelect, - pageClassName: styles.pdfThumbnail, - }, - style: { height }, - }; - }, [state, viewportInfo, onPageSelect]); - - const scrollSeekConfig = useMemo(() => { - return { - enter: velocity => Math.abs(velocity) > 1024, - exit: velocity => Math.abs(velocity) < 10, - }; - }, []); - - useEffect(() => { - const viewer = viewerRef.current; - if (!viewer) return; - return observeResize(viewer, ({ contentRect: { width, height } }) => - setViewportInfo({ width, height }) - ); - }, []); - - return ( -
- - key={pdf.id} - ref={pagesScrollerHandleRef} - scrollerRef={scroller => { - pagesScrollerRef.current = scroller as HTMLElement; - }} - onScroll={onScroll} - className={styles.virtuoso} - totalCount={state.meta.pageCount} - itemContent={pageContent} - components={{ - Item, - List, - Scroller, - Header: ListPadding, - Footer: ListPadding, - ScrollSeekPlaceholder, - }} - context={{ - width: state.meta.width, - height: state.meta.height, - pageClassName: styles.pdfPage, - }} - scrollSeekConfiguration={scrollSeekConfig} - /> -
-
- - key={`${pdf.id}-thumbnail`} - ref={thumbnailsScrollerHandleRef} - className={styles.virtuoso} - totalCount={state.meta.pageCount} - itemContent={pageContent} - components={{ - Item, - List: ListWithSmallGap, - Scroller, - ScrollSeekPlaceholder, - }} - scrollSeekConfiguration={scrollSeekConfig} - style={thumbnailsConfig.style} - context={thumbnailsConfig.context} - /> -
-
-
- - {state.meta.pageCount > 0 ? cursor + 1 : 0} - - /{state.meta.pageCount} -
- : } - onClick={() => setCollapsed(!collapsed)} - /> -
-
-
- ); -}; - -function PDFViewerStatus({ pdf }: { pdf: PDF }) { +function PDFViewerStatus({ pdf, ...props }: PDFViewerProps & { pdf: PDF }) { const state = useLiveData(pdf.state$); if (state?.status !== PDFStatus.Opened) { return ; } - return ; + return ; +} + +export interface PDFViewerProps { + model: AttachmentBlockModel; + name: string; + ext: string; + size: string; } -export function PDFViewer({ model }: ViewerProps) { +export function PDFViewer({ model, ...props }: PDFViewerProps) { const pdfService = useService(PDFService); const [pdf, setPdf] = useState(null); @@ -224,5 +38,5 @@ export function PDFViewer({ model }: ViewerProps) { return ; } - return ; + return ; } diff --git a/packages/frontend/core/src/components/attachment-viewer/styles.css.ts b/packages/frontend/core/src/components/attachment-viewer/styles.css.ts index b1abeaa6c05d2..1e85631d4e6c5 100644 --- a/packages/frontend/core/src/components/attachment-viewer/styles.css.ts +++ b/packages/frontend/core/src/components/attachment-viewer/styles.css.ts @@ -49,6 +49,10 @@ export const titlebarName = style({ wordWrap: 'break-word', }); +export const virtuoso = style({ + width: '100%', +}); + export const error = style({ flexDirection: 'column', alignItems: 'center', @@ -106,10 +110,6 @@ export const viewer = style({ }, }); -export const virtuoso = style({ - width: '100%', -}); - export const pdfIndicator = style({ display: 'flex', alignItems: 'center', @@ -127,6 +127,7 @@ export const pdfPage = style({ boxShadow: '0px 4px 20px 0px var(--transparent-black-200, rgba(0, 0, 0, 0.10))', overflow: 'hidden', + maxHeight: 'max-content', }); export const pdfThumbnails = style({ @@ -156,7 +157,6 @@ export const pdfThumbnailsList = style({ flexDirection: 'column', maxHeight: '100%', overflow: 'hidden', - resize: 'both', selectors: { '&.collapsed': { display: 'none', diff --git a/packages/frontend/core/src/components/attachment-viewer/styles.embedded.css.ts b/packages/frontend/core/src/components/attachment-viewer/styles.embedded.css.ts new file mode 100644 index 0000000000000..06d2332c33fdf --- /dev/null +++ b/packages/frontend/core/src/components/attachment-viewer/styles.embedded.css.ts @@ -0,0 +1,88 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const pdfContainer = style({ + width: '100%', + borderRadius: '8px', + borderWidth: '1px', + borderStyle: 'solid', + borderColor: cssVarV2('layer/insideBorder/border'), + background: cssVar('--affine-background-primary-color'), + userSelect: 'none', + contentVisibility: 'visible', +}); + +export const pdfViewer = style({ + position: 'relative', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '12px', + overflow: 'hidden', + background: cssVarV2('layer/background/secondary'), +}); + +export const pdfPlaceholder = style({ + position: 'absolute', + maxWidth: 'calc(100% - 24px)', + overflow: 'hidden', + height: 'auto', + pointerEvents: 'none', +}); + +export const pdfControls = style({ + position: 'absolute', + bottom: '16px', + right: '14px', + display: 'flex', + flexDirection: 'column', + gap: '10px', +}); + +export const pdfControlButton = style({ + width: '36px', + height: '36px', + borderWidth: '1px', + borderStyle: 'solid', + borderColor: cssVar('--affine-border-color'), + background: cssVar('--affine-white'), +}); + +export const pdfFooter = style({ + display: 'flex', + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + gap: '12px', + padding: '12px', + textWrap: 'nowrap', +}); + +export const pdfFooterItem = style({ + display: 'flex', + alignItems: 'center', + selectors: { + '&.truncate': { + overflow: 'hidden', + }, + }, +}); + +export const pdfTitle = style({ + marginLeft: '8px', + fontSize: '14px', + fontWeight: 600, + lineHeight: '22px', + color: cssVarV2('text/primary'), + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); + +export const pdfPageCount = style({ + fontSize: '12px', + fontWeight: 400, + lineHeight: '20px', + color: cssVarV2('text/secondary'), +}); diff --git a/packages/frontend/core/src/components/attachment-viewer/utils.ts b/packages/frontend/core/src/components/attachment-viewer/utils.ts index fb0e5c8f8383b..b2cb707fef6a5 100644 --- a/packages/frontend/core/src/components/attachment-viewer/utils.ts +++ b/packages/frontend/core/src/components/attachment-viewer/utils.ts @@ -2,6 +2,7 @@ import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; import { filesize } from 'filesize'; import { downloadBlob } from '../../utils/resource'; +import type { PDFViewerProps } from './pdf-viewer'; export async function getAttachmentBlob(model: AttachmentBlockModel) { const sourceId = model.sourceId; @@ -26,7 +27,9 @@ export async function download(model: AttachmentBlockModel) { await downloadBlob(blob, model.name); } -export function buildAttachmentProps(model: AttachmentBlockModel) { +export function buildAttachmentProps( + model: AttachmentBlockModel +): PDFViewerProps { const pieces = model.name.split('.'); const ext = pieces.pop() || ''; const name = pieces.join('.'); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx index a2891cdf75126..d9bfa87ac47df 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx @@ -54,6 +54,7 @@ import { patchDocModeService, patchEdgelessClipboard, patchEmbedLinkedDocBlockConfig, + patchForAttachmentEmbedViews, patchForMobile, patchForSharedPage, patchNotificationService, @@ -143,6 +144,7 @@ const usePatchSpecs = (shared: boolean, mode: DocMode) => { let patched = specs.concat( patchReferenceRenderer(reactToLit, referenceRenderer) ); + patched = patched.concat(patchForAttachmentEmbedViews(reactToLit)); patched = patched.concat(patchNotificationService(confirmModal)); patched = patched.concat(patchPeekViewService(peekViewService)); patched = patched.concat(patchEdgelessClipboard()); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/attachment-block.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/attachment-block.ts index 17a938b228fab..42f2a533ac501 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/attachment-block.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/attachment-block.ts @@ -1,7 +1,7 @@ +import type { ExtensionType } from '@blocksuite/affine/block-std'; import { BlockFlavourIdentifier, BlockServiceIdentifier, - type ExtensionType, StdIdentifier, } from '@blocksuite/affine/block-std'; import { diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx index 580184947ff5d..6bbd17525ef29 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx @@ -7,6 +7,9 @@ import { toReactNode, type useConfirmModal, } from '@affine/component'; +import { AttachmentPreviewErrorBoundary } from '@affine/core/components/attachment-viewer/error'; +import { PDFViewerEmbedded } from '@affine/core/components/attachment-viewer/pdf-viewer-embedded'; +import { buildAttachmentProps } from '@affine/core/components/attachment-viewer/utils'; import type { EditorService } from '@affine/core/modules/editor'; import { EditorSettingService } from '@affine/core/modules/editor-setting'; import { resolveLinkToDoc } from '@affine/core/modules/navigation'; @@ -48,6 +51,7 @@ import { AFFINE_EMBED_CARD_TOOLBAR_WIDGET, AFFINE_FORMAT_BAR_WIDGET, AffineSlashMenuWidget, + AttachmentEmbedConfigIdentifier, DocModeExtension, EdgelessRootBlockComponent, EmbedLinkedDocBlockComponent, @@ -60,6 +64,7 @@ import { ReferenceNodeConfigExtension, ReferenceNodeConfigIdentifier, } from '@blocksuite/affine/blocks'; +import { Bound } from '@blocksuite/affine/global/utils'; import { type BlockSnapshot, Text } from '@blocksuite/affine/store'; import { AIChatBlockSchema, @@ -620,3 +625,34 @@ export function patchForMobile() { }; return extension; } + +export function patchForAttachmentEmbedViews( + reactToLit: (element: ElementOrFactory) => TemplateResult +): ExtensionType { + return { + setup: di => { + di.override(AttachmentEmbedConfigIdentifier('pdf'), () => ({ + name: 'pdf', + check: (model, maxFileSize) => + model.type === 'application/pdf' && model.size <= maxFileSize, + action: model => { + const bound = Bound.deserialize(model.xywh); + bound.w = 537 + 24 + 2; + bound.h = 759 + 46 + 24 + 2; + model.doc.updateBlock(model, { + embed: true, + style: 'pdf', + xywh: bound.serialize(), + }); + }, + template: (model, _blobUrl) => + // TODO(@fundon): fixme, unable to release pdf worker when switching view + reactToLit( + + + + ), + })); + }, + }; +} diff --git a/packages/frontend/core/src/modules/pdf/renderer/worker.ts b/packages/frontend/core/src/modules/pdf/renderer/worker.ts index b435c8006ea03..cd5f9351f940b 100644 --- a/packages/frontend/core/src/modules/pdf/renderer/worker.ts +++ b/packages/frontend/core/src/modules/pdf/renderer/worker.ts @@ -103,8 +103,9 @@ class PDFRendererBackend extends OpConsumer { if (!page) return; - const width = Math.ceil(opts.width * (opts.scale ?? 1)); - const height = Math.ceil(opts.height * (opts.scale ?? 1)); + const scale = opts.scale ?? 1; + const width = Math.ceil(opts.width * scale); + const height = Math.ceil(opts.height * scale); const bitmap = viewer.createBitmap(width, height, 0); bitmap.fill(0, 0, width, height); @@ -119,9 +120,8 @@ class PDFRendererBackend extends OpConsumer { ); const data = new Uint8ClampedArray(bitmap.toUint8Array()); - const imageBitmap = await createImageBitmap( - new ImageData(data, width, height) - ); + const imageData = new ImageData(data, width, height); + const imageBitmap = await createImageBitmap(imageData); bitmap.close(); page.close(); diff --git a/packages/frontend/core/src/modules/pdf/views/components.tsx b/packages/frontend/core/src/modules/pdf/views/components.tsx index 05c5fa87f6a33..c7af3e29ca89b 100644 --- a/packages/frontend/core/src/modules/pdf/views/components.tsx +++ b/packages/frontend/core/src/modules/pdf/views/components.tsx @@ -113,3 +113,9 @@ export const LoadingSvg = memo( ); LoadingSvg.displayName = 'pdf-loading'; + +export const PDFPageCanvas = forwardRef((props, ref) => { + return ; +}); + +PDFPageCanvas.displayName = 'pdf-page-canvas'; diff --git a/packages/frontend/core/src/modules/pdf/views/index.ts b/packages/frontend/core/src/modules/pdf/views/index.ts index 7314709cc2d3b..2395d2230d1a6 100644 --- a/packages/frontend/core/src/modules/pdf/views/index.ts +++ b/packages/frontend/core/src/modules/pdf/views/index.ts @@ -4,6 +4,7 @@ export { ListPadding, ListWithSmallGap, LoadingSvg, + PDFPageCanvas, type PDFVirtuosoContext, type PDFVirtuosoProps, Scroller, diff --git a/packages/frontend/core/src/modules/pdf/views/page-renderer.tsx b/packages/frontend/core/src/modules/pdf/views/page-renderer.tsx index 80a3dcb6bb840..e9ad029df53f1 100644 --- a/packages/frontend/core/src/modules/pdf/views/page-renderer.tsx +++ b/packages/frontend/core/src/modules/pdf/views/page-renderer.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react'; import type { PDF } from '../entities/pdf'; import type { PDFPage } from '../entities/pdf-page'; -import { LoadingSvg } from './components'; +import { LoadingSvg, PDFPageCanvas } from './components'; import * as styles from './styles.css'; interface PDFPageProps { @@ -34,6 +34,8 @@ export const PDFPageRenderer = ({ const style = { width, aspectRatio: `${width} / ${height}` }; useEffect(() => { + if (width * height === 0) return; + const { page, release } = pdf.page(pageNum, `${width}:${height}:${scale}`); setPdfPage(page); @@ -41,6 +43,8 @@ export const PDFPageRenderer = ({ }, [pdf, width, height, pageNum, scale]); useEffect(() => { + if (width * height === 0) return; + pdfPage?.render({ width, height, scale }); return pdfPage?.render.unsubscribe; @@ -52,6 +56,7 @@ export const PDFPageRenderer = ({ if (!img) return; const ctx = canvas.getContext('2d'); if (!ctx) return; + if (width * height === 0) return; canvas.width = width * scale; canvas.height = height * scale; @@ -74,11 +79,7 @@ export const PDFPageRenderer = ({ style={style} onClick={() => onSelect?.(pageNum)} > - {img === null ? ( - - ) : ( - - )} + {img === null ? : } ); }; diff --git a/packages/frontend/core/src/modules/pdf/views/styles.css.ts b/packages/frontend/core/src/modules/pdf/views/styles.css.ts index 0b45c32164b15..6584895a85514 100644 --- a/packages/frontend/core/src/modules/pdf/views/styles.css.ts +++ b/packages/frontend/core/src/modules/pdf/views/styles.css.ts @@ -25,18 +25,6 @@ export const virtuosoItem = style({ 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', @@ -62,4 +50,5 @@ export const pdfLoading = style({ width: '100%', height: '100%', maxWidth: '537px', + overflow: 'hidden', }); diff --git a/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts b/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts index bc2537b95e348..610fc7b907467 100644 --- a/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts +++ b/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts @@ -9,7 +9,7 @@ import type { SurfaceRefBlockModel, } from '@blocksuite/affine/blocks'; import { AffineReference } from '@blocksuite/affine/blocks'; -import type { BlockModel } from '@blocksuite/affine/store'; +import type { Block, BlockModel } from '@blocksuite/affine/store'; import type { AIChatBlockModel } from '@toeverything/infra'; import { Entity, LiveData } from '@toeverything/infra'; import type { TemplateResult } from 'lit'; @@ -35,7 +35,8 @@ export type PeekViewElement = | HTMLElement | BlockComponent | AffineReference - | HTMLAnchorElement; + | HTMLAnchorElement + | Block; export interface PeekViewTarget { element?: PeekViewElement; @@ -182,7 +183,7 @@ function resolvePeekInfoFromPeekTarget( blockIds: [blockModel.id], }, }; - } else if (isAIChatBlockModel(blockModel)) { + } else if (isAIChatBlockModel(blockModel) && 'host' in element) { return { type: 'ai-chat-block', docRef: {