diff --git a/packages/frontend/core/src/blocksuite/database-block/properties/file/view.tsx b/packages/frontend/core/src/blocksuite/database-block/properties/file/view.tsx index 8f2d04ac91..75ac7f2192 100644 --- a/packages/frontend/core/src/blocksuite/database-block/properties/file/view.tsx +++ b/packages/frontend/core/src/blocksuite/database-block/properties/file/view.tsx @@ -1,6 +1,7 @@ import { Popover, uniReactRoot } from '@affine/component'; import { Button } from '@affine/component/ui/button'; import { Menu, MenuItem } from '@affine/component/ui/menu'; +import { PeekViewService } from '@affine/core/modules/peek-view'; import { type Cell, type CellRenderProps, @@ -40,6 +41,7 @@ import { import { WorkspaceDialogService } from '../../../../modules/dialogs'; import { useSignalValue } from '../../../../modules/doc-info/utils'; +import type { ImageData } from '../../../../modules/peek-view/view/image-preview'; import { CircularProgress } from '../../components/loading'; import { progressIconContainer } from '../../components/loading.css'; import type { @@ -216,7 +218,8 @@ class FileCellManager { } constructor( - props: CellRenderProps<{}, FileCellRawValueType, FileCellJsonValueType> + props: CellRenderProps<{}, FileCellRawValueType, FileCellJsonValueType>, + private readonly peekViewService: PeekViewService ) { this.cell = props.cell; this.selectCurrentCell = props.selectCurrentCell; @@ -298,6 +301,46 @@ class FileCellManager { a.order > b.order ? 1 : -1 ); }); + + openPreview = (id: string) => { + const imageList = this.fileList.value + .filter(v => v.type === 'done') + .map(v => ({ + ...v, + ...this.fileUploadManager?.getFileInfo(v.id).value, + })) + .filter(v => SUPPORTED_IMAGE_MIME_TYPES.has(v?.fileType?.mime ?? '')); + const getImageData = (index: number): ImageData | undefined => { + const file = imageList[index]; + if (!file) return; + const previousIndex = index - 1; + const nextIndex = index + 1; + const hasPrevious = previousIndex >= 0; + const hasNext = nextIndex < imageList.length; + return { + index, + url: file.url ?? '', + caption: file.name, + previous: hasPrevious ? () => getImageData(previousIndex) : undefined, + next: hasNext ? () => getImageData(nextIndex) : undefined, + }; + }; + const currentIndex = imageList.findIndex(v => v.id === id); + if (currentIndex === -1) return; + const imageData = getImageData(currentIndex); + if (!imageData) return; + this.peekViewService.peekView + .open({ + type: 'image-list', + data: { + image: imageData, + total: imageList.length, + }, + }) + .catch(error => { + console.error('Failed to open image list', error); + }); + }; } const SUPPORTED_IMAGE_MIME_TYPES = new Set([ @@ -317,8 +360,9 @@ const FileCellComponent: ForwardRefRenderFunction< DataViewCellLifeCycle, CellRenderProps<{}, FileCellRawValueType, FileCellJsonValueType> > = (props, ref): ReactNode => { + const peekView = useService(PeekViewService); // eslint-disable-next-line react-hooks/exhaustive-deps - const manager = useMemo(() => new FileCellManager(props), []); + const manager = useMemo(() => new FileCellManager(props, peekView), []); useEffect(() => { return () => { @@ -396,7 +440,7 @@ const FileCellComponent: ForwardRefRenderFunction< key={file.id} file={file} handleRemoveFile={manager.removeFile} - fileUploadManager={manager.fileUploadManager} + manager={manager} /> ))} @@ -440,10 +484,7 @@ const FileCellComponent: ForwardRefRenderFunction<
{fileList.map(file => (
- +
))}
@@ -453,11 +494,13 @@ const FileCellComponent: ForwardRefRenderFunction< const useFilePreview = ( file: FileItemRenderType, - fileUploadManager?: FileUploadManager + manager: FileCellManager ): { preview: ReactNode; + onPreview?: () => void; fileType: 'uploading' | 'loading' | 'image' | 'file'; } => { + const fileUploadManager = manager.fileUploadManager; const uploadProgress = useSignalValue( file.type === 'uploading' ? fileUploadManager?.getUploadProgress(file.id) @@ -486,9 +529,18 @@ const useFilePreview = ( fileType: 'loading', }; } + const onPreview = () => { + manager.openPreview(file.id); + }; return { + onPreview, preview: ( { + e.stopPropagation(); + e.preventDefault(); + onPreview(); + }} className={styles.imagePreviewIcon} src={loadFileData.url} alt={file.name} @@ -507,11 +559,11 @@ const useFilePreview = ( export const FileListItem = (props: { file: FileItemRenderType; handleRemoveFile: (file: FileItemRenderType, e?: MouseEvent) => void; - fileUploadManager?: FileUploadManager; + manager: FileCellManager; }) => { - const { file, handleRemoveFile, fileUploadManager } = props; - - const { preview, fileType } = useFilePreview(file, fileUploadManager); + const { file, handleRemoveFile, manager } = props; + const { preview, fileType, onPreview } = useFilePreview(file, manager); + const fileUploadManager = manager.fileUploadManager; const handleDownloadFile = useCallback( async (fileId: string, e?: MouseEvent) => { @@ -541,18 +593,17 @@ export const FileListItem = (props: { }, [fileUploadManager, file.name] ); + const menuItems = ( <> - {/* {fileType === 'image' && ( + {fileType === 'image' && ( { - console.log('Preview image:', file.id); - }} + onClick={onPreview} prefixIcon={} > Preview - )} */} + )} {(fileType === 'file' || fileType === 'image') && ( { @@ -579,7 +630,10 @@ export const FileListItem = (props: { return (
-
+
{fileType === 'image' ? (
{preview}
) : ( @@ -606,15 +660,22 @@ export const FileListItem = (props: { const FilePreview = (props: { file: FileItemRenderType; - fileUploadManager?: FileUploadManager; + manager: FileCellManager; }) => { - const { file, fileUploadManager } = props; - const { preview, fileType } = useFilePreview(file, fileUploadManager); + const { file, manager } = props; + const { preview, fileType } = useFilePreview(file, manager); if (fileType === 'file') { return
{file.name}
; } if (fileType === 'image') { - return
{preview}
; + return ( +
+ {preview} +
+ ); } return preview; }; 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 f06d9917eb..64cf57179a 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 @@ -17,6 +17,7 @@ import { firstValueFrom, map, race } from 'rxjs'; import type { AIChatBlockModel } from '../../../blocksuite/ai/blocks'; import { resolveLinkToDoc } from '../../navigation'; import type { WorkbenchService } from '../../workbench'; +import type { ImagePreviewData } from '../view/image-preview'; export type DocReferenceInfo = { docId: string; @@ -49,6 +50,11 @@ export interface DocPeekViewInfo { docRef: DocReferenceInfo; } +export type ImageListPeekViewInfo = { + type: 'image-list'; + data: ImagePreviewData; +}; + export type ImagePeekViewInfo = { type: 'image'; docRef: DocReferenceInfo; @@ -78,7 +84,8 @@ export type ActivePeekView = { | ImagePeekViewInfo | AttachmentPeekViewInfo | CustomTemplatePeekViewInfo - | AIChatBlockPeekViewInfo; + | AIChatBlockPeekViewInfo + | ImageListPeekViewInfo; }; const isEmbedLinkedDocModel = ( @@ -241,11 +248,21 @@ export class PeekViewEntity extends Entity { // return true if the peek view will be handled open = async ( - target: ActivePeekView['target'], + targetOrInfo: ActivePeekView['target'] | ActivePeekView['info'], template?: TemplateResult, abortSignal?: AbortSignal ) => { - const resolvedInfo = resolvePeekInfoFromPeekTarget(target, template); + let target: ActivePeekView['target']; + let resolvedInfo: ActivePeekView['info'] | undefined; + + if ('type' in targetOrInfo) { + resolvedInfo = targetOrInfo; + target = {}; + } else { + target = targetOrInfo; + resolvedInfo = resolvePeekInfoFromPeekTarget(target, template); + } + if (!resolvedInfo) { return; } diff --git a/packages/frontend/core/src/modules/peek-view/view/image-preview/index.tsx b/packages/frontend/core/src/modules/peek-view/view/image-preview/index.tsx index 58ebddc48b..038f98aef0 100644 --- a/packages/frontend/core/src/modules/peek-view/view/image-preview/index.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/image-preview/index.tsx @@ -1,7 +1,6 @@ import { Divider, Loading, toast } from '@affine/component'; import { Button, IconButton } from '@affine/component/ui/button'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; -import { useI18n } from '@affine/i18n'; import type { ImageBlockModel } from '@blocksuite/affine/model'; import type { BlockModel, Workspace } from '@blocksuite/affine/store'; import { @@ -37,9 +36,24 @@ import { useEditor } from '../utils'; import { useZoomControls } from './hooks/use-zoom'; import * as styles from './index.css'; -const filterImageBlock = (block: BlockModel): block is ImageBlockModel => { - return block.flavour === 'affine:image'; -}; +export interface ImageData { + index?: number; + url: string; + caption?: string; + onDelete?: () => void; + previous?: () => ImageData | undefined; + next?: () => ImageData | undefined; +} + +export interface ImagePreviewData { + image: ImageData; + total?: number; +} + +export interface ImagePreviewProps extends ImagePreviewData { + onClose: () => void; + blobId?: string; +} async function copyImageToClipboard(url: string) { const blob = await resourceUrlToBlob(url); @@ -55,111 +69,23 @@ async function copyImageToClipboard(url: string) { } } -export type ImagePreviewModalProps = { - docId: string; - blockId: string; -}; - -function useImageBlob( - docCollection: Workspace, - docId: string, - blockId: string -) { - const { data, error, isLoading } = useSWR( - ['workspace', 'image', docId, blockId], - { - fetcher: async ([_, __, pageId, blockId]) => { - const page = docCollection.getDoc(pageId)?.getStore(); - const block = page?.getBlock(blockId); - if (!block) { - return null; - } - const blockModel = block.model as ImageBlockModel; - return await docCollection.blobSync.get( - blockModel.props.sourceId as string - ); - }, - suspense: false, - } - ); - - return { data, error, isLoading }; -} - -const ImagePreview = forwardRef< +const GenericImagePreview = forwardRef< HTMLImageElement, - { - docCollection: Workspace; - docId: string; - blockId: string; - } & ImgHTMLAttributes ->(function ImagePreview({ docCollection, docId, blockId, ...props }, ref) { - const { data, error, isLoading } = useImageBlob( - docCollection, - docId, - blockId - ); - - const [blobUrl, setBlobUrl] = useState(null); - - const t = useI18n(); - - useEffect(() => { - let blobUrl = null; - if (data) { - blobUrl = URL.createObjectURL(data); - setBlobUrl(blobUrl); - } - return () => { - if (blobUrl) { - URL.revokeObjectURL(blobUrl); - } - }; - }, [data]); - - if (error) { - return
{t['error.NOT_FOUND']()}
; - } - - if (!blobUrl || isLoading) { + ImgHTMLAttributes +>(function GenericImagePreview(props, ref) { + if (!props.src) { return ; } - return ( - - ); + return ; }); -const ImagePreviewModalImpl = ({ - docId, - blockId, - onBlockIdChange, +export const GenericImagePreviewModal = ({ + image, + total, onClose, -}: ImagePreviewModalProps & { - onBlockIdChange: (blockId: string) => void; - onClose: () => void; -}): ReactElement | null => { - const { doc, workspace } = useEditor(docId); - const blocksuiteDoc = doc?.blockSuiteDoc; - const docCollection = workspace.docCollection; - const blockModel = useMemo(() => { - const block = blocksuiteDoc?.getBlock(blockId); - if (!block) { - return null; - } - return block.model as ImageBlockModel; - }, [blockId, blocksuiteDoc]); - const caption = useMemo(() => { - return blockModel?.props.caption ?? ''; - }, [blockModel?.props.caption]); - const [blocks, setBlocks] = useState([]); - const [cursor, setCursor] = useState(0); + blobId, +}: ImagePreviewProps): ReactElement => { const zoomRef = useRef(null); const imageRef = useRef(null); const { @@ -174,107 +100,23 @@ const ImagePreviewModalImpl = ({ currentScale, } = useZoomControls({ zoomRef, imageRef }); - const goto = useCallback( - (index: number) => { - const block = blocks[index]; - - if (!block) return; - - setCursor(index); - onBlockIdChange(block.id); - resetZoom(); - }, - [blocks, onBlockIdChange, resetZoom] - ); - - const deleteHandler = useCallback( - (index: number) => { - if (!blocksuiteDoc) { - return; - } - - let block = blocks[index]; - - if (!block) return; - const newBlocks = blocks.toSpliced(index, 1); - setBlocks(newBlocks); - - blocksuiteDoc.deleteBlock(block); - - // next - block = newBlocks[index]; - - // prev - if (!block) { - index -= 1; - block = newBlocks[index]; - - if (!block) { - onClose(); - return; - } - - setCursor(index); - } - - onBlockIdChange(block.id); - - resetZoom(); - }, - [blocksuiteDoc, blocks, onBlockIdChange, resetZoom, onClose] - ); const downloadHandler = useAsyncCallback(async () => { - const image = imageRef.current; - if (!image?.src) return; - const filename = caption || blockModel?.id || 'image'; - await downloadResourceWithUrl(image.src, filename); - }, [caption, blockModel?.id]); + if (!image.url) return; + const filename = image.caption || 'image'; + await downloadResourceWithUrl(image.url, filename); + }, [image]); const copyHandler = useAsyncCallback(async () => { - const image = imageRef.current; - if (!image?.src) return; - await copyImageToClipboard(image.src); - }, []); - - useEffect(() => { - if (!blockModel || !blocksuiteDoc) { - return; - } - - const prevs = blocksuiteDoc.getPrevs(blockModel).filter(filterImageBlock); - const nexts = blocksuiteDoc.getNexts(blockModel).filter(filterImageBlock); - - const blocks = [...prevs, blockModel, ...nexts]; - setBlocks(blocks); - setCursor(blocks.length ? prevs.length : 0); - }, [setBlocks, blockModel, blocksuiteDoc]); + if (!image.url) return; + await copyImageToClipboard(image.url); + }, [image.url]); useEffect(() => { const handleKeyUp = (event: KeyboardEvent) => { - if (!blocksuiteDoc || !blockModel) { - return; - } - - if (event.key === 'ArrowLeft') { - const prevBlock = blocksuiteDoc - .getPrevs(blockModel) - .findLast( - (block): block is ImageBlockModel => - block.flavour === 'affine:image' - ); - if (prevBlock) { - onBlockIdChange(prevBlock.id); - } - } else if (event.key === 'ArrowRight') { - const nextBlock = blocksuiteDoc - .getNexts(blockModel) - .find( - (block): block is ImageBlockModel => - block.flavour === 'affine:image' - ); - if (nextBlock) { - onBlockIdChange(nextBlock.id); - } + if (event.key === 'ArrowLeft' && image.previous) { + image.previous(); + } else if (event.key === 'ArrowRight' && image.next) { + image.next(); } else { return; } @@ -285,7 +127,6 @@ const ImagePreviewModalImpl = ({ const onCopyEvent = (event: ClipboardEvent) => { event.preventDefault(); event.stopPropagation(); - copyHandler(); }; @@ -295,7 +136,7 @@ const ImagePreviewModalImpl = ({ document.removeEventListener('keyup', handleKeyUp); document.removeEventListener('copy', onCopyEvent); }; - }, [blockModel, blocksuiteDoc, copyHandler, onBlockIdChange]); + }, [copyHandler, image]); return (
- - {caption} + {image.caption}

)}
@@ -337,12 +176,12 @@ const ImagePreviewModalImpl = ({
- {isZoomedBigger && caption !== '' ? ( + {isZoomedBigger && image.caption ? (

- {caption} + {image.caption}

) : null}
@@ -350,18 +189,20 @@ const ImagePreviewModalImpl = ({ data-testid="previous-image-button" tooltip="Previous" icon={} - disabled={cursor < 1} - onClick={() => goto(cursor - 1)} + disabled={!image.previous} + onClick={image.previous} /> -
- {`${blocks.length ? cursor + 1 : 0}/${blocks.length}`} -
+ {image.index != null && total != null && ( +
+ {`${image.index + 1}/${total}`} +
+ )} } - disabled={cursor + 1 === blocks.length} - onClick={() => goto(cursor + 1)} + disabled={!image.next} + onClick={image.next} /> } onClick={copyHandler} /> - {blockModel && !blockModel.doc.readonly && ( + {image.onDelete && ( <> } - disabled={blocks.length === 0} - onClick={() => deleteHandler(cursor)} + onClick={image.onDelete} variant="danger" /> @@ -422,6 +262,171 @@ const ImagePreviewModalImpl = ({ ); }; +// Adapter layer +export type ImagePreviewModalProps = { + docId: string; + blockId: string; +}; + +const useImageBlob = ( + docCollection: Workspace, + docId: string, + blockId: string +) => { + const { data, error, isLoading } = useSWR( + ['workspace', 'image', docId, blockId], + { + fetcher: async ([_, __, pageId, blockId]) => { + const page = docCollection.getDoc(pageId)?.getStore(); + const block = page?.getBlock(blockId); + if (!block) { + return null; + } + const blockModel = block.model as ImageBlockModel; + return await docCollection.blobSync.get( + blockModel.props.sourceId as string + ); + }, + suspense: false, + } + ); + + return { data, error, isLoading }; +}; + +const ImagePreviewModalImpl = ({ + docId, + blockId, + onBlockIdChange, + onClose, +}: ImagePreviewModalProps & { + onBlockIdChange: (blockId: string) => void; + onClose: () => void; +}): ReactElement | null => { + const { doc, workspace } = useEditor(docId); + const blocksuiteDoc = doc?.blockSuiteDoc; + const docCollection = workspace.docCollection; + const blockModel = useMemo(() => { + const block = blocksuiteDoc?.getBlock(blockId); + if (!block) { + return null; + } + return block.model as ImageBlockModel; + }, [blockId, blocksuiteDoc]); + + const { + data: blobData, + error, + isLoading, + } = useImageBlob(docCollection, docId, blockId); + + const [blobUrl, setBlobUrl] = useState(null); + + useEffect(() => { + let blobUrl = null; + if (blobData) { + blobUrl = URL.createObjectURL(blobData); + setBlobUrl(blobUrl); + } + return () => { + if (blobUrl) { + URL.revokeObjectURL(blobUrl); + } + }; + }, [blobData]); + + const [blocks, setBlocks] = useState([]); + const [cursor, setCursor] = useState(0); + + useEffect(() => { + if (!blockModel || !blocksuiteDoc) { + return; + } + + const prevs = blocksuiteDoc.getPrevs(blockModel).filter(filterImageBlock); + const nexts = blocksuiteDoc.getNexts(blockModel).filter(filterImageBlock); + + const blocks = [...prevs, blockModel, ...nexts]; + setBlocks(blocks); + setCursor(blocks.length ? prevs.length : 0); + }, [blockModel, blocksuiteDoc]); + + if (error || !blobUrl || isLoading || !blockModel) { + return null; + } + + const createImageData = (index: number): ImageData => { + const prevBlock = blocks[index - 1]; + const nextBlock = blocks[index + 1]; + return { + index, + url: blobUrl, + caption: blockModel.props.caption, + onDelete: !blockModel.doc.readonly + ? () => { + handleDelete(); + } + : undefined, + previous: prevBlock + ? () => { + onBlockIdChange(prevBlock.id); + return createImageData(index - 1); + } + : undefined, + next: nextBlock + ? () => { + onBlockIdChange(nextBlock.id); + return createImageData(index + 1); + } + : undefined, + }; + }; + + const imageData: ImageData = createImageData(cursor); + + const handleDelete = () => { + if (!blocksuiteDoc) { + return; + } + + const currentBlock = blocks[cursor]; + if (!currentBlock) return; + + const newBlocks = blocks.toSpliced(cursor, 1); + setBlocks(newBlocks); + blocksuiteDoc.deleteBlock(currentBlock); + + let nextBlock = newBlocks[cursor]; + + if (!nextBlock) { + const prevIndex = cursor - 1; + nextBlock = newBlocks[prevIndex]; + + if (!nextBlock) { + onClose(); + return; + } + + setCursor(prevIndex); + } + + onBlockIdChange(nextBlock.id); + }; + + return ( + + ); +}; + +const filterImageBlock = (block: BlockModel): block is ImageBlockModel => { + return block.flavour === 'affine:image'; +}; + export const ImagePreviewPeekView = ( props: ImagePreviewModalProps ): ReactElement | null => { @@ -455,3 +460,51 @@ export const ImagePreviewPeekView = ( ); }; + +export const GenericImagePreviewModalWithClose = ( + props: Omit +) => { + const peekViewService = useService(PeekViewService); + const handleClose = useCallback(() => { + peekViewService.peekView.close(); + }, [peekViewService]); + + const [image, setImage] = useState(props.image); + + const prevImage = useCallback(() => { + const prev = image.previous?.(); + if (!prev) return; + setImage(prev); + return prev; + }, [image]); + + const nextImage = useCallback(() => { + const next = image.next?.(); + if (!next) return; + setImage(next); + return next; + }, [image]); + return ( + <> + + + + ); +}; diff --git a/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx index da002ffe68..50f21f4f2e 100644 --- a/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx @@ -8,7 +8,10 @@ import { PeekViewService } from '../services/peek-view'; import { AIChatBlockPeekView } from './ai-chat-block-peek-view'; import { AttachmentPreviewPeekView } from './attachment-preview'; import { DocPeekPreview } from './doc-preview'; -import { ImagePreviewPeekView } from './image-preview'; +import { + GenericImagePreviewModalWithClose, + ImagePreviewPeekView, +} from './image-preview'; import { PeekViewModalContainer, type PeekViewModalContainerProps, @@ -45,6 +48,10 @@ function renderPeekView({ info }: ActivePeekView, animating?: boolean) { ); } + if (info.type === 'image-list') { + return ; + } + if (info.type === 'ai-chat-block') { return ; } @@ -61,7 +68,7 @@ const renderControls = ({ info }: ActivePeekView) => { return ; } - if (info.type === 'image') { + if (info.type === 'image' || info.type === 'image-list') { return null; // image controls are rendered in the image preview } @@ -69,7 +76,7 @@ const renderControls = ({ info }: ActivePeekView) => { }; const getMode = (info: ActivePeekView['info']) => { - if (info.type === 'image') { + if (info.type === 'image' || info.type === 'image-list') { return 'full'; } return 'fit'; @@ -94,7 +101,7 @@ const getRendererProps = ( : undefined, mode: getMode(activePeekView.info), animation: 'fadeBottom', - dialogFrame: activePeekView.info.type !== 'image', + dialogFrame: !['image', 'image-list'].includes(activePeekView.info.type), }; };