import { Tooltip } from '@affine/component'; import type { ImageBlockModel } from '@blocksuite/blocks'; import { assertExists } from '@blocksuite/global/utils'; import { ArrowLeftSmallIcon, ArrowRightSmallIcon, CopyIcon, DeleteIcon, DownloadIcon, MinusIcon, PlusIcon, ViewBarIcon, } from '@blocksuite/icons'; import type { Workspace } from '@blocksuite/store'; import { Button, IconButton } from '@toeverything/components/button'; import clsx from 'clsx'; import { useErrorBoundary } from 'foxact/use-error-boundary'; import { useAtom } from 'jotai'; import type { PropsWithChildren, ReactElement } from 'react'; import { Suspense, useCallback } from 'react'; import { useEffect, useRef, useState } from 'react'; import type { FallbackProps } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary'; import useSWR from 'swr'; import { useZoomControls } from './hooks/use-zoom'; import { buttonStyle, captionStyle, groupStyle, imageBottomContainerStyle, imagePreviewActionBarStyle, imagePreviewBackgroundStyle, imagePreviewModalCaptionStyle, imagePreviewModalCenterStyle, imagePreviewModalCloseButtonStyle, imagePreviewModalContainerStyle, imagePreviewModalStyle, loaded, scaleIndicatorButtonStyle, unloaded, } from './index.css'; import { hasAnimationPlayedAtom, previewBlockIdAtom } from './index.jotai'; import { toast } from './toast'; export type ImagePreviewModalProps = { workspace: Workspace; pageId: string; }; const ImagePreviewModalImpl = ( props: ImagePreviewModalProps & { blockId: string; onClose: () => void; } ): ReactElement | null => { const [blockId, setBlockId] = useAtom(previewBlockIdAtom); const zoomRef = useRef(null); const imageRef = useRef(null); const { isZoomedBigger, handleDrag, handleDragStart, handleDragEnd, resetZoom, zoomIn, zoomOut, resetScale, currentScale, } = useZoomControls({ zoomRef, imageRef }); const [isOpen, setIsOpen] = useAtom(hasAnimationPlayedAtom); const [hasPlayedAnimation, setHasPlayedAnimation] = useState(false); useEffect(() => { let timeoutId: NodeJS.Timeout; if (!isOpen) { timeoutId = setTimeout(() => { props.onClose(); setIsOpen(true); }, 300); return () => { clearTimeout(timeoutId); }; } return () => {}; }, [isOpen, props, setIsOpen]); const nextImageHandler = useCallback( (blockId: string | null) => { assertExists(blockId); const workspace = props.workspace; if (!hasPlayedAnimation) { setHasPlayedAnimation(true); } const page = workspace.getPage(props.pageId); assertExists(page); const block = page.getBlockById(blockId); assertExists(block); const nextBlock = page .getNextSiblings(block) .find( (block): block is ImageBlockModel => block.flavour === 'affine:image' ); if (nextBlock) { setBlockId(nextBlock.id); } }, [props.pageId, props.workspace, setBlockId, hasPlayedAnimation] ); const previousImageHandler = useCallback( (blockId: string | null) => { assertExists(blockId); const workspace = props.workspace; const page = workspace.getPage(props.pageId); assertExists(page); const block = page.getBlockById(blockId); assertExists(block); const prevBlock = page .getPreviousSiblings(block) .findLast( (block): block is ImageBlockModel => block.flavour === 'affine:image' ); if (prevBlock) { setBlockId(prevBlock.id); } resetZoom(); }, [props.pageId, props.workspace, setBlockId, resetZoom] ); const deleteHandler = useCallback( (blockId: string) => { const { pageId, workspace, onClose } = props; const page = workspace.getPage(pageId); assertExists(page); const block = page.getBlockById(blockId); assertExists(block); if ( page .getPreviousSiblings(block) .findLast( (block): block is ImageBlockModel => block.flavour === 'affine:image' ) ) { const prevBlock = page .getPreviousSiblings(block) .findLast( (block): block is ImageBlockModel => block.flavour === 'affine:image' ); if (prevBlock) { setBlockId(prevBlock.id); } } else if ( page .getNextSiblings(block) .find( (block): block is ImageBlockModel => block.flavour === 'affine:image' ) ) { const nextBlock = page .getNextSiblings(block) .find( (block): block is ImageBlockModel => block.flavour === 'affine:image' ); if (nextBlock) { setBlockId(nextBlock.id); } } else { onClose(); } page.deleteBlock(block); }, [props, setBlockId] ); const downloadHandler = useCallback( async (blockId: string | null) => { const workspace = props.workspace; const page = workspace.getPage(props.pageId); assertExists(page); if (typeof blockId === 'string') { const block = page.getBlockById(blockId) as ImageBlockModel; assertExists(block); const store = await block.page.blobs; const url = store?.get(block.sourceId); const img = await url; if (!img) { return; } const arrayBuffer = await img.arrayBuffer(); const buffer = new Uint8Array(arrayBuffer); let fileType: string; if ( buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38 ) { fileType = 'image/gif'; } else if ( buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47 ) { fileType = 'image/png'; } else if ( buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff && buffer[3] === 0xe0 ) { fileType = 'image/jpeg'; } else { // unknown, fallback to png console.error('unknown image type'); fileType = 'image/png'; } const downloadUrl = URL.createObjectURL( new Blob([arrayBuffer], { type: fileType }) ); const a = document.createElement('a'); a.href = downloadUrl; a.download = block.id ?? 'image'; document.body.appendChild(a); a.click(); document.body.removeChild(a); } }, [props.pageId, props.workspace] ); const [caption, setCaption] = useState(() => { const page = props.workspace.getPage(props.pageId); assertExists(page); const block = page.getBlockById(props.blockId) as ImageBlockModel; assertExists(block); return block?.caption; }); useEffect(() => { const page = props.workspace.getPage(props.pageId); assertExists(page); const block = page.getBlockById(props.blockId) as ImageBlockModel; assertExists(block); setCaption(block?.caption); }, [props.blockId, props.pageId, props.workspace]); const { data, error } = useSWR( ['workspace', 'image', props.pageId, props.blockId], { fetcher: ([_, __, pageId, blockId]) => { const page = props.workspace.getPage(pageId); assertExists(page); const block = page.getBlockById(blockId) as ImageBlockModel; assertExists(block); return props.workspace.blobs.get(block?.sourceId); }, suspense: true, } ); useErrorBoundary(error); const [prevData, setPrevData] = useState(() => data); const [url, setUrl] = useState(null); if (data === null) { return null; } else if (prevData !== data) { if (url) { URL.revokeObjectURL(url); } setUrl(URL.createObjectURL(data)); setPrevData(data); } else if (!url) { setUrl(URL.createObjectURL(data)); } if (!url) { return null; } return (
{ if (event.target === event.currentTarget) { setIsOpen(false); } }} >
{caption} {isZoomedBigger ? null : (

{caption}

)}
event.stopPropagation()} > {isZoomedBigger && caption !== '' ? (

{caption}

) : null}
} type="plain" className={buttonStyle} onClick={() => { assertExists(blockId); previousImageHandler(blockId); }} /> } className={buttonStyle} type="plain" onClick={() => { assertExists(blockId); nextImageHandler(blockId); }} />
} type="plain" className={buttonStyle} onClick={() => resetZoom()} /> } className={buttonStyle} type="plain" onClick={zoomOut} /> } className={buttonStyle} type="plain" onClick={() => zoomIn()} />
} type="plain" className={buttonStyle} onClick={() => { assertExists(blockId); downloadHandler(blockId).catch(err => { console.error('Could not download image', err); }); }} /> } type="plain" className={buttonStyle} onClick={() => { if (!imageRef.current) { return; } const canvas = document.createElement('canvas'); canvas.width = imageRef.current.naturalWidth; canvas.height = imageRef.current.naturalHeight; const context = canvas.getContext('2d'); if (!context) { console.warn('Could not get canvas context'); return; } context.drawImage(imageRef.current, 0, 0); canvas.toBlob(blob => { if (!blob) { console.warn('Could not get blob'); return; } const dataUrl = URL.createObjectURL(blob); global.navigator.clipboard .write([new ClipboardItem({ 'image/png': blob })]) .then(() => { console.log('Image copied to clipboard'); URL.revokeObjectURL(dataUrl); }) .catch(error => { console.error('Error copying image to clipboard', error); URL.revokeObjectURL(dataUrl); }); }, 'image/png'); toast('Copied to clipboard.'); }} />
} type="plain" className={buttonStyle} onClick={() => blockId && deleteHandler(blockId)} />
); }; const ErrorLogger = (props: FallbackProps) => { useEffect(() => { console.error('image preview modal error', props.error); }, [props.error]); return null; }; export const ImagePreviewErrorBoundary = ( props: PropsWithChildren ): ReactElement => { return ( {props.children} ); }; export const ImagePreviewModal = ( props: ImagePreviewModalProps ): ReactElement | null => { const [blockId, setBlockId] = useAtom(previewBlockIdAtom); const [isOpen, setIsOpen] = useAtom(hasAnimationPlayedAtom); const handleKeyUp = useCallback( (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); if (isOpen) { setIsOpen(false); } return; } if (!blockId) { return; } const workspace = props.workspace; const page = workspace.getPage(props.pageId); assertExists(page); const block = page.getBlockById(blockId); assertExists(block); if (event.key === 'ArrowLeft') { const prevBlock = page .getPreviousSiblings(block) .findLast( (block): block is ImageBlockModel => block.flavour === 'affine:image' ); if (prevBlock) { setBlockId(prevBlock.id); } } else if (event.key === 'ArrowRight') { const nextBlock = page .getNextSiblings(block) .find( (block): block is ImageBlockModel => block.flavour === 'affine:image' ); if (nextBlock) { setBlockId(nextBlock.id); } } else { return; } event.preventDefault(); event.stopPropagation(); }, [blockId, setBlockId, props.workspace, props.pageId, isOpen, setIsOpen] ); useEffect(() => { document.addEventListener('keyup', handleKeyUp); return () => { document.removeEventListener('keyup', handleKeyUp); }; }, [handleKeyUp]); if (!blockId) { return null; } return (
setBlockId(null)} />
); };