mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-19 07:17:00 +08:00
fix: image preview (#2818)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user