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
7 changed files with 537 additions and 419 deletions

View File

@@ -22,74 +22,6 @@ export const useZoomControls = ({
y: 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.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( const handleDragStart = useCallback(
(event: ReactMouseEvent) => { (event: ReactMouseEvent) => {
event?.preventDefault(); event?.preventDefault();
@@ -170,6 +102,86 @@ export const useZoomControls = ({
} }
}, [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]);
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(() => { useEffect(() => {
const handleScroll = (event: WheelEvent) => { const handleScroll = (event: WheelEvent) => {
const { deltaY } = event; const { deltaY } = event;
@@ -201,6 +213,7 @@ export const useZoomControls = ({
zoomIn, zoomIn,
zoomOut, zoomOut,
resetZoom, resetZoom,
resetScale,
isZoomedBigger, isZoomedBigger,
currentScale, currentScale,
handleDragStart, handleDragStart,

View File

@@ -1,18 +1,45 @@
import { baseTheme } from '@toeverything/theme'; 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', position: 'fixed',
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: '100%',
height: '100%', height: '100%',
zIndex: baseTheme.zIndexModal, zIndex: baseTheme.zIndexModal,
background: 'rgba(0, 0, 0, 0.75)',
});
export const imagePreviewModalStyle = style({
height: '100%',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: '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({ export const imagePreviewModalCloseButtonStyle = style({
@@ -33,6 +60,9 @@ export const imagePreviewModalCloseButtonStyle = style({
color: 'var(--affine-icon-color)', color: 'var(--affine-icon-color)',
transition: 'background 0.2s ease-in-out', transition: 'background 0.2s ease-in-out',
zIndex: 1, zIndex: 1,
marginTop: '38px',
marginRight: '38px',
}); });
export const imagePreviewModalGoStyle = style({ export const imagePreviewModalGoStyle = style({
@@ -86,28 +116,48 @@ export const imagePreviewActionBarStyle = style({
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
padding: '16px 0',
backgroundColor: 'var(--affine-white)', backgroundColor: 'var(--affine-white)',
borderRadius: '8px', borderRadius: '8px',
boxShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)', boxShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)',
maxWidth: 'max-content', maxWidth: 'max-content',
minHeight: '44px',
maxHeight: '44px',
}); });
export const groupStyle = style({ export const groupStyle = style({
display: 'inline-flex', padding: '10px 0',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'var(--affine-white)',
borderLeft: '1px solid #E3E2E4', borderLeft: '1px solid #E3E2E4',
}); });
export const buttonStyle = style({ export const buttonStyle = style({
paddingLeft: '10px', minWidth: '24px',
paddingRight: '10px', height: '24px',
margin: '10px 6px',
padding: '0 0',
':hover': {
backgroundColor: 'var(--affine-hover-color)',
backgroundSize: '24px 24px',
},
}); });
export const scaleIndicatorStyle = style({ export const buttonIconStyle = style({
margin: '0 8px', 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({ export const imageBottomContainerStyle = style({
@@ -126,3 +176,8 @@ export const captionStyle = style({
padding: '10px', padding: '10px',
marginBottom: '21px', 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'; import { atom } from 'jotai';
export const previewBlockIdAtom = atom<string | null>(null); export const previewBlockIdAtom = atom<string | null>(null);
export const hasAnimationPlayedAtom = atom<boolean | null>(true);
previewBlockIdAtom.onMount = set => { previewBlockIdAtom.onMount = set => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {

View File

@@ -1,6 +1,7 @@
/// <reference types="react/experimental" /> /// <reference types="react/experimental" />
import '@blocksuite/blocks'; import '@blocksuite/blocks';
import { Button, Tooltip } from '@affine/component';
import type { ImageBlockModel } from '@blocksuite/blocks'; import type { ImageBlockModel } from '@blocksuite/blocks';
import { assertExists } from '@blocksuite/global/utils'; import { assertExists } from '@blocksuite/global/utils';
import { import {
@@ -21,24 +22,26 @@ import { Suspense, useCallback } from 'react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import Button from '../../ui/button/button';
import { useZoomControls } from './hooks/use-zoom'; import { useZoomControls } from './hooks/use-zoom';
import { import {
buttonIconStyle,
buttonStyle, buttonStyle,
captionStyle, captionStyle,
groupStyle, groupStyle,
imageBottomContainerStyle, imageBottomContainerStyle,
imageNavigationControlStyle,
imagePreviewActionBarStyle, imagePreviewActionBarStyle,
imagePreviewBackgroundStyle,
imagePreviewModalCaptionStyle, imagePreviewModalCaptionStyle,
imagePreviewModalCenterStyle, imagePreviewModalCenterStyle,
imagePreviewModalCloseButtonStyle, imagePreviewModalCloseButtonStyle,
imagePreviewModalContainerStyle, imagePreviewModalContainerStyle,
imagePreviewModalGoStyle,
imagePreviewModalStyle, imagePreviewModalStyle,
scaleIndicatorStyle, loaded,
scaleIndicatorButtonStyle,
unloaded,
} from './index.css'; } from './index.css';
import { previewBlockIdAtom } from './index.jotai'; import { hasAnimationPlayedAtom, previewBlockIdAtom } from './index.jotai';
import { toast } from './toast';
export type ImagePreviewModalProps = { export type ImagePreviewModalProps = {
workspace: Workspace; workspace: Workspace;
@@ -52,64 +55,46 @@ const ImagePreviewModalImpl = (
} }
): ReactElement | null => { ): ReactElement | null => {
const [blockId, setBlockId] = useAtom(previewBlockIdAtom); 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 ImageBlockModel;
assertExists(block);
return block?.caption;
});
useEffect(() => {
const page = props.workspace.getPage(props.pageId);
assertExists(page);
const block = page.getBlockById(props.blockId) as ImageBlockModel;
assertExists(block);
setCaption(block?.caption);
}, [props.blockId, props.pageId, props.workspace]);
const { data } = useSWR(['workspace', 'embed', props.pageId, props.blockId], {
fetcher: ([_, __, pageId, blockId]) => {
const page = props.workspace.getPage(pageId);
assertExists(page);
const block = page.getBlockById(blockId) as ImageBlockModel;
assertExists(block);
return props.workspace.blobs.get(block?.sourceId);
},
suspense: true,
});
const zoomRef = useRef<HTMLDivElement | null>(null); const zoomRef = useRef<HTMLDivElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null); const imageRef = useRef<HTMLImageElement | null>(null);
const { const {
zoomIn,
zoomOut,
isZoomedBigger, isZoomedBigger,
handleDrag, handleDrag,
handleDragStart, handleDragStart,
handleDragEnd, handleDragEnd,
resetZoom, resetZoom,
zoomIn,
zoomOut,
resetScale,
currentScale, currentScale,
} = useZoomControls({ zoomRef, imageRef }); } = useZoomControls({ zoomRef, imageRef });
const [prevData, setPrevData] = useState<string | null>(() => data); const [isOpen, setIsOpen] = useAtom(hasAnimationPlayedAtom);
const [url, setUrl] = useState<string | null>(null); const [hasPlayedAnimation, setHasPlayedAnimation] = useState<boolean>(false);
if (prevData !== data) {
if (url) {
URL.revokeObjectURL(url);
}
setUrl(URL.createObjectURL(data));
setPrevData(data); useEffect(() => {
} else if (!url) { let timeoutId: NodeJS.Timeout;
setUrl(URL.createObjectURL(data));
} if (!isOpen) {
if (!url) { timeoutId = setTimeout(() => {
return null; props.onClose();
setIsOpen(true);
}, 300);
return () => {
clearTimeout(timeoutId);
};
} }
const nextImageHandler = (blockId: string | null) => { return () => {};
}, [isOpen, props, setIsOpen]);
const nextImageHandler = useCallback(
(blockId: string | null) => {
assertExists(blockId); assertExists(blockId);
const workspace = props.workspace; const workspace = props.workspace;
if (!hasPlayedAnimation) {
setHasPlayedAnimation(true);
}
const page = workspace.getPage(props.pageId); const page = workspace.getPage(props.pageId);
assertExists(page); assertExists(page);
const block = page.getBlockById(blockId); const block = page.getBlockById(blockId);
@@ -117,14 +102,17 @@ const ImagePreviewModalImpl = (
const nextBlock = page const nextBlock = page
.getNextSiblings(block) .getNextSiblings(block)
.find( .find(
(block): block is ImageBlockModel => block.flavour === 'affine:image' (block): block is ImageBlockModel => block.flavour === 'affine:embed'
); );
if (nextBlock) { if (nextBlock) {
setBlockId(nextBlock.id); setBlockId(nextBlock.id);
} }
}; },
[props.pageId, props.workspace, setBlockId, hasPlayedAnimation]
);
const previousImageHandler = (blockId: string | null) => { const previousImageHandler = useCallback(
(blockId: string | null) => {
assertExists(blockId); assertExists(blockId);
const workspace = props.workspace; const workspace = props.workspace;
const page = workspace.getPage(props.pageId); const page = workspace.getPage(props.pageId);
@@ -134,17 +122,21 @@ const ImagePreviewModalImpl = (
const prevBlock = page const prevBlock = page
.getPreviousSiblings(block) .getPreviousSiblings(block)
.findLast( .findLast(
(block): block is ImageBlockModel => block.flavour === 'affine:image' (block): block is ImageBlockModel => block.flavour === 'affine:embed'
); );
if (prevBlock) { if (prevBlock) {
setBlockId(prevBlock.id); setBlockId(prevBlock.id);
} }
}; resetZoom();
},
[props.pageId, props.workspace, setBlockId, resetZoom]
);
const deleteHandler = (blockId: string) => { const deleteHandler = useCallback(
const workspace = props.workspace; (blockId: string) => {
const { pageId, workspace, onClose } = props;
const page = workspace.getPage(props.pageId); const page = workspace.getPage(pageId);
assertExists(page); assertExists(page);
const block = page.getBlockById(blockId); const block = page.getBlockById(blockId);
assertExists(block); assertExists(block);
@@ -152,13 +144,15 @@ const ImagePreviewModalImpl = (
page page
.getPreviousSiblings(block) .getPreviousSiblings(block)
.findLast( .findLast(
(block): block is ImageBlockModel => block.flavour === 'affine:image' (block): block is ImageBlockModel =>
block.flavour === 'affine:embed'
) )
) { ) {
const prevBlock = page const prevBlock = page
.getPreviousSiblings(block) .getPreviousSiblings(block)
.findLast( .findLast(
(block): block is ImageBlockModel => block.flavour === 'affine:image' (block): block is ImageBlockModel =>
block.flavour === 'affine:embed'
); );
if (prevBlock) { if (prevBlock) {
setBlockId(prevBlock.id); setBlockId(prevBlock.id);
@@ -167,32 +161,29 @@ const ImagePreviewModalImpl = (
page page
.getNextSiblings(block) .getNextSiblings(block)
.find( .find(
(block): block is ImageBlockModel => block.flavour === 'affine:image' (block): block is ImageBlockModel =>
block.flavour === 'affine:embed'
) )
) { ) {
const nextBlock = page const nextBlock = page
.getNextSiblings(block) .getNextSiblings(block)
.find( .find(
(block): block is ImageBlockModel => block.flavour === 'affine:image' (block): block is ImageBlockModel =>
block.flavour === 'affine:embed'
); );
if (nextBlock) { 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); setBlockId(nextBlock.id);
} }
} else { } else {
props.onClose(); onClose();
} }
page.deleteBlock(block); page.deleteBlock(block);
}; },
[props, setBlockId]
);
let actionbarTimeout: NodeJS.Timeout; const downloadHandler = useCallback(
async (blockId: string | null) => {
const downloadHandler = async (blockId: string | null) => {
const workspace = props.workspace; const workspace = props.workspace;
const page = workspace.getPage(props.pageId); const page = workspace.getPage(props.pageId);
assertExists(page); assertExists(page);
@@ -238,66 +229,65 @@ const ImagePreviewModalImpl = (
new Blob([arrayBuffer], { type: fileType }) new Blob([arrayBuffer], { type: fileType })
); );
const a = document.createElement('a'); const a = document.createElement('a');
const event = new MouseEvent('click');
a.download = block.id;
a.href = downloadUrl; a.href = downloadUrl;
a.dispatchEvent(event); a.download = block.id ?? 'image';
document.body.appendChild(a);
// cleanup a.click();
a.remove(); document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
} }
}; },
[props.pageId, props.workspace]
);
const [caption, setCaption] = useState(() => {
const page = props.workspace.getPage(props.pageId);
assertExists(page);
const block = page.getBlockById(props.blockId) as ImageBlockModel;
assertExists(block);
return block?.caption;
});
useEffect(() => {
const page = props.workspace.getPage(props.pageId);
assertExists(page);
const block = page.getBlockById(props.blockId) as ImageBlockModel;
assertExists(block);
setCaption(block?.caption);
}, [props.blockId, props.pageId, props.workspace]);
const { data } = useSWR(['workspace', 'embed', props.pageId, props.blockId], {
fetcher: ([_, __, pageId, blockId]) => {
const page = props.workspace.getPage(pageId);
assertExists(page);
const block = page.getBlockById(blockId) as ImageBlockModel;
assertExists(block);
return props.workspace.blobs.get(block?.sourceId);
},
suspense: true,
});
const handleMouseEnter = () => { const [prevData, setPrevData] = useState<string | null>(() => data);
clearTimeout(actionbarTimeout); const [url, setUrl] = useState<string | null>(null);
setBIsActionBarVisible(true);
};
const handleMouseLeave = () => { if (prevData !== data) {
actionbarTimeout = setTimeout(() => { if (url) {
setBIsActionBarVisible(false); URL.revokeObjectURL(url);
}, 3000); }
}; setUrl(URL.createObjectURL(data));
setPrevData(data);
} else if (!url) {
setUrl(URL.createObjectURL(data));
}
if (!url) {
return null;
}
return ( return (
<div <div
data-testid="image-preview-modal"
className={imagePreviewModalStyle} className={imagePreviewModalStyle}
onClick={event => onClick={event => {
event.target === event.currentTarget ? props.onClose() : null 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={imagePreviewModalContainerStyle}>
<div <div
className={clsx('zoom-area', { 'zoomed-bigger': isZoomedBigger })} className={clsx('zoom-area', { 'zoomed-bigger': isZoomedBigger })}
@@ -306,6 +296,7 @@ const ImagePreviewModalImpl = (
<div className={imagePreviewModalCenterStyle}> <div className={imagePreviewModalCenterStyle}>
<img <img
data-blob-id={props.blockId} data-blob-id={props.blockId}
data-testid="image-content"
src={url} src={url}
alt={caption} alt={caption}
ref={imageRef} ref={imageRef}
@@ -313,8 +304,6 @@ const ImagePreviewModalImpl = (
onMouseDown={handleDragStart} onMouseDown={handleDragStart}
onMouseMove={handleDrag} onMouseMove={handleDrag}
onMouseUp={handleDragEnd} onMouseUp={handleDragEnd}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onLoad={resetZoom} onLoad={resetZoom}
/> />
{isZoomedBigger ? null : ( {isZoomedBigger ? null : (
@@ -323,32 +312,9 @@ const ImagePreviewModalImpl = (
</div> </div>
</div> </div>
</div> </div>
<button
onClick={() => {
props.onClose();
}}
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>
{bIsActionBarVisible ? (
<div <div
className={imageBottomContainerStyle} className={imageBottomContainerStyle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={event => event.stopPropagation()} onClick={event => event.stopPropagation()}
> >
{isZoomedBigger && caption !== '' ? ( {isZoomedBigger && caption !== '' ? (
@@ -356,53 +322,81 @@ const ImagePreviewModalImpl = (
) : null} ) : null}
<div className={imagePreviewActionBarStyle}> <div className={imagePreviewActionBarStyle}>
<div> <div>
<Tooltip content={'Previous'} disablePortal={false}>
<Button <Button
icon={<ArrowLeftSmallIcon />} icon={<ArrowLeftSmallIcon className={buttonIconStyle} />}
noBorder={true} noBorder={true}
className={buttonStyle} className={buttonStyle}
hoverColor={'-moz-initial'}
onClick={() => { onClick={() => {
assertExists(blockId); assertExists(blockId);
previousImageHandler(blockId); previousImageHandler(blockId);
}} }}
/> />
</Tooltip>
<Tooltip content={'Next'} disablePortal={false}>
<Button <Button
icon={<ArrowRightSmallIcon />} icon={<ArrowRightSmallIcon className={buttonIconStyle} />}
noBorder={true} noBorder={true}
className={buttonStyle} className={buttonStyle}
hoverColor={'-moz-initial'}
onClick={() => { onClick={() => {
assertExists(blockId); assertExists(blockId);
nextImageHandler(blockId); nextImageHandler(blockId);
}} }}
/> />
</Tooltip>
</div> </div>
<div className={groupStyle}> <div className={groupStyle}></div>
<Tooltip
content={'Fit to Screen'}
disablePortal={true}
showArrow={false}
>
<Button <Button
icon={<ViewBarIcon />} icon={<ViewBarIcon className={buttonIconStyle} />}
noBorder={true} noBorder={true}
hoverColor={'-moz-initial'}
className={buttonStyle} className={buttonStyle}
onClick={() => resetZoom()} onClick={() => resetZoom()}
/> />
</Tooltip>
<Tooltip content={'Zoom out'} disablePortal={false}>
<Button <Button
icon={<MinusIcon />} icon={<MinusIcon className={buttonIconStyle} />}
noBorder={true} noBorder={true}
className={buttonStyle} className={buttonStyle}
hoverColor={'-moz-initial'}
onClick={zoomOut} onClick={zoomOut}
/> />
<span className={scaleIndicatorStyle}>{`${( </Tooltip>
currentScale * 100 <Tooltip content={'Reset Scale'} disablePortal={false}>
).toFixed(0)}%`}</span>
<Button <Button
icon={<PlusIcon />} 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} noBorder={true}
className={buttonStyle} className={buttonStyle}
hoverColor={'-moz-initial'}
onClick={() => zoomIn()} onClick={() => zoomIn()}
/> />
</div> </Tooltip>
<div className={groupStyle}> <div className={groupStyle}></div>
<Tooltip content={'Download'} disablePortal={false}>
<Button <Button
icon={<DownloadIcon />} icon={<DownloadIcon className={buttonIconStyle} />}
noBorder={true} noBorder={true}
className={buttonStyle} className={buttonStyle}
hoverColor={'-moz-initial'}
onClick={() => { onClick={() => {
assertExists(blockId); assertExists(blockId);
downloadHandler(blockId).catch(err => { downloadHandler(blockId).catch(err => {
@@ -410,10 +404,13 @@ const ImagePreviewModalImpl = (
}); });
}} }}
/> />
</Tooltip>
<Tooltip content={'Copy to clipboard'} disablePortal={false}>
<Button <Button
icon={<CopyIcon />} icon={<CopyIcon className={buttonIconStyle} />}
noBorder={true} noBorder={true}
className={buttonStyle} className={buttonStyle}
hoverColor={'-moz-initial'}
onClick={() => { onClick={() => {
if (!imageRef.current) { if (!imageRef.current) {
return; return;
@@ -440,28 +437,27 @@ const ImagePreviewModalImpl = (
URL.revokeObjectURL(dataUrl); URL.revokeObjectURL(dataUrl);
}) })
.catch(error => { .catch(error => {
console.error( console.error('Error copying image to clipboard', error);
'Error copying image to clipboard',
error
);
URL.revokeObjectURL(dataUrl); URL.revokeObjectURL(dataUrl);
}); });
}, 'image/png'); }, 'image/png');
toast('Copied to clipboard.');
}} }}
/> />
</div> </Tooltip>
<div className={groupStyle}> <div className={groupStyle}></div>
<Tooltip content={'Delete'} disablePortal={false}>
<Button <Button
icon={<DeleteIcon />} icon={<DeleteIcon className={buttonIconStyle} />}
noBorder={true} noBorder={true}
className={buttonStyle} className={buttonStyle}
onClick={() => blockId && deleteHandler(blockId)} onClick={() => blockId && deleteHandler(blockId)}
hoverColor={'-moz-initial'}
/> />
</Tooltip>
</div> </div>
</div> </div>
</div> </div>
) : null}
</div>
); );
}; };
@@ -469,13 +465,16 @@ export const ImagePreviewModal = (
props: ImagePreviewModalProps props: ImagePreviewModalProps
): ReactElement | null => { ): ReactElement | null => {
const [blockId, setBlockId] = useAtom(previewBlockIdAtom); const [blockId, setBlockId] = useAtom(previewBlockIdAtom);
const [isOpen, setIsOpen] = useAtom(hasAnimationPlayedAtom);
const handleKeyUp = useCallback( const handleKeyUp = useCallback(
(event: KeyboardEvent) => { (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setBlockId(null); if (isOpen) {
setIsOpen(false);
}
return; return;
} }
@@ -495,7 +494,7 @@ export const ImagePreviewModal = (
.getPreviousSiblings(block) .getPreviousSiblings(block)
.findLast( .findLast(
(block): block is ImageBlockModel => (block): block is ImageBlockModel =>
block.flavour === 'affine:image' block.flavour === 'affine:embed'
); );
if (prevBlock) { if (prevBlock) {
setBlockId(prevBlock.id); setBlockId(prevBlock.id);
@@ -505,7 +504,7 @@ export const ImagePreviewModal = (
.getNextSiblings(block) .getNextSiblings(block)
.find( .find(
(block): block is ImageBlockModel => (block): block is ImageBlockModel =>
block.flavour === 'affine:image' block.flavour === 'affine:embed'
); );
if (nextBlock) { if (nextBlock) {
setBlockId(nextBlock.id); setBlockId(nextBlock.id);
@@ -516,7 +515,7 @@ export const ImagePreviewModal = (
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
}, },
[blockId, setBlockId, props.workspace, props.pageId] [blockId, setBlockId, props.workspace, props.pageId, isOpen, setIsOpen]
); );
useEffect(() => { useEffect(() => {
@@ -531,12 +530,38 @@ export const ImagePreviewModal = (
} }
return ( return (
<Suspense fallback={<div className={imagePreviewModalStyle} />}> <div
data-testid="image-preview-modal"
className={`${imagePreviewBackgroundStyle} ${isOpen ? loaded : unloaded}`}
>
<Suspense fallback={<div />}>
<ImagePreviewModalImpl <ImagePreviewModalImpl
{...props} {...props}
blockId={blockId} blockId={blockId}
onClose={() => setBlockId(null)} onClose={() => setBlockId(null)}
/> />
</Suspense> </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, duration: 2500,
} }
) => { ) => {
if (!ToastContainer) { if (!ToastContainer || (portal && !portal.contains(ToastContainer))) {
ToastContainer = createToastContainer(portal); ToastContainer = createToastContainer(portal);
} }
@@ -126,6 +126,7 @@ export const toast = (
element.style.margin = '0'; element.style.margin = '0';
element.style.padding = '0'; element.style.padding = '0';
// wait for transition // wait for transition
// ToastContainer = null;
element.addEventListener('transitionend', () => { element.addEventListener('transitionend', () => {
element.remove(); element.remove();
}); });

View File

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