mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
refactor: image preview plugin (#3457)
This commit is contained in:
20
plugins/image-preview/package.json
Normal file
20
plugins/image-preview/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@affine/image-preview-plugin",
|
||||
"version": "0.8.0-canary.0",
|
||||
"description": "Image preview plugin",
|
||||
"affinePlugin": {
|
||||
"release": true,
|
||||
"entry": {
|
||||
"core": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine/component": "workspace:*",
|
||||
"@blocksuite/icons": "^2.1.27",
|
||||
"@toeverything/plugin-infra": "workspace:*",
|
||||
"@toeverything/theme": "^0.7.9",
|
||||
"clsx": "^2.0.0",
|
||||
"react-error-boundary": "^4.0.10",
|
||||
"swr": "2.1.5"
|
||||
}
|
||||
}
|
||||
11
plugins/image-preview/src/app.tsx
Normal file
11
plugins/image-preview/src/app.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Page } from '@blocksuite/store';
|
||||
|
||||
import { ImagePreviewModal } from './component';
|
||||
|
||||
export type AppProps = {
|
||||
page: Page;
|
||||
};
|
||||
|
||||
export const App = ({ page }: AppProps) => {
|
||||
return <ImagePreviewModal pageId={page.id} workspace={page.workspace} />;
|
||||
};
|
||||
222
plugins/image-preview/src/component/hooks/use-zoom.tsx
Normal file
222
plugins/image-preview/src/component/hooks/use-zoom.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
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>(1);
|
||||
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 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]);
|
||||
|
||||
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;
|
||||
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,
|
||||
resetScale,
|
||||
isZoomedBigger,
|
||||
currentScale,
|
||||
handleDragStart,
|
||||
handleDrag,
|
||||
handleDragEnd,
|
||||
};
|
||||
};
|
||||
170
plugins/image-preview/src/component/index.css.ts
Normal file
170
plugins/image-preview/src/component/index.css.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
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({
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '36px',
|
||||
width: '36px',
|
||||
borderRadius: '10px',
|
||||
top: '0.5rem',
|
||||
right: '0.5rem',
|
||||
background: 'var(--affine-white)',
|
||||
border: 'none',
|
||||
padding: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--affine-icon-color)',
|
||||
transition: 'background 0.2s ease-in-out',
|
||||
zIndex: 1,
|
||||
marginTop: '38px',
|
||||
marginRight: '38px',
|
||||
});
|
||||
|
||||
export const imagePreviewModalGoStyle = style({
|
||||
color: 'var(--affine-white)',
|
||||
position: 'absolute',
|
||||
fontSize: '60px',
|
||||
lineHeight: '60px',
|
||||
fontWeight: 'bold',
|
||||
opacity: '0.2',
|
||||
padding: '0 15px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
export const imageNavigationControlStyle = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
zIndex: 2,
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const imagePreviewModalContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
zIndex: 1,
|
||||
'@media': {
|
||||
'screen and (max-width: 768px)': {
|
||||
alignItems: 'center',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const imagePreviewModalCenterStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const imagePreviewModalCaptionStyle = style({
|
||||
color: 'var(--affine-white)',
|
||||
marginTop: '24px',
|
||||
'@media': {
|
||||
'screen and (max-width: 768px)': {
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const imagePreviewActionBarStyle = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
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({
|
||||
padding: '10px 0',
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderLeft: '1px solid #E3E2E4',
|
||||
});
|
||||
|
||||
export const buttonStyle = style({
|
||||
margin: '10px 6px',
|
||||
});
|
||||
|
||||
export const scaleIndicatorButtonStyle = style({
|
||||
minHeight: '100%',
|
||||
maxWidth: 'max-content',
|
||||
fontSize: '12px',
|
||||
padding: '5px 5px',
|
||||
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
|
||||
export const imageBottomContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
position: 'fixed',
|
||||
bottom: '28px',
|
||||
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',
|
||||
});
|
||||
|
||||
export const suspenseFallbackStyle = style({
|
||||
opacity: 0,
|
||||
transition: 'opacity 2s ease-in-out',
|
||||
});
|
||||
22
plugins/image-preview/src/component/index.jotai.ts
Normal file
22
plugins/image-preview/src/component/index.jotai.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export const previewBlockIdAtom = atom<string | null>(null);
|
||||
export const hasAnimationPlayedAtom = atom<boolean | null>(true);
|
||||
|
||||
previewBlockIdAtom.onMount = set => {
|
||||
const callback = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.tagName === 'IMG') {
|
||||
const imageBlock = target.closest('affine-image');
|
||||
if (imageBlock) {
|
||||
const blockId = imageBlock.getAttribute('data-block-id');
|
||||
if (!blockId) return;
|
||||
set(blockId);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('dblclick', callback);
|
||||
return () => {
|
||||
window.removeEventListener('dblclick', callback);
|
||||
};
|
||||
};
|
||||
600
plugins/image-preview/src/component/index.tsx
Normal file
600
plugins/image-preview/src/component/index.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
import { Button, IconButton, Tooltip } from '@affine/component';
|
||||
import type { ImageBlockModel } from '@blocksuite/blocks';
|
||||
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 clsx from 'clsx';
|
||||
import { useErrorBoundary } from 'foxact/use-error-boundary';
|
||||
import { useAtom } from 'jotai';
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { Suspense, useCallback } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { FallbackProps } from 'react-error-boundary';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { useZoomControls } from './hooks/use-zoom';
|
||||
import {
|
||||
buttonStyle,
|
||||
captionStyle,
|
||||
groupStyle,
|
||||
imageBottomContainerStyle,
|
||||
imagePreviewActionBarStyle,
|
||||
imagePreviewBackgroundStyle,
|
||||
imagePreviewModalCaptionStyle,
|
||||
imagePreviewModalCenterStyle,
|
||||
imagePreviewModalCloseButtonStyle,
|
||||
imagePreviewModalContainerStyle,
|
||||
imagePreviewModalStyle,
|
||||
loaded,
|
||||
scaleIndicatorButtonStyle,
|
||||
unloaded,
|
||||
} from './index.css';
|
||||
import { hasAnimationPlayedAtom, previewBlockIdAtom } from './index.jotai';
|
||||
import { toast } from './toast';
|
||||
|
||||
export type ImagePreviewModalProps = {
|
||||
workspace: Workspace;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
const ImagePreviewModalImpl = (
|
||||
props: ImagePreviewModalProps & {
|
||||
blockId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
): 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);
|
||||
|
||||
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:image'
|
||||
);
|
||||
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:image'
|
||||
);
|
||||
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: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) {
|
||||
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);
|
||||
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, error } = useSWR(
|
||||
['workspace', 'image', 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,
|
||||
}
|
||||
);
|
||||
|
||||
useErrorBoundary(error);
|
||||
|
||||
const [prevData, setPrevData] = useState<string | null>(() => data);
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
if (data === null) {
|
||||
return null;
|
||||
} else if (prevData !== data) {
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
setUrl(URL.createObjectURL(data));
|
||||
|
||||
setPrevData(data);
|
||||
} else if (!url) {
|
||||
setUrl(URL.createObjectURL(data));
|
||||
}
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={imagePreviewModalStyle}
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={imagePreviewModalContainerStyle}>
|
||||
<div
|
||||
className={clsx('zoom-area', { 'zoomed-bigger': isZoomedBigger })}
|
||||
ref={zoomRef}
|
||||
>
|
||||
<div className={imagePreviewModalCenterStyle}>
|
||||
<img
|
||||
data-blob-id={props.blockId}
|
||||
data-testid="image-content"
|
||||
src={url}
|
||||
alt={caption}
|
||||
ref={imageRef}
|
||||
draggable={isZoomedBigger}
|
||||
onMouseDown={handleDragStart}
|
||||
onMouseMove={handleDrag}
|
||||
onMouseUp={handleDragEnd}
|
||||
onLoad={resetZoom}
|
||||
/>
|
||||
{isZoomedBigger ? null : (
|
||||
<p
|
||||
data-testid="image-caption-zoomedout"
|
||||
className={imagePreviewModalCaptionStyle}
|
||||
>
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={imageBottomContainerStyle}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
{isZoomedBigger && caption !== '' ? (
|
||||
<p data-testid={'image-caption-zoomedin'} className={captionStyle}>
|
||||
{caption}
|
||||
</p>
|
||||
) : null}
|
||||
<div className={imagePreviewActionBarStyle}>
|
||||
<div>
|
||||
<Tooltip content={'Previous'} disablePortal={false}>
|
||||
<IconButton
|
||||
data-testid="previous-image-button"
|
||||
icon={<ArrowLeftSmallIcon />}
|
||||
type="plain"
|
||||
className={buttonStyle}
|
||||
onClick={() => {
|
||||
assertExists(blockId);
|
||||
previousImageHandler(blockId);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={'Next'} disablePortal={false}>
|
||||
<IconButton
|
||||
data-testid="next-image-button"
|
||||
icon={<ArrowRightSmallIcon />}
|
||||
className={buttonStyle}
|
||||
type="plain"
|
||||
onClick={() => {
|
||||
assertExists(blockId);
|
||||
nextImageHandler(blockId);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={groupStyle}></div>
|
||||
<Tooltip
|
||||
content={'Fit to Screen'}
|
||||
disablePortal={true}
|
||||
showArrow={false}
|
||||
>
|
||||
<IconButton
|
||||
data-testid="fit-to-screen-button"
|
||||
icon={<ViewBarIcon />}
|
||||
type="plain"
|
||||
className={buttonStyle}
|
||||
onClick={() => resetZoom()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={'Zoom out'} disablePortal={false}>
|
||||
<IconButton
|
||||
data-testid="zoom-out-button"
|
||||
icon={<MinusIcon />}
|
||||
className={buttonStyle}
|
||||
type="plain"
|
||||
onClick={zoomOut}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={'Reset Scale'} disablePortal={false}>
|
||||
<Button
|
||||
data-testid="reset-scale-button"
|
||||
type="plain"
|
||||
size={'large'}
|
||||
className={scaleIndicatorButtonStyle}
|
||||
onClick={resetScale}
|
||||
>
|
||||
{`${(currentScale * 100).toFixed(0)}%`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={'Zoom in'} disablePortal={false}>
|
||||
<IconButton
|
||||
data-testid="zoom-in-button"
|
||||
icon={<PlusIcon />}
|
||||
className={buttonStyle}
|
||||
type="plain"
|
||||
onClick={() => zoomIn()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div className={groupStyle}></div>
|
||||
<Tooltip content={'Download'} disablePortal={false}>
|
||||
<IconButton
|
||||
data-testid="download-button"
|
||||
icon={<DownloadIcon />}
|
||||
type="plain"
|
||||
className={buttonStyle}
|
||||
onClick={() => {
|
||||
assertExists(blockId);
|
||||
downloadHandler(blockId).catch(err => {
|
||||
console.error('Could not download image', err);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={'Copy to clipboard'} disablePortal={false}>
|
||||
<IconButton
|
||||
data-testid="copy-to-clipboard-button"
|
||||
icon={<CopyIcon />}
|
||||
type="plain"
|
||||
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');
|
||||
toast('Copied to clipboard.');
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div className={groupStyle}></div>
|
||||
<Tooltip content={'Delete'} disablePortal={false}>
|
||||
<IconButton
|
||||
data-testid="delete-button"
|
||||
icon={<DeleteIcon />}
|
||||
type="plain"
|
||||
className={buttonStyle}
|
||||
onClick={() => blockId && deleteHandler(blockId)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorLogger = (props: FallbackProps) => {
|
||||
useEffect(() => {
|
||||
console.error('image preview modal error', props.error);
|
||||
}, [props.error]);
|
||||
return null;
|
||||
};
|
||||
|
||||
export const ImagePreviewErrorBoundary = (
|
||||
props: PropsWithChildren
|
||||
): ReactElement => {
|
||||
return (
|
||||
<ErrorBoundary fallbackRender={ErrorLogger}>{props.children}</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
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();
|
||||
if (isOpen) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!blockId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = props.workspace;
|
||||
|
||||
const page = workspace.getPage(props.pageId);
|
||||
assertExists(page);
|
||||
const block = page.getBlockById(blockId);
|
||||
assertExists(block);
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
const prevBlock = page
|
||||
.getPreviousSiblings(block)
|
||||
.findLast(
|
||||
(block): block is ImageBlockModel =>
|
||||
block.flavour === 'affine:image'
|
||||
);
|
||||
if (prevBlock) {
|
||||
setBlockId(prevBlock.id);
|
||||
}
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
const nextBlock = page
|
||||
.getNextSiblings(block)
|
||||
.find(
|
||||
(block): block is ImageBlockModel =>
|
||||
block.flavour === 'affine:image'
|
||||
);
|
||||
if (nextBlock) {
|
||||
setBlockId(nextBlock.id);
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
[blockId, setBlockId, props.workspace, props.pageId, isOpen, setIsOpen]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keyup', handleKeyUp);
|
||||
return () => {
|
||||
document.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, [handleKeyUp]);
|
||||
|
||||
if (!blockId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ImagePreviewErrorBoundary>
|
||||
<div
|
||||
data-testid="image-preview-modal"
|
||||
className={`${imagePreviewBackgroundStyle} ${
|
||||
isOpen ? loaded : unloaded
|
||||
}`}
|
||||
>
|
||||
<Suspense>
|
||||
<ImagePreviewModalImpl
|
||||
{...props}
|
||||
blockId={blockId}
|
||||
onClose={() => setBlockId(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
<button
|
||||
data-testid="image-preview-close-button"
|
||||
onClick={() => {
|
||||
setBlockId(null);
|
||||
}}
|
||||
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>
|
||||
</ImagePreviewErrorBoundary>
|
||||
);
|
||||
};
|
||||
21
plugins/image-preview/src/component/toast.ts
Normal file
21
plugins/image-preview/src/component/toast.ts
Normal 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;
|
||||
}>;
|
||||
}
|
||||
}
|
||||
18
plugins/image-preview/src/index.ts
Normal file
18
plugins/image-preview/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { PluginContext } from '@toeverything/plugin-infra/entry';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { App } from './app';
|
||||
|
||||
export const entry = (context: PluginContext) => {
|
||||
context.register('editor', (div, editor) => {
|
||||
const root = createRoot(div);
|
||||
root.render(createElement(App, { page: editor.page }));
|
||||
return () => {
|
||||
root.unmount();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
// do nothing
|
||||
};
|
||||
};
|
||||
17
plugins/image-preview/tsconfig.json
Normal file
17
plugins/image-preview/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["./src"],
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "lib",
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/plugin-infra"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/component"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user