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 { 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',
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user