fix: image preview (#2818)

This commit is contained in:
danielchim 2023-06-27 15:26:57 +08:00 committed by GitHub
parent 10c7f93a85
commit 4307e1eb6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 537 additions and 419 deletions

View File

@ -22,74 +22,6 @@ export const useZoomControls = ({
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.2) {
const newScale = currentScale - 0.1;
setCurrentScale(newScale);
image.style.width = `${image.naturalWidth * newScale}px`;
image.style.height = `${image.naturalHeight * newScale}px`;
const zoomedWidth = image.naturalWidth * newScale;
const zoomedHeight = image.naturalHeight * newScale;
const containerWidth = window.innerWidth;
const containerHeight = window.innerHeight;
if (zoomedWidth > containerWidth || zoomedHeight > containerHeight) {
image.style.transform = `translate(0px, 0px)`;
setImagePos({ x: 0, y: 0 });
}
}
}, [imageRef, currentScale]);
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]);
const resetZoom = useCallback(() => {
const image = imageRef.current;
if (image) {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = 0.2;
const availableWidth = viewportWidth * (1 - margin);
const availableHeight = viewportHeight * (1 - margin);
const widthRatio = availableWidth / image.naturalWidth;
const heightRatio = availableHeight / image.naturalHeight;
const newScale = Math.min(widthRatio, heightRatio);
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 });
checkZoomSize();
}
}, [checkZoomSize, imageRef]);
const handleDragStart = useCallback(
(event: ReactMouseEvent) => {
event?.preventDefault();
@ -170,6 +102,86 @@ export const useZoomControls = ({
}
}, [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]);
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.2) {
const newScale = currentScale - 0.1;
setCurrentScale(newScale);
image.style.width = `${image.naturalWidth * newScale}px`;
image.style.height = `${image.naturalHeight * newScale}px`;
const zoomedWidth = image.naturalWidth * newScale;
const zoomedHeight = image.naturalHeight * newScale;
const containerWidth = window.innerWidth;
const containerHeight = window.innerHeight;
if (zoomedWidth > containerWidth || zoomedHeight > containerHeight) {
image.style.transform = `translate(0px, 0px)`;
setImagePos({ x: 0, y: 0 });
}
}
}, [imageRef, currentScale]);
const resetZoom = useCallback(() => {
const image = imageRef.current;
if (image) {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = 0.2;
const availableWidth = viewportWidth * (1 - margin);
const availableHeight = viewportHeight * (1 - margin);
const widthRatio = availableWidth / image.naturalWidth;
const heightRatio = availableHeight / image.naturalHeight;
const newScale = Math.min(widthRatio, heightRatio);
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 });
checkZoomSize();
}
}, [imageRef, checkZoomSize]);
const resetScale = useCallback(() => {
const image = imageRef.current;
if (image) {
setCurrentScale(1);
image.style.width = `${image.naturalWidth}px`;
image.style.height = `${image.naturalHeight}px`;
image.style.transform = 'translate(0px, 0px)';
setImagePos({ x: 0, y: 0 });
}
}, [imageRef]);
useEffect(() => {
const handleScroll = (event: WheelEvent) => {
const { deltaY } = event;
@ -201,6 +213,7 @@ export const useZoomControls = ({
zoomIn,
zoomOut,
resetZoom,
resetScale,
isZoomedBigger,
currentScale,
handleDragStart,

View File

@ -1,18 +1,45 @@
import { baseTheme } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
import { keyframes, style } from '@vanilla-extract/css';
export const imagePreviewModalStyle = style({
const fadeInAnimation = keyframes({
from: { opacity: 0 },
to: { opacity: 1 },
});
const fadeOutAnimation = keyframes({
from: { opacity: 1 },
to: { opacity: 0 },
});
export const imagePreviewBackgroundStyle = style({
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: baseTheme.zIndexModal,
background: 'rgba(0, 0, 0, 0.75)',
});
export const imagePreviewModalStyle = style({
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
// background: 'var(--affine-background-modal-color)',
background: 'rgba(0,0,0,0.75)',
});
export const loaded = style({
opacity: 0,
animationName: fadeInAnimation,
animationDuration: '0.25s',
animationFillMode: 'forwards',
});
export const unloaded = style({
opacity: 1,
animationName: fadeOutAnimation,
animationDuration: '0.25s',
animationFillMode: 'forwards',
});
export const imagePreviewModalCloseButtonStyle = style({
@ -33,6 +60,9 @@ export const imagePreviewModalCloseButtonStyle = style({
color: 'var(--affine-icon-color)',
transition: 'background 0.2s ease-in-out',
zIndex: 1,
marginTop: '38px',
marginRight: '38px',
});
export const imagePreviewModalGoStyle = style({
@ -86,28 +116,48 @@ 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',
minHeight: '44px',
maxHeight: '44px',
});
export const groupStyle = style({
display: 'inline-flex',
padding: '10px 0',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--affine-white)',
borderLeft: '1px solid #E3E2E4',
});
export const buttonStyle = style({
paddingLeft: '10px',
paddingRight: '10px',
minWidth: '24px',
height: '24px',
margin: '10px 6px',
padding: '0 0',
':hover': {
backgroundColor: 'var(--affine-hover-color)',
backgroundSize: '24px 24px',
},
});
export const scaleIndicatorStyle = style({
margin: '0 8px',
export const buttonIconStyle = style({
width: '20px',
height: '20px',
});
export const scaleIndicatorButtonStyle = style({
minHeight: '100%',
maxWidth: 'max-content',
fontSize: '12px',
padding: '5px 5px',
':hover': {
backgroundColor: 'var(--affine-hover-color)',
},
});
export const imageBottomContainerStyle = style({
@ -126,3 +176,8 @@ export const captionStyle = style({
padding: '10px',
marginBottom: '21px',
});
export const suspenseFallbackStyle = style({
opacity: 0,
transition: 'opacity 2s ease-in-out',
});

View File

@ -2,6 +2,7 @@ import type { EmbedBlockDoubleClickData } from '@blocksuite/blocks';
import { atom } from 'jotai';
export const previewBlockIdAtom = atom<string | null>(null);
export const hasAnimationPlayedAtom = atom<boolean | null>(true);
previewBlockIdAtom.onMount = set => {
if (typeof window !== 'undefined') {

View File

@ -1,6 +1,7 @@
/// <reference types="react/experimental" />
import '@blocksuite/blocks';
import { Button, Tooltip } from '@affine/component';
import type { ImageBlockModel } from '@blocksuite/blocks';
import { assertExists } from '@blocksuite/global/utils';
import {
@ -21,24 +22,26 @@ 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 {
buttonIconStyle,
buttonStyle,
captionStyle,
groupStyle,
imageBottomContainerStyle,
imageNavigationControlStyle,
imagePreviewActionBarStyle,
imagePreviewBackgroundStyle,
imagePreviewModalCaptionStyle,
imagePreviewModalCenterStyle,
imagePreviewModalCloseButtonStyle,
imagePreviewModalContainerStyle,
imagePreviewModalGoStyle,
imagePreviewModalStyle,
scaleIndicatorStyle,
loaded,
scaleIndicatorButtonStyle,
unloaded,
} from './index.css';
import { previewBlockIdAtom } from './index.jotai';
import { hasAnimationPlayedAtom, previewBlockIdAtom } from './index.jotai';
import { toast } from './toast';
export type ImagePreviewModalProps = {
workspace: Workspace;
@ -52,8 +55,189 @@ const ImagePreviewModalImpl = (
}
): ReactElement | null => {
const [blockId, setBlockId] = useAtom(previewBlockIdAtom);
const zoomRef = useRef<HTMLDivElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const {
isZoomedBigger,
handleDrag,
handleDragStart,
handleDragEnd,
resetZoom,
zoomIn,
zoomOut,
resetScale,
currentScale,
} = useZoomControls({ zoomRef, imageRef });
const [isOpen, setIsOpen] = useAtom(hasAnimationPlayedAtom);
const [hasPlayedAnimation, setHasPlayedAnimation] = useState<boolean>(false);
const [bIsActionBarVisible, setBIsActionBarVisible] = 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:embed'
);
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:embed'
);
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:embed'
)
) {
const prevBlock = page
.getPreviousSiblings(block)
.findLast(
(block): block is ImageBlockModel =>
block.flavour === 'affine:embed'
);
if (prevBlock) {
setBlockId(prevBlock.id);
}
} else if (
page
.getNextSiblings(block)
.find(
(block): block is ImageBlockModel =>
block.flavour === 'affine:embed'
)
) {
const nextBlock = page
.getNextSiblings(block)
.find(
(block): block is ImageBlockModel =>
block.flavour === 'affine:embed'
);
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);
@ -78,20 +262,10 @@ const ImagePreviewModalImpl = (
},
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);
if (prevData !== data) {
if (url) {
URL.revokeObjectURL(url);
@ -105,199 +279,15 @@ 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 ImageBlockModel => block.flavour === 'affine:image'
);
if (nextBlock) {
setBlockId(nextBlock.id);
}
};
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 ImageBlockModel => block.flavour === 'affine:image'
);
if (prevBlock) {
setBlockId(prevBlock.id);
}
};
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 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) {
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 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');
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}
onClick={event =>
event.target === event.currentTarget ? props.onClose() : null
}
onClick={event => {
if (event.target === event.currentTarget) {
setIsOpen(false);
}
}}
>
{!isZoomedBigger ? (
<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 })}
@ -306,6 +296,7 @@ const ImagePreviewModalImpl = (
<div className={imagePreviewModalCenterStyle}>
<img
data-blob-id={props.blockId}
data-testid="image-content"
src={url}
alt={caption}
ref={imageRef}
@ -313,8 +304,6 @@ const ImagePreviewModalImpl = (
onMouseDown={handleDragStart}
onMouseMove={handleDrag}
onMouseUp={handleDragEnd}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onLoad={resetZoom}
/>
{isZoomedBigger ? null : (
@ -323,144 +312,151 @@ const ImagePreviewModalImpl = (
</div>
</div>
</div>
<button
onClick={() => {
props.onClose();
}}
className={imagePreviewModalCloseButtonStyle}
<div
className={imageBottomContainerStyle}
onClick={event => event.stopPropagation()}
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.286086 0.285964C0.530163 0.0418858 0.925891 0.0418858 1.16997 0.285964L5.00013 4.11613L8.83029 0.285964C9.07437 0.0418858 9.4701 0.0418858 9.71418 0.285964C9.95825 0.530041 9.95825 0.925769 9.71418 1.16985L5.88401 5.00001L9.71418 8.83017C9.95825 9.07425 9.95825 9.46998 9.71418 9.71405C9.4701 9.95813 9.07437 9.95813 8.83029 9.71405L5.00013 5.88389L1.16997 9.71405C0.925891 9.95813 0.530163 9.95813 0.286086 9.71405C0.0420079 9.46998 0.0420079 9.07425 0.286086 8.83017L4.11625 5.00001L0.286086 1.16985C0.0420079 0.925769 0.0420079 0.530041 0.286086 0.285964Z"
fill="#77757D"
/>
</svg>
</button>
{bIsActionBarVisible ? (
<div
className={imageBottomContainerStyle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={event => event.stopPropagation()}
>
{isZoomedBigger && caption !== '' ? (
<p className={captionStyle}>{caption}</p>
) : null}
<div className={imagePreviewActionBarStyle}>
<div>
{isZoomedBigger && caption !== '' ? (
<p className={captionStyle}>{caption}</p>
) : null}
<div className={imagePreviewActionBarStyle}>
<div>
<Tooltip content={'Previous'} disablePortal={false}>
<Button
icon={<ArrowLeftSmallIcon />}
icon={<ArrowLeftSmallIcon className={buttonIconStyle} />}
noBorder={true}
className={buttonStyle}
hoverColor={'-moz-initial'}
onClick={() => {
assertExists(blockId);
previousImageHandler(blockId);
}}
/>
</Tooltip>
<Tooltip content={'Next'} disablePortal={false}>
<Button
icon={<ArrowRightSmallIcon />}
icon={<ArrowRightSmallIcon className={buttonIconStyle} />}
noBorder={true}
className={buttonStyle}
hoverColor={'-moz-initial'}
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>
</Tooltip>
</div>
<div className={groupStyle}></div>
<Tooltip
content={'Fit to Screen'}
disablePortal={true}
showArrow={false}
>
<Button
icon={<ViewBarIcon className={buttonIconStyle} />}
noBorder={true}
hoverColor={'-moz-initial'}
className={buttonStyle}
onClick={() => resetZoom()}
/>
</Tooltip>
<Tooltip content={'Zoom out'} disablePortal={false}>
<Button
icon={<MinusIcon className={buttonIconStyle} />}
noBorder={true}
className={buttonStyle}
hoverColor={'-moz-initial'}
onClick={zoomOut}
/>
</Tooltip>
<Tooltip content={'Reset Scale'} disablePortal={false}>
<Button
noBorder={true}
size={'middle'}
className={scaleIndicatorButtonStyle}
hoverColor={'-moz-initial'}
onClick={resetScale}
>
{`${(currentScale * 100).toFixed(0)}%`}
</Button>
</Tooltip>
<Tooltip content={'Zoom in'} disablePortal={false}>
<Button
icon={<PlusIcon className={buttonIconStyle} />}
noBorder={true}
className={buttonStyle}
hoverColor={'-moz-initial'}
onClick={() => zoomIn()}
/>
</Tooltip>
<div className={groupStyle}></div>
<Tooltip content={'Download'} disablePortal={false}>
<Button
icon={<DownloadIcon className={buttonIconStyle} />}
noBorder={true}
className={buttonStyle}
hoverColor={'-moz-initial'}
onClick={() => {
assertExists(blockId);
downloadHandler(blockId).catch(err => {
console.error('Could not download image', err);
});
}}
/>
</Tooltip>
<Tooltip content={'Copy to clipboard'} disablePortal={false}>
<Button
icon={<CopyIcon className={buttonIconStyle} />}
noBorder={true}
className={buttonStyle}
hoverColor={'-moz-initial'}
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');
toast('Copied to clipboard.');
}}
/>
</Tooltip>
<div className={groupStyle}></div>
<Tooltip content={'Delete'} disablePortal={false}>
<Button
icon={<DeleteIcon className={buttonIconStyle} />}
noBorder={true}
className={buttonStyle}
onClick={() => blockId && deleteHandler(blockId)}
hoverColor={'-moz-initial'}
/>
</Tooltip>
</div>
) : null}
</div>
</div>
);
};
@ -469,13 +465,16 @@ 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();
setBlockId(null);
if (isOpen) {
setIsOpen(false);
}
return;
}
@ -495,7 +494,7 @@ export const ImagePreviewModal = (
.getPreviousSiblings(block)
.findLast(
(block): block is ImageBlockModel =>
block.flavour === 'affine:image'
block.flavour === 'affine:embed'
);
if (prevBlock) {
setBlockId(prevBlock.id);
@ -505,7 +504,7 @@ export const ImagePreviewModal = (
.getNextSiblings(block)
.find(
(block): block is ImageBlockModel =>
block.flavour === 'affine:image'
block.flavour === 'affine:embed'
);
if (nextBlock) {
setBlockId(nextBlock.id);
@ -516,7 +515,7 @@ export const ImagePreviewModal = (
event.preventDefault();
event.stopPropagation();
},
[blockId, setBlockId, props.workspace, props.pageId]
[blockId, setBlockId, props.workspace, props.pageId, isOpen, setIsOpen]
);
useEffect(() => {
@ -531,12 +530,38 @@ export const ImagePreviewModal = (
}
return (
<Suspense fallback={<div className={imagePreviewModalStyle} />}>
<ImagePreviewModalImpl
{...props}
blockId={blockId}
onClose={() => setBlockId(null)}
/>
</Suspense>
<div
data-testid="image-preview-modal"
className={`${imagePreviewBackgroundStyle} ${isOpen ? loaded : unloaded}`}
>
<Suspense fallback={<div />}>
<ImagePreviewModalImpl
{...props}
blockId={blockId}
onClose={() => setBlockId(null)}
/>
</Suspense>
<button
onClick={() => {
setIsOpen(false);
}}
className={imagePreviewModalCloseButtonStyle}
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.286086 0.285964C0.530163 0.0418858 0.925891 0.0418858 1.16997 0.285964L5.00013 4.11613L8.83029 0.285964C9.07437 0.0418858 9.4701 0.0418858 9.71418 0.285964C9.95825 0.530041 9.95825 0.925769 9.71418 1.16985L5.88401 5.00001L9.71418 8.83017C9.95825 9.07425 9.95825 9.46998 9.71418 9.71405C9.4701 9.95813 9.07437 9.95813 8.83029 9.71405L5.00013 5.88389L1.16997 9.71405C0.925891 9.95813 0.530163 9.95813 0.286086 9.71405C0.0420079 9.46998 0.0420079 9.07425 0.286086 8.83017L4.11625 5.00001L0.286086 1.16985C0.0420079 0.925769 0.0420079 0.530041 0.286086 0.285964Z"
fill="#77757D"
/>
</svg>
</button>
</div>
);
};

View File

@ -0,0 +1,21 @@
import type { ToastOptions } from '@affine/component';
import { toast as basicToast } from '@affine/component';
export const toast = (message: string, options?: ToastOptions) => {
const mainContainer = document.querySelector(
'[data-testid="image-preview-modal"]'
) as HTMLElement;
return basicToast(message, {
portal: mainContainer || document.body,
...options,
});
};
declare global {
// global Events
interface WindowEventMap {
'affine-toast:emit': CustomEvent<{
message: string;
}>;
}
}

View File

@ -67,7 +67,7 @@ export const toast = (
duration: 2500,
}
) => {
if (!ToastContainer) {
if (!ToastContainer || (portal && !portal.contains(ToastContainer))) {
ToastContainer = createToastContainer(portal);
}
@ -126,6 +126,7 @@ export const toast = (
element.style.margin = '0';
element.style.padding = '0';
// wait for transition
// ToastContainer = null;
element.addEventListener('transitionend', () => {
element.remove();
});

View File

@ -11,6 +11,8 @@ const StyledTooltip = styled(StyledPopperContainer)(() => {
backgroundColor: 'var(--affine-tooltip)',
color: 'var(--affine-white)',
fontSize: 'var(--affine-font-sm)',
borderRadius: '8px',
marginBottom: '12px',
};
});