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 { 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)',
});
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
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 imagePreviewTrap = style({
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
});
export const imagePreviewModalCloseButtonStyle = style({
position: 'absolute',
@@ -96,6 +66,7 @@ export const imagePreviewModalCenterStyle = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
userSelect: 'none',
});
export const imagePreviewModalCaptionStyle = style({
color: cssVar('white'),
@@ -155,7 +126,7 @@ export const imageBottomContainerStyle = style({
alignItems: 'center',
position: 'fixed',
bottom: '28px',
zIndex: baseTheme.zIndexModal + 1,
zIndex: 2,
});
export const captionStyle = style({
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 { Button, IconButton } from '@affine/component/ui/button';
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 { assertExists } from '@blocksuite/global/utils';
import {
ArrowLeftSmallIcon,
ArrowRightSmallIcon,
CloseIcon,
CopyIcon,
DeleteIcon,
DownloadIcon,
@@ -16,54 +19,158 @@ import {
import type { BlockModel, DocCollection } 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, useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import {
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
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 {
captionStyle,
cursorStyle,
dividerStyle,
imageBottomContainerStyle,
imagePreviewActionBarStyle,
imagePreviewBackgroundStyle,
imagePreviewModalCaptionStyle,
imagePreviewModalCenterStyle,
imagePreviewModalCloseButtonStyle,
imagePreviewModalContainerStyle,
imagePreviewModalStyle,
loaded,
scaleIndicatorButtonStyle,
unloaded,
} from './index.css';
import {
hasAnimationPlayedAtom,
previewBlockIdAtom,
previewblocksAtom,
} from './index.jotai';
import * as styles from './index.css';
const filterImageBlock = (block: BlockModel): block is ImageBlockModel => {
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 = {
docCollection: DocCollection;
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 = (
props: ImagePreviewModalProps & {
blockId: string;
onBlockIdChange: (blockId: string | null) => void;
onClose: () => void;
animating: boolean;
}
): ReactElement | null => {
const [blocks, setBlocks] = useAtom(previewblocksAtom);
const [blockId, setBlockId] = useAtom(previewBlockIdAtom);
const page = useMemo(() => {
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 zoomRef = useRef<HTMLDivElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
@@ -78,32 +185,9 @@ const ImagePreviewModalImpl = (
resetScale,
currentScale,
} = 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(
(index: number) => {
if (!hasPlayedAnimation) {
setHasPlayedAnimation(true);
}
const workspace = props.docCollection;
const page = workspace.getDoc(props.pageId);
assertExists(page);
@@ -113,18 +197,10 @@ const ImagePreviewModalImpl = (
if (!block) return;
setCursor(index);
setBlockId(block.id);
props.onBlockIdChange(block.id);
resetZoom();
},
[
props.pageId,
props.docCollection,
blocks,
setBlockId,
hasPlayedAnimation,
resetZoom,
]
[props, blocks, resetZoom]
);
const deleteHandler = useCallback(
@@ -137,19 +213,18 @@ const ImagePreviewModalImpl = (
let block = blocks[index];
if (!block) return;
blocks.splice(index, 1);
setBlocks([...blocks]);
const newBlocks = blocks.toSpliced(index, 1);
setBlocks(newBlocks);
page.deleteBlock(block);
// next
block = blocks[index];
block = newBlocks[index];
// prev
if (!block) {
index -= 1;
block = blocks[index];
block = newBlocks[index];
if (!block) {
onClose();
@@ -159,93 +234,43 @@ const ImagePreviewModalImpl = (
setCursor(index);
}
setBlockId(block.id);
props.onBlockIdChange(block.id);
resetZoom();
},
[props, blocks, setBlockId, setBlocks, setCursor, resetZoom]
[props, blocks, setBlocks, setCursor, resetZoom]
);
const downloadHandler = useCallback(
async (blockId: string | null) => {
const workspace = props.docCollection;
const page = workspace.getDoc(props.pageId);
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();
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 downloadHandler = useAsyncCallback(async () => {
const url = imageRef.current?.src;
if (url) {
await saveBufferToFile(url, caption || blockModel?.id || 'image');
}
}, [caption, blockModel?.id]);
const [caption, setCaption] = useState(() => {
const page = props.docCollection.getDoc(props.pageId);
assertExists(page);
const block = page.getBlockById<ImageBlockModel>(props.blockId);
assertExists(block);
return block?.caption;
});
const copyHandler = useAsyncCallback(async () => {
const url = imageRef.current?.src;
if (url) {
await copyImageToClipboard(url);
}
}, []);
useEffect(() => {
const page = props.docCollection.getDoc(props.pageId);
assertExists(page);
const block = page.getBlockById<ImageBlockModel>(props.blockId);
assertExists(block);
const block = page.getBlock(props.blockId);
if (!block) {
return;
}
const blockModel = block.model as ImageBlockModel;
const prevs = page.getPrevs(block).filter(filterImageBlock);
const nexts = page.getNexts(block).filter(filterImageBlock);
const prevs = page.getPrevs(blockModel).filter(filterImageBlock);
const nexts = page.getNexts(blockModel).filter(filterImageBlock);
const blocks = [...prevs, block, ...nexts];
const blocks = [...prevs, blockModel, ...nexts];
setBlocks(blocks);
setCursor(blocks.length ? prevs.length : 0);
setCaption(block?.caption);
}, [props.blockId, props.pageId, props.docCollection, setBlocks]);
const { data, error } = useSWR(
@@ -254,14 +279,57 @@ const ImagePreviewModalImpl = (
fetcher: ([_, __, pageId, blockId]) => {
const page = props.docCollection.getDoc(pageId);
assertExists(page);
const block = page.getBlockById<ImageBlockModel>(blockId);
assertExists(block);
return props.docCollection.blobSync.get(block?.sourceId as string);
const block = page.getBlock(blockId);
if (!block) {
return null;
}
const blockModel = block.model as ImageBlockModel;
return props.docCollection.blobSync.get(blockModel.sourceId as string);
},
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);
const [prevData, setPrevData] = useState<string | null>(() => data);
@@ -283,20 +351,14 @@ const ImagePreviewModalImpl = (
return null;
}
return (
<div
className={imagePreviewModalStyle}
onClick={event => {
if (event.target === event.currentTarget) {
setIsOpen(false);
}
}}
>
<div className={imagePreviewModalContainerStyle}>
<div className={styles.imagePreviewModalStyle}>
<div className={styles.imagePreviewTrap} onClick={props.onClose} />
<div className={styles.imagePreviewModalContainerStyle}>
<div
className={clsx('zoom-area', { 'zoomed-bigger': isZoomedBigger })}
ref={zoomRef}
>
<div className={imagePreviewModalCenterStyle}>
<div className={styles.imagePreviewModalCenterStyle}>
<img
data-blob-id={props.blockId}
data-testid="image-content"
@@ -312,7 +374,7 @@ const ImagePreviewModalImpl = (
{isZoomedBigger ? null : (
<p
data-testid="image-caption-zoomedout"
className={imagePreviewModalCaptionStyle}
className={styles.imagePreviewModalCaptionStyle}
>
{caption}
</p>
@@ -321,135 +383,87 @@ const ImagePreviewModalImpl = (
</div>
</div>
<div
className={imageBottomContainerStyle}
onClick={event => event.stopPropagation()}
>
<div className={styles.imageBottomContainerStyle}>
{isZoomedBigger && caption !== '' ? (
<p data-testid={'image-caption-zoomedin'} className={captionStyle}>
<p
data-testid={'image-caption-zoomedin'}
className={styles.captionStyle}
>
{caption}
</p>
) : null}
<div className={imagePreviewActionBarStyle}>
<Tooltip content={'Previous'}>
<IconButton
data-testid="previous-image-button"
icon={<ArrowLeftSmallIcon />}
type="plain"
disabled={cursor < 1}
onClick={() => goto(cursor - 1)}
/>
</Tooltip>
<div className={cursorStyle}>
<div className={styles.imagePreviewActionBarStyle}>
<ButtonWithTooltip
data-testid="previous-image-button"
tooltip="Previous"
icon={<ArrowLeftSmallIcon />}
disabled={props.animating || cursor < 1}
onClick={() => goto(cursor - 1)}
/>
<div className={styles.cursorStyle}>
{`${blocks.length ? cursor + 1 : 0}/${blocks.length}`}
</div>
<Tooltip content={'Next'}>
<IconButton
data-testid="next-image-button"
icon={<ArrowRightSmallIcon />}
type="plain"
disabled={cursor + 1 === blocks.length}
onClick={() => goto(cursor + 1)}
/>
</Tooltip>
<div className={dividerStyle}></div>
<Tooltip content={'Fit to screen'}>
<IconButton
data-testid="fit-to-screen-button"
icon={<ViewBarIcon />}
type="plain"
onClick={() => resetZoom()}
/>
</Tooltip>
<Tooltip content={'Zoom out'}>
<IconButton
data-testid="zoom-out-button"
icon={<MinusIcon />}
type="plain"
onClick={zoomOut}
/>
</Tooltip>
<Tooltip content={'Reset scale'}>
<Button
data-testid="reset-scale-button"
type="plain"
className={scaleIndicatorButtonStyle}
onClick={resetScale}
>
{`${(currentScale * 100).toFixed(0)}%`}
</Button>
</Tooltip>
<Tooltip content={'Zoom in'}>
<IconButton
data-testid="zoom-in-button"
icon={<PlusIcon />}
type="plain"
onClick={() => zoomIn()}
/>
</Tooltip>
<div className={dividerStyle}></div>
<Tooltip content={'Download'}>
<IconButton
data-testid="download-button"
icon={<DownloadIcon />}
type="plain"
onClick={() => {
assertExists(blockId);
downloadHandler(blockId).catch(err => {
console.error('Could not download image', err);
});
}}
/>
</Tooltip>
<Tooltip content={'Copy to clipboard'}>
<IconButton
data-testid="copy-to-clipboard-button"
icon={<CopyIcon />}
type="plain"
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={dividerStyle}></div>
<Tooltip content={'Delete'}>
<IconButton
data-testid="delete-button"
icon={<DeleteIcon />}
type="plain"
disabled={blocks.length === 0}
onClick={() => deleteHandler(cursor)}
/>
</Tooltip>
<ButtonWithTooltip
data-testid="next-image-button"
tooltip="Next"
icon={<ArrowRightSmallIcon />}
disabled={props.animating || cursor + 1 === blocks.length}
onClick={() => goto(cursor + 1)}
/>
<div className={styles.dividerStyle}></div>
<ButtonWithTooltip
data-testid="fit-to-screen-button"
tooltip="Fit to screen"
icon={<ViewBarIcon />}
disabled={props.animating}
onClick={() => resetZoom()}
/>
<ButtonWithTooltip
data-testid="zoom-out-button"
tooltip="Zoom out"
icon={<MinusIcon />}
disabled={props.animating}
onClick={zoomOut}
/>
<ButtonWithTooltip
data-testid="reset-scale-button"
tooltip="Reset scale"
onClick={resetScale}
disabled={props.animating}
>
{`${(currentScale * 100).toFixed(0)}%`}
</ButtonWithTooltip>
<ButtonWithTooltip
data-testid="zoom-in-button"
tooltip="Zoom in"
icon={<PlusIcon />}
disabled={props.animating}
onClick={zoomIn}
/>
<div className={styles.dividerStyle}></div>
<ButtonWithTooltip
data-testid="download-button"
tooltip="Download"
icon={<DownloadIcon />}
disabled={props.animating}
onClick={downloadHandler}
/>
<ButtonWithTooltip
data-testid="copy-to-clipboard-button"
tooltip="Copy to clipboard"
icon={<CopyIcon />}
disabled={props.animating}
onClick={copyHandler}
/>
<div className={styles.dividerStyle}></div>
<ButtonWithTooltip
data-testid="delete-button"
tooltip="Delete"
icon={<DeleteIcon />}
disabled={props.animating || blocks.length === 0}
onClick={() => deleteHandler(cursor)}
/>
</div>
</div>
</div>
@@ -472,110 +486,64 @@ export const ImagePreviewErrorBoundary = (
export const ImagePreviewModal = (
props: ImagePreviewModalProps
): ReactElement | null => {
const [blockId, setBlockId] = useAtom(previewBlockIdAtom);
const [isOpen, setIsOpen] = useAtom(hasAnimationPlayedAtom);
const [show, setShow] = useState(false);
const [blockId, setBlockId] = useState<string | null>(null);
const isOpen = show && !!blockId;
const handleKeyUp = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
if (isOpen) {
setIsOpen(false);
// todo: refactor this to use peek view service
useLayoutEffect(() => {
const handleDblClick = (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;
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) {
return null;
}
const [animating, setAnimating] = useState(false);
return ReactDOM.createPortal(
<ImagePreviewErrorBoundary>
<div
data-testid="image-preview-modal"
className={`${imagePreviewBackgroundStyle} ${
isOpen ? loaded : unloaded
}`}
>
return (
<PeekViewModalContainer
padding={false}
onOpenChange={setShow}
open={isOpen}
animation="fade"
onAnimationStart={() => setAnimating(true)}
onAnimateEnd={() => setAnimating(false)}
testId="image-preview-modal"
>
<ImagePreviewErrorBoundary>
<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"
{blockId ? (
<ImagePreviewModalImpl
{...props}
animating={animating}
blockId={blockId}
onBlockIdChange={setBlockId}
onClose={() => setShow(false)}
/>
</svg>
</button>
</div>
</ImagePreviewErrorBoundary>,
document.body
) : null}
<button
data-testid="image-preview-close-button"
onClick={() => {
setShow(false);
}}
className={styles.imagePreviewModalCloseButtonStyle}
>
<CloseIcon />
</button>
</Suspense>
</ImagePreviewErrorBoundary>
</PeekViewModalContainer>
);
};

View File

@@ -1,3 +1,4 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const editor = style({
@@ -7,3 +8,12 @@ export const editor = style({
},
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 { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
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 { PageNotFound } from '@affine/core/pages/404';
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 { DocMode } from '@toeverything/infra';
import { DocsService, FrameworkScope, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { WorkbenchService } from '../../workbench';
@@ -145,7 +147,9 @@ export function DocPeekPreview({
return (
<AffineErrorBoundary>
<Scrollable.Root>
<Scrollable.Viewport className="affine-page-viewport">
<Scrollable.Viewport
className={clsx('affine-page-viewport', styles.affineDocViewport)}
>
<FrameworkScope scope={doc.scope}>
<BlockSuiteEditor
ref={onRef}
@@ -154,6 +158,13 @@ export function DocPeekPreview({
defaultSelectedBlockId={blockId}
page={doc.blockSuiteDoc}
/>
{editor?.host ? (
<ImagePreviewModal
pageId={doc.id}
docCollection={doc.blockSuiteDoc.collection}
host={editor.host}
/>
) : null}
</FrameworkScope>
</Scrollable.Viewport>
<Scrollable.Scrollbar />

View File

@@ -3,8 +3,9 @@ import { createVar, keyframes, style } from '@vanilla-extract/css';
export const animationTimeout = createVar();
export const transformOrigin = createVar();
export const animationType = createVar();
const contentShow = keyframes({
const zoomIn = keyframes({
from: {
transform: 'scale(0.10)',
},
@@ -12,7 +13,7 @@ const contentShow = keyframes({
transform: 'scale(1)',
},
});
const contentHide = keyframes({
const zoomOut = keyframes({
to: {
opacity: 0,
transform: 'scale(0.10)',
@@ -93,32 +94,57 @@ export const modalContentWrapper = style({
export const modalContentContainer = style({
display: 'flex',
alignItems: 'flex-start',
width: '90%',
height: '90%',
maxWidth: 1248,
width: '100%',
height: '100%',
willChange: 'transform, opacity',
transformOrigin: transformOrigin,
selectors: {
'&[data-state=entered], &[data-state=entering]': {
animationFillMode: 'forwards',
animationName: contentShow,
animationDuration: animationTimeout,
animationTimingFunction: 'cubic-bezier(0.42, 0, 0.58, 1)',
},
'&[data-state=exited], &[data-state=exiting]': {
animationFillMode: 'forwards',
animationName: contentHide,
animationDuration: animationTimeout,
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({
flex: 1,
height: '100%',
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',
minHeight: 300,
// :focus-visible will set outline

View File

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