mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user