feat: image-preview (#2720)

Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
danielchim 2023-06-14 12:20:29 +08:00 committed by GitHub
parent 6a4f70cf43
commit ad32ed5dd5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 651 additions and 91 deletions

View File

@ -0,0 +1,194 @@
import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
import { useCallback, useEffect, useState } from 'react';
interface UseZoomControlsProps {
zoomRef: RefObject<HTMLDivElement>;
imageRef: RefObject<HTMLImageElement>;
}
export const useZoomControls = ({
zoomRef,
imageRef,
}: UseZoomControlsProps) => {
const [currentScale, setCurrentScale] = useState<number>(0.5);
const [isZoomedBigger, setIsZoomedBigger] = useState<boolean>(false);
const [isDragging, setIsDragging] = useState<boolean>(false);
const [mouseX, setMouseX] = useState<number>(0);
const [mouseY, setMouseY] = useState<number>(0);
const [dragBeforeX, setDragBeforeX] = useState<number>(0);
const [dragBeforeY, setDragBeforeY] = useState<number>(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,
};
};

View File

@ -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',
});

View File

@ -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<HTMLDivElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const {
zoomIn,
zoomOut,
isZoomedBigger,
handleDrag,
handleDragStart,
handleDragEnd,
resetZoom,
currentScale,
} = useZoomControls({ zoomRef, imageRef });
const [prevData, setPrevData] = useState<string | null>(() => data);
const [url, setUrl] = useState<string | null>(null);
const imageRef = useRef<HTMLImageElement>(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 (
<div data-testid="image-preview-modal" className={imagePreviewModalStyle}>
<div className={imageNavigationControlStyle}>
<span
className={imagePreviewModalGoStyle}
style={{
left: 0,
}}
onClick={() => {
assertExists(blockId);
previousImageHandler(blockId);
}}
>
</span>
<span
className={imagePreviewModalGoStyle}
style={{
right: 0,
}}
onClick={() => {
assertExists(blockId);
nextImageHandler(blockId);
}}
>
</span>
</div>
<div className={imagePreviewModalContainerStyle}>
<div
className={clsx('zoom-area', { 'zoomed-bigger': isZoomedBigger })}
ref={zoomRef}
>
<div className={imagePreviewModalCenterStyle}>
<img
data-blob-id={props.blockId}
src={url}
alt={caption}
ref={imageRef}
draggable={isZoomedBigger}
onMouseDown={handleDragStart}
onMouseMove={handleDrag}
onMouseUp={handleDragEnd}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
width={'50%'}
/>
{isZoomedBigger ? null : (
<p className={imagePreviewModalCaptionStyle}>{caption}</p>
)}
</div>
</div>
</div>
<button
onClick={() => {
props.onClose();
@ -99,67 +351,122 @@ const ImagePreviewModalImpl = (
/>
</svg>
</button>
<span
className={imagePreviewModalGoStyle}
style={{
left: 0,
}}
onClick={() => {
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);
}
}}
>
</span>
<div className={imagePreviewModalContainerStyle}>
<img
data-blob-id={props.blockId}
alt={caption}
className={imagePreviewModalImageStyle}
ref={imageRef}
src={url}
/>
</div>
<span
className={imagePreviewModalGoStyle}
style={{
right: 0,
}}
onClick={() => {
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);
}
}}
>
</span>
{bIsActionBarVisible ? (
<div
className={imageBottomContainerStyle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{isZoomedBigger && caption !== '' ? (
<p className={captionStyle}>{caption}</p>
) : null}
<div className={imagePreviewActionBarStyle}>
<div>
<Button
icon={<ArrowLeftSmallIcon />}
noBorder={true}
className={buttonStyle}
onClick={() => {
assertExists(blockId);
previousImageHandler(blockId);
}}
/>
<Button
icon={<ArrowRightSmallIcon />}
noBorder={true}
className={buttonStyle}
onClick={() => {
assertExists(blockId);
nextImageHandler(blockId);
}}
/>
</div>
<div className={groupStyle}>
<Button
icon={<ViewBarIcon />}
noBorder={true}
className={buttonStyle}
onClick={() => resetZoom()}
/>
<Button
icon={<MinusIcon />}
noBorder={true}
className={buttonStyle}
onClick={zoomOut}
/>
<span className={scaleIndicatorStyle}>{`${(
currentScale * 100
).toFixed(0)}%`}</span>
<Button
icon={<PlusIcon />}
noBorder={true}
className={buttonStyle}
onClick={() => zoomIn()}
/>
</div>
<div className={groupStyle}>
<Button
icon={<DownloadIcon />}
noBorder={true}
className={buttonStyle}
onClick={() => {
assertExists(blockId);
downloadHandler(blockId).catch(err => {
console.error('Could not download image', err);
});
}}
/>
<Button
icon={<CopyIcon />}
noBorder={true}
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);
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');
}}
/>
</div>
<div className={groupStyle}>
<Button
icon={<DeleteIcon />}
noBorder={true}
className={buttonStyle}
onClick={() => blockId && deleteHandler(blockId)}
/>
</div>
</div>
</div>
) : null}
</div>
);
};