refactor: image preview component issues in center peek (#7313)

fix AF-948

- remove jotai
- every editor got its own image block modal
- image block uses center peek modal for animation and style
This commit is contained in:
pengx17
2024-06-25 07:34:18 +00:00
parent 76f6d91cdd
commit f51da066a8
9 changed files with 440 additions and 459 deletions

View File

@@ -1,47 +1,17 @@
import { baseTheme, cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { keyframes, style } from '@vanilla-extract/css'; import { 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({ export const imagePreviewModalStyle = style({
height: '100%', height: '100%',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}); });
export const loaded = style({ export const imagePreviewTrap = style({
opacity: 0, position: 'absolute',
animationName: fadeInAnimation, inset: 0,
animationDuration: '0.25s', width: '100%',
animationFillMode: 'forwards', height: '100%',
});
export const unloaded = style({
opacity: 1,
animationName: fadeOutAnimation,
animationDuration: '0.25s',
animationFillMode: 'forwards',
}); });
export const imagePreviewModalCloseButtonStyle = style({ export const imagePreviewModalCloseButtonStyle = style({
position: 'absolute', position: 'absolute',
@@ -96,6 +66,7 @@ export const imagePreviewModalCenterStyle = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
userSelect: 'none',
}); });
export const imagePreviewModalCaptionStyle = style({ export const imagePreviewModalCaptionStyle = style({
color: cssVar('white'), color: cssVar('white'),
@@ -155,7 +126,7 @@ export const imageBottomContainerStyle = style({
alignItems: 'center', alignItems: 'center',
position: 'fixed', position: 'fixed',
bottom: '28px', bottom: '28px',
zIndex: baseTheme.zIndexModal + 1, zIndex: 2,
}); });
export const captionStyle = style({ export const captionStyle = style({
maxWidth: '686px', maxWidth: '686px',

View File

@@ -1,24 +0,0 @@
import type { ImageBlockModel } from '@blocksuite/blocks';
import { atom } from 'jotai';
export const previewBlockIdAtom = atom<string | null>(null);
export const previewblocksAtom = atom<ImageBlockModel[]>([]);
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.dataset.blockId;
if (!blockId) return;
set(blockId);
}
}
};
window.addEventListener('dblclick', callback);
return () => {
window.removeEventListener('dblclick', callback);
};
};

View File

@@ -1,11 +1,14 @@
import { toast } from '@affine/component'; import { toast } from '@affine/component';
import { Button, IconButton } from '@affine/component/ui/button'; import { Button, IconButton } from '@affine/component/ui/button';
import { Tooltip } from '@affine/component/ui/tooltip'; import { Tooltip } from '@affine/component/ui/tooltip';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { PeekViewModalContainer } from '@affine/core/modules/peek-view/view/modal-container';
import type { ImageBlockModel } from '@blocksuite/blocks'; import type { ImageBlockModel } from '@blocksuite/blocks';
import { assertExists } from '@blocksuite/global/utils'; import { assertExists } from '@blocksuite/global/utils';
import { import {
ArrowLeftSmallIcon, ArrowLeftSmallIcon,
ArrowRightSmallIcon, ArrowRightSmallIcon,
CloseIcon,
CopyIcon, CopyIcon,
DeleteIcon, DeleteIcon,
DownloadIcon, DownloadIcon,
@@ -16,54 +19,158 @@ import {
import type { BlockModel, DocCollection } from '@blocksuite/store'; import type { BlockModel, DocCollection } from '@blocksuite/store';
import clsx from 'clsx'; import clsx from 'clsx';
import { useErrorBoundary } from 'foxact/use-error-boundary'; import { useErrorBoundary } from 'foxact/use-error-boundary';
import { useAtom } from 'jotai';
import type { PropsWithChildren, ReactElement } from 'react'; import type { PropsWithChildren, ReactElement } from 'react';
import { Suspense, useCallback, useEffect, useRef, useState } from 'react'; import {
import ReactDOM from 'react-dom'; Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { FallbackProps } from 'react-error-boundary'; import type { FallbackProps } from 'react-error-boundary';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import useSWR from 'swr'; import useSWR from 'swr';
import { useZoomControls } from './hooks/use-zoom'; import { useZoomControls } from './hooks/use-zoom';
import { import * as styles from './index.css';
captionStyle,
cursorStyle,
dividerStyle,
imageBottomContainerStyle,
imagePreviewActionBarStyle,
imagePreviewBackgroundStyle,
imagePreviewModalCaptionStyle,
imagePreviewModalCenterStyle,
imagePreviewModalCloseButtonStyle,
imagePreviewModalContainerStyle,
imagePreviewModalStyle,
loaded,
scaleIndicatorButtonStyle,
unloaded,
} from './index.css';
import {
hasAnimationPlayedAtom,
previewBlockIdAtom,
previewblocksAtom,
} from './index.jotai';
const filterImageBlock = (block: BlockModel): block is ImageBlockModel => { const filterImageBlock = (block: BlockModel): block is ImageBlockModel => {
return block.flavour === 'affine:image'; return block.flavour === 'affine:image';
}; };
function resolveMimeType(buffer: Uint8Array): string {
if (
buffer[0] === 0x47 &&
buffer[1] === 0x49 &&
buffer[2] === 0x46 &&
buffer[3] === 0x38
) {
return 'image/gif';
} else if (
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47
) {
return 'image/png';
} else if (
buffer[0] === 0xff &&
buffer[1] === 0xd8 &&
buffer[2] === 0xff &&
buffer[3] === 0xe0
) {
return 'image/jpeg';
} else {
// unknown, fallback to png
console.error('unknown image type');
return 'image/png';
}
}
async function imageUrlToBlob(url: string): Promise<Blob | undefined> {
const buffer = await fetch(url).then(response => {
return response.arrayBuffer();
});
if (!buffer) {
console.warn('Could not get blob');
return;
}
try {
const type = resolveMimeType(new Uint8Array(buffer));
const blob = new Blob([buffer], { type });
return blob;
} catch (error) {
console.error('Error converting image to blob', error);
}
return;
}
async function copyImageToClipboard(url: string) {
const blob = await imageUrlToBlob(url);
if (!blob) {
return;
}
try {
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
console.log('Image copied to clipboard');
toast('Copied to clipboard.');
} catch (error) {
console.error('Error copying image to clipboard', error);
}
}
async function saveBufferToFile(url: string, filename: string) {
// given input url may not have correct mime type
const blob = await imageUrlToBlob(url);
if (!blob) {
return;
}
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(blobUrl);
}
export type ImagePreviewModalProps = { export type ImagePreviewModalProps = {
docCollection: DocCollection; docCollection: DocCollection;
pageId: string; pageId: string;
host: HTMLElement;
};
const ButtonWithTooltip = ({
icon,
tooltip,
disabled,
...props
}: PropsWithChildren<{
disabled?: boolean;
icon?: ReactElement;
tooltip: string;
onClick: () => void;
className?: string;
}>) => {
const element = icon ? (
<IconButton icon={icon} type="plain" disabled={disabled} {...props} />
) : (
<Button disabled={disabled} type="plain" {...props} />
);
if (disabled) {
return element;
} else {
return <Tooltip content={tooltip}>{element}</Tooltip>;
}
}; };
const ImagePreviewModalImpl = ( const ImagePreviewModalImpl = (
props: ImagePreviewModalProps & { props: ImagePreviewModalProps & {
blockId: string; blockId: string;
onBlockIdChange: (blockId: string | null) => void;
onClose: () => void; onClose: () => void;
animating: boolean;
} }
): ReactElement | null => { ): ReactElement | null => {
const [blocks, setBlocks] = useAtom(previewblocksAtom); const page = useMemo(() => {
const [blockId, setBlockId] = useAtom(previewBlockIdAtom); return props.docCollection.getDoc(props.pageId);
}, [props.docCollection, props.pageId]);
const blockModel = useMemo(() => {
const block = page?.getBlock(props.blockId);
if (!block) {
return null;
}
return block.model as ImageBlockModel;
}, [page, props.blockId]);
const caption = useMemo(() => {
return blockModel?.caption ?? '';
}, [blockModel?.caption]);
const [blocks, setBlocks] = useState<ImageBlockModel[]>([]);
const [cursor, setCursor] = useState(0); const [cursor, setCursor] = useState(0);
const zoomRef = useRef<HTMLDivElement | null>(null); const zoomRef = useRef<HTMLDivElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null); const imageRef = useRef<HTMLImageElement | null>(null);
@@ -78,32 +185,9 @@ const ImagePreviewModalImpl = (
resetScale, resetScale,
currentScale, currentScale,
} = useZoomControls({ zoomRef, imageRef }); } = useZoomControls({ zoomRef, imageRef });
const [isOpen, setIsOpen] = useAtom(hasAnimationPlayedAtom);
const [hasPlayedAnimation, setHasPlayedAnimation] = useState<boolean>(false);
useEffect(() => {
let timeoutId: number;
if (!isOpen) {
timeoutId = window.setTimeout(() => {
props.onClose();
setIsOpen(true);
}, 300);
return () => {
clearTimeout(timeoutId);
};
}
return;
}, [isOpen, props, setIsOpen]);
const goto = useCallback( const goto = useCallback(
(index: number) => { (index: number) => {
if (!hasPlayedAnimation) {
setHasPlayedAnimation(true);
}
const workspace = props.docCollection; const workspace = props.docCollection;
const page = workspace.getDoc(props.pageId); const page = workspace.getDoc(props.pageId);
assertExists(page); assertExists(page);
@@ -113,18 +197,10 @@ const ImagePreviewModalImpl = (
if (!block) return; if (!block) return;
setCursor(index); setCursor(index);
setBlockId(block.id); props.onBlockIdChange(block.id);
resetZoom(); resetZoom();
}, },
[ [props, blocks, resetZoom]
props.pageId,
props.docCollection,
blocks,
setBlockId,
hasPlayedAnimation,
resetZoom,
]
); );
const deleteHandler = useCallback( const deleteHandler = useCallback(
@@ -137,19 +213,18 @@ const ImagePreviewModalImpl = (
let block = blocks[index]; let block = blocks[index];
if (!block) return; if (!block) return;
const newBlocks = blocks.toSpliced(index, 1);
blocks.splice(index, 1); setBlocks(newBlocks);
setBlocks([...blocks]);
page.deleteBlock(block); page.deleteBlock(block);
// next // next
block = blocks[index]; block = newBlocks[index];
// prev // prev
if (!block) { if (!block) {
index -= 1; index -= 1;
block = blocks[index]; block = newBlocks[index];
if (!block) { if (!block) {
onClose(); onClose();
@@ -159,93 +234,43 @@ const ImagePreviewModalImpl = (
setCursor(index); setCursor(index);
} }
setBlockId(block.id); props.onBlockIdChange(block.id);
resetZoom(); resetZoom();
}, },
[props, blocks, setBlockId, setBlocks, setCursor, resetZoom] [props, blocks, setBlocks, setCursor, resetZoom]
); );
const downloadHandler = useCallback( const downloadHandler = useAsyncCallback(async () => {
async (blockId: string | null) => { const url = imageRef.current?.src;
const workspace = props.docCollection; if (url) {
const page = workspace.getDoc(props.pageId); await saveBufferToFile(url, caption || blockModel?.id || 'image');
assertExists(page);
if (typeof blockId === 'string') {
const block = page.getBlockById<ImageBlockModel>(blockId);
assertExists(block);
const store = block.page.blobSync;
const url = store?.get(block.sourceId as string);
const img = await url;
if (!img) {
return;
} }
const arrayBuffer = await img.arrayBuffer(); }, [caption, blockModel?.id]);
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.append(a);
a.click();
a.remove();
}
},
[props.pageId, props.docCollection]
);
const [caption, setCaption] = useState(() => { const copyHandler = useAsyncCallback(async () => {
const page = props.docCollection.getDoc(props.pageId); const url = imageRef.current?.src;
assertExists(page); if (url) {
const block = page.getBlockById<ImageBlockModel>(props.blockId); await copyImageToClipboard(url);
assertExists(block); }
return block?.caption; }, []);
});
useEffect(() => { useEffect(() => {
const page = props.docCollection.getDoc(props.pageId); const page = props.docCollection.getDoc(props.pageId);
assertExists(page); assertExists(page);
const block = page.getBlockById<ImageBlockModel>(props.blockId); const block = page.getBlock(props.blockId);
assertExists(block); if (!block) {
return;
}
const blockModel = block.model as ImageBlockModel;
const prevs = page.getPrevs(block).filter(filterImageBlock); const prevs = page.getPrevs(blockModel).filter(filterImageBlock);
const nexts = page.getNexts(block).filter(filterImageBlock); const nexts = page.getNexts(blockModel).filter(filterImageBlock);
const blocks = [...prevs, block, ...nexts]; const blocks = [...prevs, blockModel, ...nexts];
setBlocks(blocks); setBlocks(blocks);
setCursor(blocks.length ? prevs.length : 0); setCursor(blocks.length ? prevs.length : 0);
setCaption(block?.caption);
}, [props.blockId, props.pageId, props.docCollection, setBlocks]); }, [props.blockId, props.pageId, props.docCollection, setBlocks]);
const { data, error } = useSWR( const { data, error } = useSWR(
@@ -254,14 +279,57 @@ const ImagePreviewModalImpl = (
fetcher: ([_, __, pageId, blockId]) => { fetcher: ([_, __, pageId, blockId]) => {
const page = props.docCollection.getDoc(pageId); const page = props.docCollection.getDoc(pageId);
assertExists(page); assertExists(page);
const block = page.getBlockById<ImageBlockModel>(blockId);
assertExists(block); const block = page.getBlock(blockId);
return props.docCollection.blobSync.get(block?.sourceId as string); if (!block) {
return null;
}
const blockModel = block.model as ImageBlockModel;
return props.docCollection.blobSync.get(blockModel.sourceId as string);
}, },
suspense: true, suspense: true,
} }
); );
useEffect(() => {
const handleKeyUp = (event: KeyboardEvent) => {
if (!page || !blockModel) {
return;
}
if (event.key === 'ArrowLeft') {
const prevBlock = page
.getPrevs(blockModel)
.findLast(
(block): block is ImageBlockModel =>
block.flavour === 'affine:image'
);
if (prevBlock) {
props.onBlockIdChange(prevBlock.id);
}
} else if (event.key === 'ArrowRight') {
const nextBlock = page
.getNexts(blockModel)
.find(
(block): block is ImageBlockModel =>
block.flavour === 'affine:image'
);
if (nextBlock) {
props.onBlockIdChange(nextBlock.id);
}
} else {
return;
}
event.preventDefault();
event.stopPropagation();
};
document.addEventListener('keyup', handleKeyUp);
return () => {
document.removeEventListener('keyup', handleKeyUp);
};
}, [blockModel, page, props]);
useErrorBoundary(error); useErrorBoundary(error);
const [prevData, setPrevData] = useState<string | null>(() => data); const [prevData, setPrevData] = useState<string | null>(() => data);
@@ -283,20 +351,14 @@ const ImagePreviewModalImpl = (
return null; return null;
} }
return ( return (
<div <div className={styles.imagePreviewModalStyle}>
className={imagePreviewModalStyle} <div className={styles.imagePreviewTrap} onClick={props.onClose} />
onClick={event => { <div className={styles.imagePreviewModalContainerStyle}>
if (event.target === event.currentTarget) {
setIsOpen(false);
}
}}
>
<div className={imagePreviewModalContainerStyle}>
<div <div
className={clsx('zoom-area', { 'zoomed-bigger': isZoomedBigger })} className={clsx('zoom-area', { 'zoomed-bigger': isZoomedBigger })}
ref={zoomRef} ref={zoomRef}
> >
<div className={imagePreviewModalCenterStyle}> <div className={styles.imagePreviewModalCenterStyle}>
<img <img
data-blob-id={props.blockId} data-blob-id={props.blockId}
data-testid="image-content" data-testid="image-content"
@@ -312,7 +374,7 @@ const ImagePreviewModalImpl = (
{isZoomedBigger ? null : ( {isZoomedBigger ? null : (
<p <p
data-testid="image-caption-zoomedout" data-testid="image-caption-zoomedout"
className={imagePreviewModalCaptionStyle} className={styles.imagePreviewModalCaptionStyle}
> >
{caption} {caption}
</p> </p>
@@ -321,135 +383,87 @@ const ImagePreviewModalImpl = (
</div> </div>
</div> </div>
<div <div className={styles.imageBottomContainerStyle}>
className={imageBottomContainerStyle}
onClick={event => event.stopPropagation()}
>
{isZoomedBigger && caption !== '' ? ( {isZoomedBigger && caption !== '' ? (
<p data-testid={'image-caption-zoomedin'} className={captionStyle}> <p
data-testid={'image-caption-zoomedin'}
className={styles.captionStyle}
>
{caption} {caption}
</p> </p>
) : null} ) : null}
<div className={imagePreviewActionBarStyle}> <div className={styles.imagePreviewActionBarStyle}>
<Tooltip content={'Previous'}> <ButtonWithTooltip
<IconButton
data-testid="previous-image-button" data-testid="previous-image-button"
tooltip="Previous"
icon={<ArrowLeftSmallIcon />} icon={<ArrowLeftSmallIcon />}
type="plain" disabled={props.animating || cursor < 1}
disabled={cursor < 1}
onClick={() => goto(cursor - 1)} onClick={() => goto(cursor - 1)}
/> />
</Tooltip> <div className={styles.cursorStyle}>
<div className={cursorStyle}>
{`${blocks.length ? cursor + 1 : 0}/${blocks.length}`} {`${blocks.length ? cursor + 1 : 0}/${blocks.length}`}
</div> </div>
<Tooltip content={'Next'}> <ButtonWithTooltip
<IconButton
data-testid="next-image-button" data-testid="next-image-button"
tooltip="Next"
icon={<ArrowRightSmallIcon />} icon={<ArrowRightSmallIcon />}
type="plain" disabled={props.animating || cursor + 1 === blocks.length}
disabled={cursor + 1 === blocks.length}
onClick={() => goto(cursor + 1)} onClick={() => goto(cursor + 1)}
/> />
</Tooltip> <div className={styles.dividerStyle}></div>
<div className={dividerStyle}></div> <ButtonWithTooltip
<Tooltip content={'Fit to screen'}>
<IconButton
data-testid="fit-to-screen-button" data-testid="fit-to-screen-button"
tooltip="Fit to screen"
icon={<ViewBarIcon />} icon={<ViewBarIcon />}
type="plain" disabled={props.animating}
onClick={() => resetZoom()} onClick={() => resetZoom()}
/> />
</Tooltip> <ButtonWithTooltip
<Tooltip content={'Zoom out'}>
<IconButton
data-testid="zoom-out-button" data-testid="zoom-out-button"
tooltip="Zoom out"
icon={<MinusIcon />} icon={<MinusIcon />}
type="plain" disabled={props.animating}
onClick={zoomOut} onClick={zoomOut}
/> />
</Tooltip> <ButtonWithTooltip
<Tooltip content={'Reset scale'}>
<Button
data-testid="reset-scale-button" data-testid="reset-scale-button"
type="plain" tooltip="Reset scale"
className={scaleIndicatorButtonStyle}
onClick={resetScale} onClick={resetScale}
disabled={props.animating}
> >
{`${(currentScale * 100).toFixed(0)}%`} {`${(currentScale * 100).toFixed(0)}%`}
</Button> </ButtonWithTooltip>
</Tooltip>
<Tooltip content={'Zoom in'}> <ButtonWithTooltip
<IconButton
data-testid="zoom-in-button" data-testid="zoom-in-button"
tooltip="Zoom in"
icon={<PlusIcon />} icon={<PlusIcon />}
type="plain" disabled={props.animating}
onClick={() => zoomIn()} onClick={zoomIn}
/> />
</Tooltip> <div className={styles.dividerStyle}></div>
<div className={dividerStyle}></div> <ButtonWithTooltip
<Tooltip content={'Download'}>
<IconButton
data-testid="download-button" data-testid="download-button"
tooltip="Download"
icon={<DownloadIcon />} icon={<DownloadIcon />}
type="plain" disabled={props.animating}
onClick={() => { onClick={downloadHandler}
assertExists(blockId);
downloadHandler(blockId).catch(err => {
console.error('Could not download image', err);
});
}}
/> />
</Tooltip> <ButtonWithTooltip
<Tooltip content={'Copy to clipboard'}>
<IconButton
data-testid="copy-to-clipboard-button" data-testid="copy-to-clipboard-button"
tooltip="Copy to clipboard"
icon={<CopyIcon />} icon={<CopyIcon />}
type="plain" disabled={props.animating}
onClick={() => { onClick={copyHandler}
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={styles.dividerStyle}></div>
<div className={dividerStyle}></div> <ButtonWithTooltip
<Tooltip content={'Delete'}>
<IconButton
data-testid="delete-button" data-testid="delete-button"
tooltip="Delete"
icon={<DeleteIcon />} icon={<DeleteIcon />}
type="plain" disabled={props.animating || blocks.length === 0}
disabled={blocks.length === 0}
onClick={() => deleteHandler(cursor)} onClick={() => deleteHandler(cursor)}
/> />
</Tooltip>
</div> </div>
</div> </div>
</div> </div>
@@ -472,110 +486,64 @@ export const ImagePreviewErrorBoundary = (
export const ImagePreviewModal = ( export const ImagePreviewModal = (
props: ImagePreviewModalProps props: ImagePreviewModalProps
): ReactElement | null => { ): ReactElement | null => {
const [blockId, setBlockId] = useAtom(previewBlockIdAtom); const [show, setShow] = useState(false);
const [isOpen, setIsOpen] = useAtom(hasAnimationPlayedAtom); const [blockId, setBlockId] = useState<string | null>(null);
const isOpen = show && !!blockId;
const handleKeyUp = useCallback( // todo: refactor this to use peek view service
(event: KeyboardEvent) => { useLayoutEffect(() => {
if (event.key === 'Escape') { const handleDblClick = (event: MouseEvent) => {
event.preventDefault(); const target = event.target as HTMLElement | null;
event.stopPropagation(); if (target?.tagName === 'IMG') {
if (isOpen) { const imageBlock = target.closest('affine-image');
setIsOpen(false); if (imageBlock) {
const blockId = imageBlock.dataset.blockId;
if (!blockId) return;
setBlockId(blockId);
setShow(true);
} }
return;
} }
if (!blockId) {
return;
}
const workspace = props.docCollection;
const page = workspace.getDoc(props.pageId);
assertExists(page);
const block = page.getBlockById(blockId);
assertExists(block);
if (event.key === 'ArrowLeft') {
const prevBlock = page
.getPrevs(block)
.findLast(
(block): block is ImageBlockModel =>
block.flavour === 'affine:image'
);
if (prevBlock) {
setBlockId(prevBlock.id);
}
} else if (event.key === 'ArrowRight') {
const nextBlock = page
.getNexts(block)
.find(
(block): block is ImageBlockModel =>
block.flavour === 'affine:image'
);
if (nextBlock) {
setBlockId(nextBlock.id);
}
} else {
return;
}
event.preventDefault();
event.stopPropagation();
},
[blockId, setBlockId, props.docCollection, props.pageId, isOpen, setIsOpen]
);
useEffect(() => {
document.addEventListener('keyup', handleKeyUp);
return () => {
document.removeEventListener('keyup', handleKeyUp);
}; };
}, [handleKeyUp]); props.host.addEventListener('dblclick', handleDblClick);
return () => {
props.host.removeEventListener('dblclick', handleDblClick);
};
}, [props.host]);
if (!blockId) { const [animating, setAnimating] = useState(false);
return null;
}
return ReactDOM.createPortal( return (
<ImagePreviewErrorBoundary> <PeekViewModalContainer
<div padding={false}
data-testid="image-preview-modal" onOpenChange={setShow}
className={`${imagePreviewBackgroundStyle} ${ open={isOpen}
isOpen ? loaded : unloaded animation="fade"
}`} onAnimationStart={() => setAnimating(true)}
onAnimateEnd={() => setAnimating(false)}
testId="image-preview-modal"
> >
<ImagePreviewErrorBoundary>
<Suspense> <Suspense>
{blockId ? (
<ImagePreviewModalImpl <ImagePreviewModalImpl
{...props} {...props}
animating={animating}
blockId={blockId} blockId={blockId}
onClose={() => setBlockId(null)} onBlockIdChange={setBlockId}
onClose={() => setShow(false)}
/> />
</Suspense> ) : null}
<button <button
data-testid="image-preview-close-button" data-testid="image-preview-close-button"
onClick={() => { onClick={() => {
setBlockId(null); setShow(false);
}} }}
className={imagePreviewModalCloseButtonStyle} className={styles.imagePreviewModalCloseButtonStyle}
> >
<svg <CloseIcon />
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> </button>
</div> </Suspense>
</ImagePreviewErrorBoundary>, </ImagePreviewErrorBoundary>
document.body </PeekViewModalContainer>
); );
}; };

View File

@@ -1,3 +1,4 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
export const editor = style({ export const editor = style({
@@ -7,3 +8,12 @@ export const editor = style({
}, },
minHeight: '100%', minHeight: '100%',
}); });
export const affineDocViewport = style({
display: 'flex',
flexDirection: 'column',
userSelect: 'none',
containerName: 'viewport',
containerType: 'inline-size',
background: cssVar('backgroundPrimaryColor'),
});

View File

@@ -3,6 +3,7 @@ import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import { AIProvider } from '@affine/core/blocksuite/presets/ai'; import { AIProvider } from '@affine/core/blocksuite/presets/ai';
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary'; import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
import { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor'; import { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor';
import { ImagePreviewModal } from '@affine/core/components/image-preview';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { PageNotFound } from '@affine/core/pages/404'; import { PageNotFound } from '@affine/core/pages/404';
import { Bound, type EdgelessRootService } from '@blocksuite/blocks'; import { Bound, type EdgelessRootService } from '@blocksuite/blocks';
@@ -10,6 +11,7 @@ import { DisposableGroup } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets'; import type { AffineEditorContainer } from '@blocksuite/presets';
import type { DocMode } from '@toeverything/infra'; import type { DocMode } from '@toeverything/infra';
import { DocsService, FrameworkScope, useService } from '@toeverything/infra'; import { DocsService, FrameworkScope, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { WorkbenchService } from '../../workbench'; import { WorkbenchService } from '../../workbench';
@@ -145,7 +147,9 @@ export function DocPeekPreview({
return ( return (
<AffineErrorBoundary> <AffineErrorBoundary>
<Scrollable.Root> <Scrollable.Root>
<Scrollable.Viewport className="affine-page-viewport"> <Scrollable.Viewport
className={clsx('affine-page-viewport', styles.affineDocViewport)}
>
<FrameworkScope scope={doc.scope}> <FrameworkScope scope={doc.scope}>
<BlockSuiteEditor <BlockSuiteEditor
ref={onRef} ref={onRef}
@@ -154,6 +158,13 @@ export function DocPeekPreview({
defaultSelectedBlockId={blockId} defaultSelectedBlockId={blockId}
page={doc.blockSuiteDoc} page={doc.blockSuiteDoc}
/> />
{editor?.host ? (
<ImagePreviewModal
pageId={doc.id}
docCollection={doc.blockSuiteDoc.collection}
host={editor.host}
/>
) : null}
</FrameworkScope> </FrameworkScope>
</Scrollable.Viewport> </Scrollable.Viewport>
<Scrollable.Scrollbar /> <Scrollable.Scrollbar />

View File

@@ -3,8 +3,9 @@ import { createVar, keyframes, style } from '@vanilla-extract/css';
export const animationTimeout = createVar(); export const animationTimeout = createVar();
export const transformOrigin = createVar(); export const transformOrigin = createVar();
export const animationType = createVar();
const contentShow = keyframes({ const zoomIn = keyframes({
from: { from: {
transform: 'scale(0.10)', transform: 'scale(0.10)',
}, },
@@ -12,7 +13,7 @@ const contentShow = keyframes({
transform: 'scale(1)', transform: 'scale(1)',
}, },
}); });
const contentHide = keyframes({ const zoomOut = keyframes({
to: { to: {
opacity: 0, opacity: 0,
transform: 'scale(0.10)', transform: 'scale(0.10)',
@@ -93,32 +94,57 @@ export const modalContentWrapper = style({
export const modalContentContainer = style({ export const modalContentContainer = style({
display: 'flex', display: 'flex',
alignItems: 'flex-start', alignItems: 'flex-start',
width: '90%', width: '100%',
height: '90%', height: '100%',
maxWidth: 1248,
willChange: 'transform, opacity', willChange: 'transform, opacity',
transformOrigin: transformOrigin, transformOrigin: transformOrigin,
selectors: { selectors: {
'&[data-state=entered], &[data-state=entering]': { '&[data-state=entered], &[data-state=entering]': {
animationFillMode: 'forwards', animationFillMode: 'forwards',
animationName: contentShow,
animationDuration: animationTimeout, animationDuration: animationTimeout,
animationTimingFunction: 'cubic-bezier(0.42, 0, 0.58, 1)', animationTimingFunction: 'cubic-bezier(0.42, 0, 0.58, 1)',
}, },
'&[data-state=exited], &[data-state=exiting]': { '&[data-state=exited], &[data-state=exiting]': {
animationFillMode: 'forwards', animationFillMode: 'forwards',
animationName: contentHide,
animationDuration: animationTimeout, animationDuration: animationTimeout,
animationTimingFunction: 'cubic-bezier(0.42, 0, 0.58, 1)', animationTimingFunction: 'cubic-bezier(0.42, 0, 0.58, 1)',
}, },
}, },
}); });
export const modalContentContainerWithZoom = style({
selectors: {
'&[data-state=entered], &[data-state=entering]': {
animationName: zoomIn,
},
'&[data-state=exited], &[data-state=exiting]': {
animationName: zoomOut,
},
},
});
export const modalContentContainerWithFade = style({
selectors: {
'&[data-state=entered], &[data-state=entering]': {
animationName: fadeIn,
},
'&[data-state=exited], &[data-state=exiting]': {
animationName: fadeOut,
},
},
});
export const containerPadding = style({
width: '90%',
height: '90%',
maxWidth: 1248,
});
export const modalContent = style({ export const modalContent = style({
flex: 1, flex: 1,
height: '100%', height: '100%',
backgroundColor: cssVar('backgroundOverlayPanelColor'), backgroundColor: cssVar('backgroundOverlayPanelColor'),
boxShadow: '0px 0px 0px 2.23px rgba(0, 0, 0, 0.08)', backdropFilter: 'drop-shadow(0px 0px 2px rgba(0, 0, 0, 0.08))',
borderRadius: '8px', borderRadius: '8px',
minHeight: 300, minHeight: 300,
// :focus-visible will set outline // :focus-visible will set outline

View File

@@ -1,6 +1,6 @@
import * as Dialog from '@radix-ui/react-dialog'; import * as Dialog from '@radix-ui/react-dialog';
import { cssVar } from '@toeverything/theme';
import { assignInlineVars } from '@vanilla-extract/dynamic'; import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { import {
createContext, createContext,
type PropsWithChildren, type PropsWithChildren,
@@ -24,7 +24,7 @@ const contentOptions: Dialog.DialogContentProps = {
}, },
style: { style: {
padding: 0, padding: 0,
backgroundColor: cssVar('backgroundPrimaryColor'), backgroundColor: 'transparent',
overflow: 'hidden', overflow: 'hidden',
}, },
}; };
@@ -64,14 +64,22 @@ export const PeekViewModalContainer = ({
controls, controls,
children, children,
hideOnEntering, hideOnEntering,
onAnimationStart,
onAnimateEnd, onAnimateEnd,
animation = 'zoom',
padding = true,
testId,
}: PropsWithChildren<{ }: PropsWithChildren<{
open: boolean; open: boolean;
hideOnEntering?: boolean; hideOnEntering?: boolean;
target?: HTMLElement; target?: HTMLElement;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
controls: React.ReactNode; controls?: React.ReactNode;
onAnimationStart?: () => void;
onAnimateEnd?: () => void; onAnimateEnd?: () => void;
padding?: boolean;
animation?: 'fade' | 'zoom';
testId?: string;
}>) => { }>) => {
const [{ status }, toggle] = useTransition({ const [{ status }, toggle] = useTransition({
timeout: animationTimeout, timeout: animationTimeout,
@@ -100,11 +108,11 @@ export const PeekViewModalContainer = ({
[styles.transformOrigin]: transformOrigin, [styles.transformOrigin]: transformOrigin,
[styles.animationTimeout]: `${animationTimeout}ms`, [styles.animationTimeout]: `${animationTimeout}ms`,
})} })}
onAnimationEnd={() => { onAnimationStart={onAnimationStart}
onAnimateEnd?.(); onAnimationEnd={onAnimateEnd}
}}
/> />
<div <div
data-testid={testId}
data-peek-view-wrapper data-peek-view-wrapper
className={styles.modalContentWrapper} className={styles.modalContentWrapper}
style={assignInlineVars({ style={assignInlineVars({
@@ -113,7 +121,13 @@ export const PeekViewModalContainer = ({
})} })}
> >
<div <div
className={styles.modalContentContainer} className={clsx(
styles.modalContentContainer,
padding && styles.containerPadding,
animation === 'fade'
? styles.modalContentContainerWithFade
: styles.modalContentContainerWithZoom
)}
data-testid="peek-view-modal-animation-container" data-testid="peek-view-modal-animation-container"
data-state={status} data-state={status}
> >
@@ -123,9 +137,11 @@ export const PeekViewModalContainer = ({
> >
{hideOnEntering && status === 'entering' ? null : children} {hideOnEntering && status === 'entering' ? null : children}
</Dialog.Content> </Dialog.Content>
{controls ? (
<div data-state={status} className={styles.modalControls}> <div data-state={status} className={styles.modalControls}>
{controls} {controls}
</div> </div>
) : null}
</div> </div>
</div> </div>
</Dialog.Portal> </Dialog.Portal>

View File

@@ -287,7 +287,13 @@ const DetailPageImpl = memo(function DetailPageImpl() {
} }
/> />
<ImagePreviewModal pageId={doc.id} docCollection={docCollection} /> {editor?.host ? (
<ImagePreviewModal
pageId={doc.id}
docCollection={docCollection}
host={editor.host}
/>
) : null}
<GlobalPageHistoryModal /> <GlobalPageHistoryModal />
<PageAIOnboarding /> <PageAIOnboarding />
</> </>

View File

@@ -399,12 +399,9 @@ test('image able to copy to clipboard', async ({ page }) => {
const locator = page.getByTestId('image-preview-modal'); const locator = page.getByTestId('image-preview-modal');
await expect(locator).toBeVisible(); await expect(locator).toBeVisible();
await locator.getByTestId('copy-to-clipboard-button').click(); await locator.getByTestId('copy-to-clipboard-button').click();
await new Promise<void>(resolve => { await expect(
page.on('console', message => { page.locator('[data-testid=affine-toast]:has-text("Copied to clipboard.")')
expect(message.text()).toBe('Image copied to clipboard'); ).toBeVisible();
resolve();
});
});
}); });
test('image able to download', async ({ page }) => { test('image able to download', async ({ page }) => {