mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-11-30 17:42:12 +03:00
fix: image preview (#2818)
This commit is contained in:
parent
10c7f93a85
commit
4307e1eb6b
@ -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,
|
||||
|
@ -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',
|
||||
});
|
||||
|
@ -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') {
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
}>;
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
|
@ -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',
|
||||
};
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user