diff --git a/packages/component/src/components/image-preview-modal/hooks/use-zoom.tsx b/packages/component/src/components/image-preview-modal/hooks/use-zoom.tsx new file mode 100644 index 0000000000..db1538472c --- /dev/null +++ b/packages/component/src/components/image-preview-modal/hooks/use-zoom.tsx @@ -0,0 +1,194 @@ +import type { MouseEvent as ReactMouseEvent, RefObject } from 'react'; +import { useCallback, useEffect, useState } from 'react'; + +interface UseZoomControlsProps { + zoomRef: RefObject; + imageRef: RefObject; +} + +export const useZoomControls = ({ + zoomRef, + imageRef, +}: UseZoomControlsProps) => { + const [currentScale, setCurrentScale] = useState(0.5); + const [isZoomedBigger, setIsZoomedBigger] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [mouseX, setMouseX] = useState(0); + const [mouseY, setMouseY] = useState(0); + const [dragBeforeX, setDragBeforeX] = useState(0); + const [dragBeforeY, setDragBeforeY] = useState(0); + const [imagePos, setImagePos] = useState<{ x: number; y: number }>({ + x: 0, + y: 0, + }); + + const zoomIn = useCallback(() => { + const image = imageRef.current; + + if (image && currentScale < 2) { + const newScale = currentScale + 0.1; + setCurrentScale(newScale); + image.style.width = `${image.naturalWidth * newScale}px`; + image.style.height = `${image.naturalHeight * newScale}px`; + } + }, [imageRef, currentScale]); + + const zoomOut = useCallback(() => { + const image = imageRef.current; + if (image && currentScale > 0.5) { + const newScale = currentScale - 0.1; + setCurrentScale(newScale); + image.style.width = `${image.naturalWidth * newScale}px`; + image.style.height = `${image.naturalHeight * newScale}px`; + if (!isZoomedBigger) { + image.style.transform = `translate(0px, 0px)`; + } + } + }, [imageRef, currentScale, isZoomedBigger]); + + const resetZoom = useCallback(() => { + const image = imageRef.current; + if (image) { + const newScale = 0.5; + setCurrentScale(newScale); + image.style.width = `${image.naturalWidth * newScale}px`; + image.style.height = `${image.naturalHeight * newScale}px`; + image.style.transform = `translate(0px, 0px)`; + setImagePos({ x: 0, y: 0 }); + } + }, [imageRef]); + + const handleDragStart = useCallback( + (event: ReactMouseEvent) => { + event?.preventDefault(); + setIsDragging(true); + const image = imageRef.current; + if (image && isZoomedBigger) { + image.style.cursor = 'grab'; + const rect = image.getBoundingClientRect(); + setDragBeforeX(rect.left); + setDragBeforeY(rect.top); + setMouseX(event.clientX); + setMouseY(event.clientY); + } + }, + [imageRef, isZoomedBigger] + ); + + const handleDrag = useCallback( + (event: ReactMouseEvent) => { + event?.preventDefault(); + const image = imageRef.current; + + if (isDragging && image && isZoomedBigger) { + image.style.cursor = 'grabbing'; + const currentX = imagePos.x; + const currentY = imagePos.y; + const newPosX = currentX + event.clientX - mouseX; + const newPosY = currentY + event.clientY - mouseY; + image.style.transform = `translate(${newPosX}px, ${newPosY}px)`; + } + }, + [ + imagePos.x, + imagePos.y, + imageRef, + isDragging, + isZoomedBigger, + mouseX, + mouseY, + ] + ); + + const dragEndImpl = useCallback(() => { + setIsDragging(false); + + const image = imageRef.current; + if (image && isZoomedBigger && isDragging) { + image.style.cursor = 'pointer'; + const rect = image.getBoundingClientRect(); + const newPos = { x: rect.left, y: rect.top }; + const currentX = imagePos.x; + const currentY = imagePos.y; + const newPosX = currentX + newPos.x - dragBeforeX; + const newPosY = currentY + newPos.y - dragBeforeY; + setImagePos({ x: newPosX, y: newPosY }); + } + }, [ + dragBeforeX, + dragBeforeY, + imagePos.x, + imagePos.y, + imageRef, + isDragging, + isZoomedBigger, + ]); + + const handleDragEnd = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + dragEndImpl(); + }, + [dragEndImpl] + ); + + const handleMouseUp = useCallback(() => { + if (isDragging) { + dragEndImpl(); + } + }, [isDragging, dragEndImpl]); + + const checkZoomSize = useCallback(() => { + const { current: zoomArea } = zoomRef; + if (zoomArea) { + const image = zoomArea.querySelector('img'); + if (image) { + const zoomedWidth = image.naturalWidth * currentScale; + const zoomedHeight = image.naturalHeight * currentScale; + const containerWidth = window.innerWidth; + const containerHeight = window.innerHeight; + setIsZoomedBigger( + zoomedWidth > containerWidth || zoomedHeight > containerHeight + ); + } + } + }, [currentScale, zoomRef]); + + useEffect(() => { + const handleScroll = (event: WheelEvent) => { + const { deltaY } = event; + if (deltaY > 0) { + zoomOut(); + } else if (deltaY < 0) { + zoomIn(); + } + }; + + const handleResize = () => { + checkZoomSize(); + }; + + checkZoomSize(); + + window.addEventListener('wheel', handleScroll, { passive: false }); + window.addEventListener('resize', handleResize); + window.addEventListener('mouseup', handleMouseUp); + + return () => { + window.removeEventListener('wheel', handleScroll); + window.removeEventListener('resize', handleResize); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [zoomIn, zoomOut, checkZoomSize, handleMouseUp]); + + return { + zoomIn, + zoomOut, + resetZoom, + isZoomedBigger, + currentScale, + handleDragStart, + handleDrag, + handleDragEnd, + }; +}; diff --git a/packages/component/src/components/image-preview-modal/index.css.ts b/packages/component/src/components/image-preview-modal/index.css.ts index 1856d112b9..30d405ac18 100644 --- a/packages/component/src/components/image-preview-modal/index.css.ts +++ b/packages/component/src/components/image-preview-modal/index.css.ts @@ -11,7 +11,8 @@ export const imagePreviewModalStyle = style({ display: 'flex', alignItems: 'center', justifyContent: 'center', - background: 'var(--affine-background-modal-color)', + // background: 'var(--affine-background-modal-color)', + background: 'rgba(0,0,0,0.75)', }); export const imagePreviewModalCloseButtonStyle = style({ @@ -20,11 +21,9 @@ export const imagePreviewModalCloseButtonStyle = style({ flexDirection: 'column', justifyContent: 'center', alignItems: 'center', - height: '36px', width: '36px', borderRadius: '10px', - top: '0.5rem', right: '0.5rem', background: 'var(--affine-white)', @@ -33,37 +32,97 @@ export const imagePreviewModalCloseButtonStyle = style({ cursor: 'pointer', color: 'var(--affine-icon-color)', transition: 'background 0.2s ease-in-out', + zIndex: 1, }); export const imagePreviewModalGoStyle = style({ - height: '50%', color: 'var(--affine-white)', position: 'absolute', fontSize: '60px', lineHeight: '60px', fontWeight: 'bold', - display: 'flex', - alignItems: 'center', opacity: '0.2', padding: '0 15px', cursor: 'pointer', }); +export const imageNavigationControlStyle = style({ + display: 'flex', + height: '100%', + zIndex: 0, + justifyContent: 'space-between', + alignItems: 'center', +}); + export const imagePreviewModalContainerStyle = style({ - position: 'absolute', - top: '20%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + zIndex: 1, + '@media': { + 'screen and (max-width: 768px)': { + alignItems: 'center', + }, + }, }); -export const imagePreviewModalImageStyle = style({ - background: 'transparent', - maxWidth: '686px', - objectFit: 'contain', - objectPosition: 'center', - borderRadius: '4px', +export const imagePreviewModalCenterStyle = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', }); -export const imagePreviewModalActionsStyle = style({ - position: 'absolute', +export const imagePreviewModalCaptionStyle = style({ + color: 'var(--affine-white)', + marginTop: '24px', + '@media': { + 'screen and (max-width: 768px)': { + textAlign: 'center', + }, + }, +}); + +export const imagePreviewActionBarStyle = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + padding: '16px 0', + backgroundColor: 'var(--affine-white)', + borderRadius: '8px', + boxShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)', + maxWidth: 'max-content', +}); + +export const groupStyle = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'var(--affine-white)', + borderLeft: '1px solid #E3E2E4', +}); + +export const buttonStyle = style({ + paddingLeft: '10px', + paddingRight: '10px', +}); + +export const scaleIndicatorStyle = style({ + margin: '0 8px', +}); + +export const imageBottomContainerStyle = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + position: 'fixed', bottom: '28px', - background: 'var(--affine-white)', + zIndex: baseTheme.zIndexModal + 1, +}); + +export const captionStyle = style({ + maxWidth: '686px', + color: 'var(--affine-white)', + background: 'rgba(0,0,0,0.75)', + padding: '10px', + marginBottom: '21px', }); diff --git a/packages/component/src/components/image-preview-modal/index.tsx b/packages/component/src/components/image-preview-modal/index.tsx index f1aa0f33b0..cff3c410a5 100644 --- a/packages/component/src/components/image-preview-modal/index.tsx +++ b/packages/component/src/components/image-preview-modal/index.tsx @@ -3,19 +3,40 @@ import '@blocksuite/blocks'; import type { EmbedBlockModel } 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 clsx from 'clsx'; import { useAtom } from 'jotai'; import type { ReactElement } from 'react'; import { Suspense, useCallback } from 'react'; import { useEffect, useRef, useState } from 'react'; import useSWR from 'swr'; +import Button from '../../ui/button/button'; +import { useZoomControls } from './hooks/use-zoom'; import { + buttonStyle, + captionStyle, + groupStyle, + imageBottomContainerStyle, + imageNavigationControlStyle, + imagePreviewActionBarStyle, + imagePreviewModalCaptionStyle, + imagePreviewModalCenterStyle, imagePreviewModalCloseButtonStyle, imagePreviewModalContainerStyle, imagePreviewModalGoStyle, - imagePreviewModalImageStyle, imagePreviewModalStyle, + scaleIndicatorStyle, } from './index.css'; import { previewBlockIdAtom } from './index.jotai'; @@ -31,38 +52,46 @@ const ImagePreviewModalImpl = ( } ): ReactElement | null => { const [blockId, setBlockId] = useAtom(previewBlockIdAtom); + + const [bIsActionBarVisible, setBIsActionBarVisible] = useState(false); const [caption, setCaption] = useState(() => { const page = props.workspace.getPage(props.pageId); assertExists(page); - const block = page.getBlockById(props.blockId) as EmbedBlockModel | null; + const block = page.getBlockById(props.blockId) as EmbedBlockModel; assertExists(block); - return block.caption; + return block?.caption; }); useEffect(() => { const page = props.workspace.getPage(props.pageId); assertExists(page); - const block = page.getBlockById(props.blockId) as EmbedBlockModel | null; + const block = page.getBlockById(props.blockId) as EmbedBlockModel; assertExists(block); - const disposable = block.propsUpdated.on(() => { - setCaption(block.caption); - }); - return () => { - disposable.dispose(); - }; + setCaption(block?.caption); }, [props.blockId, props.pageId, props.workspace]); const { data } = useSWR(['workspace', 'embed', props.pageId, props.blockId], { fetcher: ([_, __, pageId, blockId]) => { const page = props.workspace.getPage(pageId); assertExists(page); - const block = page.getBlockById(blockId) as EmbedBlockModel | null; + const block = page.getBlockById(blockId) as EmbedBlockModel; assertExists(block); - return props.workspace.blobs.get(block.sourceId); + return props.workspace.blobs.get(block?.sourceId); }, suspense: true, }); + const zoomRef = useRef(null); + const imageRef = useRef(null); + const { + zoomIn, + zoomOut, + isZoomedBigger, + handleDrag, + handleDragStart, + handleDragEnd, + resetZoom, + currentScale, + } = useZoomControls({ zoomRef, imageRef }); const [prevData, setPrevData] = useState(() => data); const [url, setUrl] = useState(null); - const imageRef = useRef(null); if (prevData !== data) { if (url) { URL.revokeObjectURL(url); @@ -76,8 +105,231 @@ const ImagePreviewModalImpl = ( if (!url) { return null; } + const nextImageHandler = (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 nextBlock = page + .getNextSiblings(block) + .find( + (block): block is EmbedBlockModel => block.flavour === 'affine:embed' + ); + if (nextBlock) { + setBlockId(nextBlock.id); + const image = imageRef.current; + resetZoom(); + if (image) { + image.style.width = '50%'; // Reset the width to its original size + image.style.height = 'auto'; // Reset the height to maintain aspect ratio + } + } + }; + + const previousImageHandler = (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 EmbedBlockModel => block.flavour === 'affine:embed' + ); + if (prevBlock) { + setBlockId(prevBlock.id); + const image = imageRef.current; + if (image) { + resetZoom(); + image.style.width = '50%'; // Reset the width to its original size + image.style.height = 'auto'; // Reset the height to maintain aspect ratio + } + } + }; + + const deleteHandler = (blockId: string) => { + const workspace = props.workspace; + + const page = workspace.getPage(props.pageId); + assertExists(page); + const block = page.getBlockById(blockId); + assertExists(block); + if ( + page + .getPreviousSiblings(block) + .findLast( + (block): block is EmbedBlockModel => block.flavour === 'affine:embed' + ) + ) { + const prevBlock = page + .getPreviousSiblings(block) + .findLast( + (block): block is EmbedBlockModel => block.flavour === 'affine:embed' + ); + if (prevBlock) { + setBlockId(prevBlock.id); + const image = imageRef.current; + resetZoom(); + if (image) { + image.style.width = '100%'; // Reset the width to its original size + image.style.height = 'auto'; // Reset the height to maintain aspect ratio + } + } + } else if ( + page + .getNextSiblings(block) + .find( + (block): block is EmbedBlockModel => block.flavour === 'affine:embed' + ) + ) { + const nextBlock = page + .getNextSiblings(block) + .find( + (block): block is EmbedBlockModel => block.flavour === 'affine:embed' + ); + if (nextBlock) { + const image = imageRef.current; + resetZoom(); + if (image) { + image.style.width = '100%'; // Reset the width to its original size + image.style.height = 'auto'; // Reset the height to maintain aspect ratio + } + setBlockId(nextBlock.id); + } + } else { + props.onClose(); + } + page.deleteBlock(block); + }; + + let actionbarTimeout: NodeJS.Timeout; + + const downloadHandler = 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 EmbedBlockModel; + 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'); + const event = new MouseEvent('click'); + a.download = block.id; + a.href = downloadUrl; + a.dispatchEvent(event); + + // cleanup + a.remove(); + URL.revokeObjectURL(downloadUrl); + } + }; + + const handleMouseEnter = () => { + clearTimeout(actionbarTimeout); + setBIsActionBarVisible(true); + }; + + const handleMouseLeave = () => { + actionbarTimeout = setTimeout(() => { + setBIsActionBarVisible(false); + }, 3000); + }; + return (
+
+ { + assertExists(blockId); + previousImageHandler(blockId); + }} + > + ❮ + + { + assertExists(blockId); + nextImageHandler(blockId); + }} + > + ❯ + +
+
+
+
+ {caption} + {isZoomedBigger ? null : ( +

{caption}

+ )} +
+
+
- { - 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 EmbedBlockModel => - block.flavour === 'affine:embed' - ); - if (prevBlock) { - setBlockId(prevBlock.id); - } - }} - > - ❮ - -
- {caption} -
- { - assertExists(blockId); - const workspace = props.workspace; - - const page = workspace.getPage(props.pageId); - assertExists(page); - const block = page.getBlockById(blockId); - assertExists(block); - const nextBlock = page - .getNextSiblings(block) - .find( - (block): block is EmbedBlockModel => - block.flavour === 'affine:embed' - ); - if (nextBlock) { - setBlockId(nextBlock.id); - } - }} - > - ❯ - + {bIsActionBarVisible ? ( +
+ {isZoomedBigger && caption !== '' ? ( +

{caption}

+ ) : null} +
+
+
+
+
+
+
+
+
+
+
+ ) : null}
); };