mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-24 04:27:27 +08:00
feat: image-preview (#2720)
Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
|||||||
|
import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface UseZoomControlsProps {
|
||||||
|
zoomRef: RefObject<HTMLDivElement>;
|
||||||
|
imageRef: RefObject<HTMLImageElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useZoomControls = ({
|
||||||
|
zoomRef,
|
||||||
|
imageRef,
|
||||||
|
}: UseZoomControlsProps) => {
|
||||||
|
const [currentScale, setCurrentScale] = useState<number>(0.5);
|
||||||
|
const [isZoomedBigger, setIsZoomedBigger] = useState<boolean>(false);
|
||||||
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||||
|
const [mouseX, setMouseX] = useState<number>(0);
|
||||||
|
const [mouseY, setMouseY] = useState<number>(0);
|
||||||
|
const [dragBeforeX, setDragBeforeX] = useState<number>(0);
|
||||||
|
const [dragBeforeY, setDragBeforeY] = useState<number>(0);
|
||||||
|
const [imagePos, setImagePos] = useState<{ x: number; y: number }>({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const zoomIn = useCallback(() => {
|
||||||
|
const image = imageRef.current;
|
||||||
|
|
||||||
|
if (image && currentScale < 2) {
|
||||||
|
const newScale = currentScale + 0.1;
|
||||||
|
setCurrentScale(newScale);
|
||||||
|
image.style.width = `${image.naturalWidth * newScale}px`;
|
||||||
|
image.style.height = `${image.naturalHeight * newScale}px`;
|
||||||
|
}
|
||||||
|
}, [imageRef, currentScale]);
|
||||||
|
|
||||||
|
const zoomOut = useCallback(() => {
|
||||||
|
const image = imageRef.current;
|
||||||
|
if (image && currentScale > 0.5) {
|
||||||
|
const newScale = currentScale - 0.1;
|
||||||
|
setCurrentScale(newScale);
|
||||||
|
image.style.width = `${image.naturalWidth * newScale}px`;
|
||||||
|
image.style.height = `${image.naturalHeight * newScale}px`;
|
||||||
|
if (!isZoomedBigger) {
|
||||||
|
image.style.transform = `translate(0px, 0px)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [imageRef, currentScale, isZoomedBigger]);
|
||||||
|
|
||||||
|
const resetZoom = useCallback(() => {
|
||||||
|
const image = imageRef.current;
|
||||||
|
if (image) {
|
||||||
|
const newScale = 0.5;
|
||||||
|
setCurrentScale(newScale);
|
||||||
|
image.style.width = `${image.naturalWidth * newScale}px`;
|
||||||
|
image.style.height = `${image.naturalHeight * newScale}px`;
|
||||||
|
image.style.transform = `translate(0px, 0px)`;
|
||||||
|
setImagePos({ x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
}, [imageRef]);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(event: ReactMouseEvent) => {
|
||||||
|
event?.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
const image = imageRef.current;
|
||||||
|
if (image && isZoomedBigger) {
|
||||||
|
image.style.cursor = 'grab';
|
||||||
|
const rect = image.getBoundingClientRect();
|
||||||
|
setDragBeforeX(rect.left);
|
||||||
|
setDragBeforeY(rect.top);
|
||||||
|
setMouseX(event.clientX);
|
||||||
|
setMouseY(event.clientY);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[imageRef, isZoomedBigger]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDrag = useCallback(
|
||||||
|
(event: ReactMouseEvent) => {
|
||||||
|
event?.preventDefault();
|
||||||
|
const image = imageRef.current;
|
||||||
|
|
||||||
|
if (isDragging && image && isZoomedBigger) {
|
||||||
|
image.style.cursor = 'grabbing';
|
||||||
|
const currentX = imagePos.x;
|
||||||
|
const currentY = imagePos.y;
|
||||||
|
const newPosX = currentX + event.clientX - mouseX;
|
||||||
|
const newPosY = currentY + event.clientY - mouseY;
|
||||||
|
image.style.transform = `translate(${newPosX}px, ${newPosY}px)`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
imagePos.x,
|
||||||
|
imagePos.y,
|
||||||
|
imageRef,
|
||||||
|
isDragging,
|
||||||
|
isZoomedBigger,
|
||||||
|
mouseX,
|
||||||
|
mouseY,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dragEndImpl = useCallback(() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const image = imageRef.current;
|
||||||
|
if (image && isZoomedBigger && isDragging) {
|
||||||
|
image.style.cursor = 'pointer';
|
||||||
|
const rect = image.getBoundingClientRect();
|
||||||
|
const newPos = { x: rect.left, y: rect.top };
|
||||||
|
const currentX = imagePos.x;
|
||||||
|
const currentY = imagePos.y;
|
||||||
|
const newPosX = currentX + newPos.x - dragBeforeX;
|
||||||
|
const newPosY = currentY + newPos.y - dragBeforeY;
|
||||||
|
setImagePos({ x: newPosX, y: newPosY });
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
dragBeforeX,
|
||||||
|
dragBeforeY,
|
||||||
|
imagePos.x,
|
||||||
|
imagePos.y,
|
||||||
|
imageRef,
|
||||||
|
isDragging,
|
||||||
|
isZoomedBigger,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: ReactMouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
dragEndImpl();
|
||||||
|
},
|
||||||
|
[dragEndImpl]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
dragEndImpl();
|
||||||
|
}
|
||||||
|
}, [isDragging, dragEndImpl]);
|
||||||
|
|
||||||
|
const checkZoomSize = useCallback(() => {
|
||||||
|
const { current: zoomArea } = zoomRef;
|
||||||
|
if (zoomArea) {
|
||||||
|
const image = zoomArea.querySelector('img');
|
||||||
|
if (image) {
|
||||||
|
const zoomedWidth = image.naturalWidth * currentScale;
|
||||||
|
const zoomedHeight = image.naturalHeight * currentScale;
|
||||||
|
const containerWidth = window.innerWidth;
|
||||||
|
const containerHeight = window.innerHeight;
|
||||||
|
setIsZoomedBigger(
|
||||||
|
zoomedWidth > containerWidth || zoomedHeight > containerHeight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentScale, zoomRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = (event: WheelEvent) => {
|
||||||
|
const { deltaY } = event;
|
||||||
|
if (deltaY > 0) {
|
||||||
|
zoomOut();
|
||||||
|
} else if (deltaY < 0) {
|
||||||
|
zoomIn();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
checkZoomSize();
|
||||||
|
};
|
||||||
|
|
||||||
|
checkZoomSize();
|
||||||
|
|
||||||
|
window.addEventListener('wheel', handleScroll, { passive: false });
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('wheel', handleScroll);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [zoomIn, zoomOut, checkZoomSize, handleMouseUp]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
resetZoom,
|
||||||
|
isZoomedBigger,
|
||||||
|
currentScale,
|
||||||
|
handleDragStart,
|
||||||
|
handleDrag,
|
||||||
|
handleDragEnd,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -11,7 +11,8 @@ export const imagePreviewModalStyle = style({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
background: 'var(--affine-background-modal-color)',
|
// background: 'var(--affine-background-modal-color)',
|
||||||
|
background: 'rgba(0,0,0,0.75)',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const imagePreviewModalCloseButtonStyle = style({
|
export const imagePreviewModalCloseButtonStyle = style({
|
||||||
@@ -20,11 +21,9 @@ export const imagePreviewModalCloseButtonStyle = style({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
||||||
height: '36px',
|
height: '36px',
|
||||||
width: '36px',
|
width: '36px',
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
|
|
||||||
top: '0.5rem',
|
top: '0.5rem',
|
||||||
right: '0.5rem',
|
right: '0.5rem',
|
||||||
background: 'var(--affine-white)',
|
background: 'var(--affine-white)',
|
||||||
@@ -33,37 +32,97 @@ export const imagePreviewModalCloseButtonStyle = style({
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const imagePreviewModalGoStyle = style({
|
export const imagePreviewModalGoStyle = style({
|
||||||
height: '50%',
|
|
||||||
color: 'var(--affine-white)',
|
color: 'var(--affine-white)',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
fontSize: '60px',
|
fontSize: '60px',
|
||||||
lineHeight: '60px',
|
lineHeight: '60px',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
opacity: '0.2',
|
opacity: '0.2',
|
||||||
padding: '0 15px',
|
padding: '0 15px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const imageNavigationControlStyle = style({
|
||||||
|
display: 'flex',
|
||||||
|
height: '100%',
|
||||||
|
zIndex: 0,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
export const imagePreviewModalContainerStyle = style({
|
export const imagePreviewModalContainerStyle = style({
|
||||||
position: 'absolute',
|
display: 'flex',
|
||||||
top: '20%',
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 1,
|
||||||
|
'@media': {
|
||||||
|
'screen and (max-width: 768px)': {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const imagePreviewModalImageStyle = style({
|
export const imagePreviewModalCenterStyle = style({
|
||||||
background: 'transparent',
|
display: 'flex',
|
||||||
maxWidth: '686px',
|
flexDirection: 'column',
|
||||||
objectFit: 'contain',
|
alignItems: 'center',
|
||||||
objectPosition: 'center',
|
|
||||||
borderRadius: '4px',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const imagePreviewModalActionsStyle = style({
|
export const imagePreviewModalCaptionStyle = style({
|
||||||
position: 'absolute',
|
color: 'var(--affine-white)',
|
||||||
|
marginTop: '24px',
|
||||||
|
'@media': {
|
||||||
|
'screen and (max-width: 768px)': {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const imagePreviewActionBarStyle = style({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '16px 0',
|
||||||
|
backgroundColor: 'var(--affine-white)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)',
|
||||||
|
maxWidth: 'max-content',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const groupStyle = style({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'var(--affine-white)',
|
||||||
|
borderLeft: '1px solid #E3E2E4',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buttonStyle = style({
|
||||||
|
paddingLeft: '10px',
|
||||||
|
paddingRight: '10px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const scaleIndicatorStyle = style({
|
||||||
|
margin: '0 8px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const imageBottomContainerStyle = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'fixed',
|
||||||
bottom: '28px',
|
bottom: '28px',
|
||||||
background: 'var(--affine-white)',
|
zIndex: baseTheme.zIndexModal + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const captionStyle = style({
|
||||||
|
maxWidth: '686px',
|
||||||
|
color: 'var(--affine-white)',
|
||||||
|
background: 'rgba(0,0,0,0.75)',
|
||||||
|
padding: '10px',
|
||||||
|
marginBottom: '21px',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,19 +3,40 @@ import '@blocksuite/blocks';
|
|||||||
|
|
||||||
import type { EmbedBlockModel } from '@blocksuite/blocks';
|
import type { EmbedBlockModel } from '@blocksuite/blocks';
|
||||||
import { assertExists } from '@blocksuite/global/utils';
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
import {
|
||||||
|
ArrowLeftSmallIcon,
|
||||||
|
ArrowRightSmallIcon,
|
||||||
|
CopyIcon,
|
||||||
|
DeleteIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
MinusIcon,
|
||||||
|
PlusIcon,
|
||||||
|
ViewBarIcon,
|
||||||
|
} from '@blocksuite/icons';
|
||||||
import type { Workspace } from '@blocksuite/store';
|
import type { Workspace } from '@blocksuite/store';
|
||||||
|
import clsx from 'clsx';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { Suspense, useCallback } from 'react';
|
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 {
|
import {
|
||||||
|
buttonStyle,
|
||||||
|
captionStyle,
|
||||||
|
groupStyle,
|
||||||
|
imageBottomContainerStyle,
|
||||||
|
imageNavigationControlStyle,
|
||||||
|
imagePreviewActionBarStyle,
|
||||||
|
imagePreviewModalCaptionStyle,
|
||||||
|
imagePreviewModalCenterStyle,
|
||||||
imagePreviewModalCloseButtonStyle,
|
imagePreviewModalCloseButtonStyle,
|
||||||
imagePreviewModalContainerStyle,
|
imagePreviewModalContainerStyle,
|
||||||
imagePreviewModalGoStyle,
|
imagePreviewModalGoStyle,
|
||||||
imagePreviewModalImageStyle,
|
|
||||||
imagePreviewModalStyle,
|
imagePreviewModalStyle,
|
||||||
|
scaleIndicatorStyle,
|
||||||
} from './index.css';
|
} from './index.css';
|
||||||
import { previewBlockIdAtom } from './index.jotai';
|
import { previewBlockIdAtom } from './index.jotai';
|
||||||
|
|
||||||
@@ -31,38 +52,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 [caption, setCaption] = useState(() => {
|
||||||
const page = props.workspace.getPage(props.pageId);
|
const page = props.workspace.getPage(props.pageId);
|
||||||
assertExists(page);
|
assertExists(page);
|
||||||
const block = page.getBlockById(props.blockId) as EmbedBlockModel | null;
|
const block = page.getBlockById(props.blockId) as EmbedBlockModel;
|
||||||
assertExists(block);
|
assertExists(block);
|
||||||
return block.caption;
|
return block?.caption;
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const page = props.workspace.getPage(props.pageId);
|
const page = props.workspace.getPage(props.pageId);
|
||||||
assertExists(page);
|
assertExists(page);
|
||||||
const block = page.getBlockById(props.blockId) as EmbedBlockModel | null;
|
const block = page.getBlockById(props.blockId) as EmbedBlockModel;
|
||||||
assertExists(block);
|
assertExists(block);
|
||||||
const disposable = block.propsUpdated.on(() => {
|
setCaption(block?.caption);
|
||||||
setCaption(block.caption);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
disposable.dispose();
|
|
||||||
};
|
|
||||||
}, [props.blockId, props.pageId, props.workspace]);
|
}, [props.blockId, props.pageId, props.workspace]);
|
||||||
const { data } = useSWR(['workspace', 'embed', props.pageId, props.blockId], {
|
const { data } = useSWR(['workspace', 'embed', props.pageId, props.blockId], {
|
||||||
fetcher: ([_, __, pageId, blockId]) => {
|
fetcher: ([_, __, pageId, blockId]) => {
|
||||||
const page = props.workspace.getPage(pageId);
|
const page = props.workspace.getPage(pageId);
|
||||||
assertExists(page);
|
assertExists(page);
|
||||||
const block = page.getBlockById(blockId) as EmbedBlockModel | null;
|
const block = page.getBlockById(blockId) as EmbedBlockModel;
|
||||||
assertExists(block);
|
assertExists(block);
|
||||||
return props.workspace.blobs.get(block.sourceId);
|
return props.workspace.blobs.get(block?.sourceId);
|
||||||
},
|
},
|
||||||
suspense: true,
|
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 [prevData, setPrevData] = useState<string | null>(() => data);
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
const imageRef = useRef<HTMLImageElement>(null);
|
|
||||||
if (prevData !== data) {
|
if (prevData !== data) {
|
||||||
if (url) {
|
if (url) {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
@@ -76,8 +105,231 @@ const ImagePreviewModalImpl = (
|
|||||||
if (!url) {
|
if (!url) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const nextImageHandler = (blockId: string | null) => {
|
||||||
|
assertExists(blockId);
|
||||||
|
const workspace = props.workspace;
|
||||||
|
|
||||||
|
const page = workspace.getPage(props.pageId);
|
||||||
|
assertExists(page);
|
||||||
|
const block = page.getBlockById(blockId);
|
||||||
|
assertExists(block);
|
||||||
|
const nextBlock = page
|
||||||
|
.getNextSiblings(block)
|
||||||
|
.find(
|
||||||
|
(block): block is EmbedBlockModel => block.flavour === 'affine:embed'
|
||||||
|
);
|
||||||
|
if (nextBlock) {
|
||||||
|
setBlockId(nextBlock.id);
|
||||||
|
const image = imageRef.current;
|
||||||
|
resetZoom();
|
||||||
|
if (image) {
|
||||||
|
image.style.width = '50%'; // Reset the width to its original size
|
||||||
|
image.style.height = 'auto'; // Reset the height to maintain aspect ratio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previousImageHandler = (blockId: string | null) => {
|
||||||
|
assertExists(blockId);
|
||||||
|
const workspace = props.workspace;
|
||||||
|
const page = workspace.getPage(props.pageId);
|
||||||
|
assertExists(page);
|
||||||
|
const block = page.getBlockById(blockId);
|
||||||
|
assertExists(block);
|
||||||
|
const prevBlock = page
|
||||||
|
.getPreviousSiblings(block)
|
||||||
|
.findLast(
|
||||||
|
(block): block is EmbedBlockModel => block.flavour === 'affine:embed'
|
||||||
|
);
|
||||||
|
if (prevBlock) {
|
||||||
|
setBlockId(prevBlock.id);
|
||||||
|
const image = imageRef.current;
|
||||||
|
if (image) {
|
||||||
|
resetZoom();
|
||||||
|
image.style.width = '50%'; // Reset the width to its original size
|
||||||
|
image.style.height = 'auto'; // Reset the height to maintain aspect ratio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteHandler = (blockId: string) => {
|
||||||
|
const workspace = props.workspace;
|
||||||
|
|
||||||
|
const page = workspace.getPage(props.pageId);
|
||||||
|
assertExists(page);
|
||||||
|
const block = page.getBlockById(blockId);
|
||||||
|
assertExists(block);
|
||||||
|
if (
|
||||||
|
page
|
||||||
|
.getPreviousSiblings(block)
|
||||||
|
.findLast(
|
||||||
|
(block): block is EmbedBlockModel => block.flavour === 'affine:embed'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const prevBlock = page
|
||||||
|
.getPreviousSiblings(block)
|
||||||
|
.findLast(
|
||||||
|
(block): block is EmbedBlockModel => block.flavour === 'affine:embed'
|
||||||
|
);
|
||||||
|
if (prevBlock) {
|
||||||
|
setBlockId(prevBlock.id);
|
||||||
|
const image = imageRef.current;
|
||||||
|
resetZoom();
|
||||||
|
if (image) {
|
||||||
|
image.style.width = '100%'; // Reset the width to its original size
|
||||||
|
image.style.height = 'auto'; // Reset the height to maintain aspect ratio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
page
|
||||||
|
.getNextSiblings(block)
|
||||||
|
.find(
|
||||||
|
(block): block is EmbedBlockModel => block.flavour === 'affine:embed'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const nextBlock = page
|
||||||
|
.getNextSiblings(block)
|
||||||
|
.find(
|
||||||
|
(block): block is EmbedBlockModel => block.flavour === 'affine:embed'
|
||||||
|
);
|
||||||
|
if (nextBlock) {
|
||||||
|
const image = imageRef.current;
|
||||||
|
resetZoom();
|
||||||
|
if (image) {
|
||||||
|
image.style.width = '100%'; // Reset the width to its original size
|
||||||
|
image.style.height = 'auto'; // Reset the height to maintain aspect ratio
|
||||||
|
}
|
||||||
|
setBlockId(nextBlock.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
props.onClose();
|
||||||
|
}
|
||||||
|
page.deleteBlock(block);
|
||||||
|
};
|
||||||
|
|
||||||
|
let actionbarTimeout: NodeJS.Timeout;
|
||||||
|
|
||||||
|
const downloadHandler = async (blockId: string | null) => {
|
||||||
|
const workspace = props.workspace;
|
||||||
|
const page = workspace.getPage(props.pageId);
|
||||||
|
assertExists(page);
|
||||||
|
if (typeof blockId === 'string') {
|
||||||
|
const block = page.getBlockById(blockId) as EmbedBlockModel;
|
||||||
|
assertExists(block);
|
||||||
|
const store = await block.page.blobs;
|
||||||
|
const url = store?.get(block.sourceId);
|
||||||
|
const img = await url;
|
||||||
|
if (!img) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const arrayBuffer = await img.arrayBuffer();
|
||||||
|
const buffer = new Uint8Array(arrayBuffer);
|
||||||
|
let fileType: string;
|
||||||
|
if (
|
||||||
|
buffer[0] === 0x47 &&
|
||||||
|
buffer[1] === 0x49 &&
|
||||||
|
buffer[2] === 0x46 &&
|
||||||
|
buffer[3] === 0x38
|
||||||
|
) {
|
||||||
|
fileType = 'image/gif';
|
||||||
|
} else if (
|
||||||
|
buffer[0] === 0x89 &&
|
||||||
|
buffer[1] === 0x50 &&
|
||||||
|
buffer[2] === 0x4e &&
|
||||||
|
buffer[3] === 0x47
|
||||||
|
) {
|
||||||
|
fileType = 'image/png';
|
||||||
|
} else if (
|
||||||
|
buffer[0] === 0xff &&
|
||||||
|
buffer[1] === 0xd8 &&
|
||||||
|
buffer[2] === 0xff &&
|
||||||
|
buffer[3] === 0xe0
|
||||||
|
) {
|
||||||
|
fileType = 'image/jpeg';
|
||||||
|
} else {
|
||||||
|
// unknown, fallback to png
|
||||||
|
console.error('unknown image type');
|
||||||
|
fileType = 'image/png';
|
||||||
|
}
|
||||||
|
const downloadUrl = URL.createObjectURL(
|
||||||
|
new Blob([arrayBuffer], { type: fileType })
|
||||||
|
);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
const event = new MouseEvent('click');
|
||||||
|
a.download = block.id;
|
||||||
|
a.href = downloadUrl;
|
||||||
|
a.dispatchEvent(event);
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(downloadUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
clearTimeout(actionbarTimeout);
|
||||||
|
setBIsActionBarVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
actionbarTimeout = setTimeout(() => {
|
||||||
|
setBIsActionBarVisible(false);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="image-preview-modal" className={imagePreviewModalStyle}>
|
<div data-testid="image-preview-modal" className={imagePreviewModalStyle}>
|
||||||
|
<div className={imageNavigationControlStyle}>
|
||||||
|
<span
|
||||||
|
className={imagePreviewModalGoStyle}
|
||||||
|
style={{
|
||||||
|
left: 0,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
assertExists(blockId);
|
||||||
|
previousImageHandler(blockId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
❮
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={imagePreviewModalGoStyle}
|
||||||
|
style={{
|
||||||
|
right: 0,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
assertExists(blockId);
|
||||||
|
nextImageHandler(blockId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
❯
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={imagePreviewModalContainerStyle}>
|
||||||
|
<div
|
||||||
|
className={clsx('zoom-area', { 'zoomed-bigger': isZoomedBigger })}
|
||||||
|
ref={zoomRef}
|
||||||
|
>
|
||||||
|
<div className={imagePreviewModalCenterStyle}>
|
||||||
|
<img
|
||||||
|
data-blob-id={props.blockId}
|
||||||
|
src={url}
|
||||||
|
alt={caption}
|
||||||
|
ref={imageRef}
|
||||||
|
draggable={isZoomedBigger}
|
||||||
|
onMouseDown={handleDragStart}
|
||||||
|
onMouseMove={handleDrag}
|
||||||
|
onMouseUp={handleDragEnd}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
width={'50%'}
|
||||||
|
/>
|
||||||
|
{isZoomedBigger ? null : (
|
||||||
|
<p className={imagePreviewModalCaptionStyle}>{caption}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.onClose();
|
props.onClose();
|
||||||
@@ -99,67 +351,122 @@ const ImagePreviewModalImpl = (
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span
|
{bIsActionBarVisible ? (
|
||||||
className={imagePreviewModalGoStyle}
|
<div
|
||||||
style={{
|
className={imageBottomContainerStyle}
|
||||||
left: 0,
|
onMouseEnter={handleMouseEnter}
|
||||||
}}
|
onMouseLeave={handleMouseLeave}
|
||||||
onClick={() => {
|
>
|
||||||
assertExists(blockId);
|
{isZoomedBigger && caption !== '' ? (
|
||||||
const workspace = props.workspace;
|
<p className={captionStyle}>{caption}</p>
|
||||||
|
) : null}
|
||||||
const page = workspace.getPage(props.pageId);
|
<div className={imagePreviewActionBarStyle}>
|
||||||
assertExists(page);
|
<div>
|
||||||
const block = page.getBlockById(blockId);
|
<Button
|
||||||
assertExists(block);
|
icon={<ArrowLeftSmallIcon />}
|
||||||
const prevBlock = page
|
noBorder={true}
|
||||||
.getPreviousSiblings(block)
|
className={buttonStyle}
|
||||||
.findLast(
|
onClick={() => {
|
||||||
(block): block is EmbedBlockModel =>
|
assertExists(blockId);
|
||||||
block.flavour === 'affine:embed'
|
previousImageHandler(blockId);
|
||||||
);
|
}}
|
||||||
if (prevBlock) {
|
/>
|
||||||
setBlockId(prevBlock.id);
|
<Button
|
||||||
}
|
icon={<ArrowRightSmallIcon />}
|
||||||
}}
|
noBorder={true}
|
||||||
>
|
className={buttonStyle}
|
||||||
❮
|
onClick={() => {
|
||||||
</span>
|
assertExists(blockId);
|
||||||
<div className={imagePreviewModalContainerStyle}>
|
nextImageHandler(blockId);
|
||||||
<img
|
}}
|
||||||
data-blob-id={props.blockId}
|
/>
|
||||||
alt={caption}
|
</div>
|
||||||
className={imagePreviewModalImageStyle}
|
<div className={groupStyle}>
|
||||||
ref={imageRef}
|
<Button
|
||||||
src={url}
|
icon={<ViewBarIcon />}
|
||||||
/>
|
noBorder={true}
|
||||||
</div>
|
className={buttonStyle}
|
||||||
<span
|
onClick={() => resetZoom()}
|
||||||
className={imagePreviewModalGoStyle}
|
/>
|
||||||
style={{
|
<Button
|
||||||
right: 0,
|
icon={<MinusIcon />}
|
||||||
}}
|
noBorder={true}
|
||||||
onClick={() => {
|
className={buttonStyle}
|
||||||
assertExists(blockId);
|
onClick={zoomOut}
|
||||||
const workspace = props.workspace;
|
/>
|
||||||
|
<span className={scaleIndicatorStyle}>{`${(
|
||||||
const page = workspace.getPage(props.pageId);
|
currentScale * 100
|
||||||
assertExists(page);
|
).toFixed(0)}%`}</span>
|
||||||
const block = page.getBlockById(blockId);
|
<Button
|
||||||
assertExists(block);
|
icon={<PlusIcon />}
|
||||||
const nextBlock = page
|
noBorder={true}
|
||||||
.getNextSiblings(block)
|
className={buttonStyle}
|
||||||
.find(
|
onClick={() => zoomIn()}
|
||||||
(block): block is EmbedBlockModel =>
|
/>
|
||||||
block.flavour === 'affine:embed'
|
</div>
|
||||||
);
|
<div className={groupStyle}>
|
||||||
if (nextBlock) {
|
<Button
|
||||||
setBlockId(nextBlock.id);
|
icon={<DownloadIcon />}
|
||||||
}
|
noBorder={true}
|
||||||
}}
|
className={buttonStyle}
|
||||||
>
|
onClick={() => {
|
||||||
❯
|
assertExists(blockId);
|
||||||
</span>
|
downloadHandler(blockId).catch(err => {
|
||||||
|
console.error('Could not download image', err);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<CopyIcon />}
|
||||||
|
noBorder={true}
|
||||||
|
className={buttonStyle}
|
||||||
|
onClick={() => {
|
||||||
|
if (!imageRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = imageRef.current.naturalWidth;
|
||||||
|
canvas.height = imageRef.current.naturalHeight;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
console.warn('Could not get canvas context');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.drawImage(imageRef.current, 0, 0);
|
||||||
|
canvas.toBlob(blob => {
|
||||||
|
if (!blob) {
|
||||||
|
console.warn('Could not get blob');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dataUrl = URL.createObjectURL(blob);
|
||||||
|
navigator.clipboard
|
||||||
|
.write([new ClipboardItem({ 'image/png': blob })])
|
||||||
|
.then(() => {
|
||||||
|
console.log('Image copied to clipboard');
|
||||||
|
URL.revokeObjectURL(dataUrl);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(
|
||||||
|
'Error copying image to clipboard',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
URL.revokeObjectURL(dataUrl);
|
||||||
|
});
|
||||||
|
}, 'image/png');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={groupStyle}>
|
||||||
|
<Button
|
||||||
|
icon={<DeleteIcon />}
|
||||||
|
noBorder={true}
|
||||||
|
className={buttonStyle}
|
||||||
|
onClick={() => blockId && deleteHandler(blockId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user