mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-16 13:57:02 +08:00
feat(editor): support image preview for attachment columns (#11544)
close: BS-2634
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Popover, uniReactRoot } from '@affine/component';
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
import { Menu, MenuItem } from '@affine/component/ui/menu';
|
||||
import { PeekViewService } from '@affine/core/modules/peek-view';
|
||||
import {
|
||||
type Cell,
|
||||
type CellRenderProps,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
|
||||
import { WorkspaceDialogService } from '../../../../modules/dialogs';
|
||||
import { useSignalValue } from '../../../../modules/doc-info/utils';
|
||||
import type { ImageData } from '../../../../modules/peek-view/view/image-preview';
|
||||
import { CircularProgress } from '../../components/loading';
|
||||
import { progressIconContainer } from '../../components/loading.css';
|
||||
import type {
|
||||
@@ -216,7 +218,8 @@ class FileCellManager {
|
||||
}
|
||||
|
||||
constructor(
|
||||
props: CellRenderProps<{}, FileCellRawValueType, FileCellJsonValueType>
|
||||
props: CellRenderProps<{}, FileCellRawValueType, FileCellJsonValueType>,
|
||||
private readonly peekViewService: PeekViewService
|
||||
) {
|
||||
this.cell = props.cell;
|
||||
this.selectCurrentCell = props.selectCurrentCell;
|
||||
@@ -298,6 +301,46 @@ class FileCellManager {
|
||||
a.order > b.order ? 1 : -1
|
||||
);
|
||||
});
|
||||
|
||||
openPreview = (id: string) => {
|
||||
const imageList = this.fileList.value
|
||||
.filter(v => v.type === 'done')
|
||||
.map(v => ({
|
||||
...v,
|
||||
...this.fileUploadManager?.getFileInfo(v.id).value,
|
||||
}))
|
||||
.filter(v => SUPPORTED_IMAGE_MIME_TYPES.has(v?.fileType?.mime ?? ''));
|
||||
const getImageData = (index: number): ImageData | undefined => {
|
||||
const file = imageList[index];
|
||||
if (!file) return;
|
||||
const previousIndex = index - 1;
|
||||
const nextIndex = index + 1;
|
||||
const hasPrevious = previousIndex >= 0;
|
||||
const hasNext = nextIndex < imageList.length;
|
||||
return {
|
||||
index,
|
||||
url: file.url ?? '',
|
||||
caption: file.name,
|
||||
previous: hasPrevious ? () => getImageData(previousIndex) : undefined,
|
||||
next: hasNext ? () => getImageData(nextIndex) : undefined,
|
||||
};
|
||||
};
|
||||
const currentIndex = imageList.findIndex(v => v.id === id);
|
||||
if (currentIndex === -1) return;
|
||||
const imageData = getImageData(currentIndex);
|
||||
if (!imageData) return;
|
||||
this.peekViewService.peekView
|
||||
.open({
|
||||
type: 'image-list',
|
||||
data: {
|
||||
image: imageData,
|
||||
total: imageList.length,
|
||||
},
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to open image list', error);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
|
||||
@@ -317,8 +360,9 @@ const FileCellComponent: ForwardRefRenderFunction<
|
||||
DataViewCellLifeCycle,
|
||||
CellRenderProps<{}, FileCellRawValueType, FileCellJsonValueType>
|
||||
> = (props, ref): ReactNode => {
|
||||
const peekView = useService(PeekViewService);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const manager = useMemo(() => new FileCellManager(props), []);
|
||||
const manager = useMemo(() => new FileCellManager(props, peekView), []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -396,7 +440,7 @@ const FileCellComponent: ForwardRefRenderFunction<
|
||||
key={file.id}
|
||||
file={file}
|
||||
handleRemoveFile={manager.removeFile}
|
||||
fileUploadManager={manager.fileUploadManager}
|
||||
manager={manager}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -440,10 +484,7 @@ const FileCellComponent: ForwardRefRenderFunction<
|
||||
<div className={styles.cellContainer}>
|
||||
{fileList.map(file => (
|
||||
<div key={file.id} className={styles.fileItemCell}>
|
||||
<FilePreview
|
||||
file={file}
|
||||
fileUploadManager={manager.fileUploadManager}
|
||||
/>
|
||||
<FilePreview file={file} manager={manager} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -453,11 +494,13 @@ const FileCellComponent: ForwardRefRenderFunction<
|
||||
|
||||
const useFilePreview = (
|
||||
file: FileItemRenderType,
|
||||
fileUploadManager?: FileUploadManager
|
||||
manager: FileCellManager
|
||||
): {
|
||||
preview: ReactNode;
|
||||
onPreview?: () => void;
|
||||
fileType: 'uploading' | 'loading' | 'image' | 'file';
|
||||
} => {
|
||||
const fileUploadManager = manager.fileUploadManager;
|
||||
const uploadProgress = useSignalValue(
|
||||
file.type === 'uploading'
|
||||
? fileUploadManager?.getUploadProgress(file.id)
|
||||
@@ -486,9 +529,18 @@ const useFilePreview = (
|
||||
fileType: 'loading',
|
||||
};
|
||||
}
|
||||
const onPreview = () => {
|
||||
manager.openPreview(file.id);
|
||||
};
|
||||
return {
|
||||
onPreview,
|
||||
preview: (
|
||||
<img
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onPreview();
|
||||
}}
|
||||
className={styles.imagePreviewIcon}
|
||||
src={loadFileData.url}
|
||||
alt={file.name}
|
||||
@@ -507,11 +559,11 @@ const useFilePreview = (
|
||||
export const FileListItem = (props: {
|
||||
file: FileItemRenderType;
|
||||
handleRemoveFile: (file: FileItemRenderType, e?: MouseEvent) => void;
|
||||
fileUploadManager?: FileUploadManager;
|
||||
manager: FileCellManager;
|
||||
}) => {
|
||||
const { file, handleRemoveFile, fileUploadManager } = props;
|
||||
|
||||
const { preview, fileType } = useFilePreview(file, fileUploadManager);
|
||||
const { file, handleRemoveFile, manager } = props;
|
||||
const { preview, fileType, onPreview } = useFilePreview(file, manager);
|
||||
const fileUploadManager = manager.fileUploadManager;
|
||||
|
||||
const handleDownloadFile = useCallback(
|
||||
async (fileId: string, e?: MouseEvent) => {
|
||||
@@ -541,18 +593,17 @@ export const FileListItem = (props: {
|
||||
},
|
||||
[fileUploadManager, file.name]
|
||||
);
|
||||
|
||||
const menuItems = (
|
||||
<>
|
||||
{/* {fileType === 'image' && (
|
||||
{fileType === 'image' && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
console.log('Preview image:', file.id);
|
||||
}}
|
||||
onClick={onPreview}
|
||||
prefixIcon={<FileIcon width={20} height={20} />}
|
||||
>
|
||||
Preview
|
||||
</MenuItem>
|
||||
)} */}
|
||||
)}
|
||||
{(fileType === 'file' || fileType === 'image') && (
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
@@ -579,7 +630,10 @@ export const FileListItem = (props: {
|
||||
|
||||
return (
|
||||
<div className={styles.fileItem}>
|
||||
<div className={styles.fileItemContent}>
|
||||
<div
|
||||
className={styles.fileItemContent}
|
||||
style={fileType === 'image' ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
{fileType === 'image' ? (
|
||||
<div className={styles.fileItemImagePreview}>{preview}</div>
|
||||
) : (
|
||||
@@ -606,15 +660,22 @@ export const FileListItem = (props: {
|
||||
|
||||
const FilePreview = (props: {
|
||||
file: FileItemRenderType;
|
||||
fileUploadManager?: FileUploadManager;
|
||||
manager: FileCellManager;
|
||||
}) => {
|
||||
const { file, fileUploadManager } = props;
|
||||
const { preview, fileType } = useFilePreview(file, fileUploadManager);
|
||||
const { file, manager } = props;
|
||||
const { preview, fileType } = useFilePreview(file, manager);
|
||||
if (fileType === 'file') {
|
||||
return <div className={styles.filePreviewContainer}>{file.name}</div>;
|
||||
}
|
||||
if (fileType === 'image') {
|
||||
return <div className={styles.imagePreviewContainer}>{preview}</div>;
|
||||
return (
|
||||
<div
|
||||
className={styles.imagePreviewContainer}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{preview}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return preview;
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import { firstValueFrom, map, race } from 'rxjs';
|
||||
import type { AIChatBlockModel } from '../../../blocksuite/ai/blocks';
|
||||
import { resolveLinkToDoc } from '../../navigation';
|
||||
import type { WorkbenchService } from '../../workbench';
|
||||
import type { ImagePreviewData } from '../view/image-preview';
|
||||
|
||||
export type DocReferenceInfo = {
|
||||
docId: string;
|
||||
@@ -49,6 +50,11 @@ export interface DocPeekViewInfo {
|
||||
docRef: DocReferenceInfo;
|
||||
}
|
||||
|
||||
export type ImageListPeekViewInfo = {
|
||||
type: 'image-list';
|
||||
data: ImagePreviewData;
|
||||
};
|
||||
|
||||
export type ImagePeekViewInfo = {
|
||||
type: 'image';
|
||||
docRef: DocReferenceInfo;
|
||||
@@ -78,7 +84,8 @@ export type ActivePeekView = {
|
||||
| ImagePeekViewInfo
|
||||
| AttachmentPeekViewInfo
|
||||
| CustomTemplatePeekViewInfo
|
||||
| AIChatBlockPeekViewInfo;
|
||||
| AIChatBlockPeekViewInfo
|
||||
| ImageListPeekViewInfo;
|
||||
};
|
||||
|
||||
const isEmbedLinkedDocModel = (
|
||||
@@ -241,11 +248,21 @@ export class PeekViewEntity extends Entity {
|
||||
|
||||
// return true if the peek view will be handled
|
||||
open = async (
|
||||
target: ActivePeekView['target'],
|
||||
targetOrInfo: ActivePeekView['target'] | ActivePeekView['info'],
|
||||
template?: TemplateResult,
|
||||
abortSignal?: AbortSignal
|
||||
) => {
|
||||
const resolvedInfo = resolvePeekInfoFromPeekTarget(target, template);
|
||||
let target: ActivePeekView['target'];
|
||||
let resolvedInfo: ActivePeekView['info'] | undefined;
|
||||
|
||||
if ('type' in targetOrInfo) {
|
||||
resolvedInfo = targetOrInfo;
|
||||
target = {};
|
||||
} else {
|
||||
target = targetOrInfo;
|
||||
resolvedInfo = resolvePeekInfoFromPeekTarget(target, template);
|
||||
}
|
||||
|
||||
if (!resolvedInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Divider, Loading, toast } from '@affine/component';
|
||||
import { Button, IconButton } from '@affine/component/ui/button';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type { ImageBlockModel } from '@blocksuite/affine/model';
|
||||
import type { BlockModel, Workspace } from '@blocksuite/affine/store';
|
||||
import {
|
||||
@@ -37,9 +36,24 @@ import { useEditor } from '../utils';
|
||||
import { useZoomControls } from './hooks/use-zoom';
|
||||
import * as styles from './index.css';
|
||||
|
||||
const filterImageBlock = (block: BlockModel): block is ImageBlockModel => {
|
||||
return block.flavour === 'affine:image';
|
||||
};
|
||||
export interface ImageData {
|
||||
index?: number;
|
||||
url: string;
|
||||
caption?: string;
|
||||
onDelete?: () => void;
|
||||
previous?: () => ImageData | undefined;
|
||||
next?: () => ImageData | undefined;
|
||||
}
|
||||
|
||||
export interface ImagePreviewData {
|
||||
image: ImageData;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface ImagePreviewProps extends ImagePreviewData {
|
||||
onClose: () => void;
|
||||
blobId?: string;
|
||||
}
|
||||
|
||||
async function copyImageToClipboard(url: string) {
|
||||
const blob = await resourceUrlToBlob(url);
|
||||
@@ -55,111 +69,23 @@ async function copyImageToClipboard(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export type ImagePreviewModalProps = {
|
||||
docId: string;
|
||||
blockId: string;
|
||||
};
|
||||
|
||||
function useImageBlob(
|
||||
docCollection: Workspace,
|
||||
docId: string,
|
||||
blockId: string
|
||||
) {
|
||||
const { data, error, isLoading } = useSWR(
|
||||
['workspace', 'image', docId, blockId],
|
||||
{
|
||||
fetcher: async ([_, __, pageId, blockId]) => {
|
||||
const page = docCollection.getDoc(pageId)?.getStore();
|
||||
const block = page?.getBlock(blockId);
|
||||
if (!block) {
|
||||
return null;
|
||||
}
|
||||
const blockModel = block.model as ImageBlockModel;
|
||||
return await docCollection.blobSync.get(
|
||||
blockModel.props.sourceId as string
|
||||
);
|
||||
},
|
||||
suspense: false,
|
||||
}
|
||||
);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
const ImagePreview = forwardRef<
|
||||
const GenericImagePreview = forwardRef<
|
||||
HTMLImageElement,
|
||||
{
|
||||
docCollection: Workspace;
|
||||
docId: string;
|
||||
blockId: string;
|
||||
} & ImgHTMLAttributes<HTMLImageElement>
|
||||
>(function ImagePreview({ docCollection, docId, blockId, ...props }, ref) {
|
||||
const { data, error, isLoading } = useImageBlob(
|
||||
docCollection,
|
||||
docId,
|
||||
blockId
|
||||
);
|
||||
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
let blobUrl = null;
|
||||
if (data) {
|
||||
blobUrl = URL.createObjectURL(data);
|
||||
setBlobUrl(blobUrl);
|
||||
}
|
||||
return () => {
|
||||
if (blobUrl) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
if (error) {
|
||||
return <div>{t['error.NOT_FOUND']()}</div>;
|
||||
}
|
||||
|
||||
if (!blobUrl || isLoading) {
|
||||
ImgHTMLAttributes<HTMLImageElement>
|
||||
>(function GenericImagePreview(props, ref) {
|
||||
if (!props.src) {
|
||||
return <Loading size={24} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
data-blob-id={blockId}
|
||||
data-testid="image-content"
|
||||
src={blobUrl}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <img data-testid="image-content" ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
const ImagePreviewModalImpl = ({
|
||||
docId,
|
||||
blockId,
|
||||
onBlockIdChange,
|
||||
export const GenericImagePreviewModal = ({
|
||||
image,
|
||||
total,
|
||||
onClose,
|
||||
}: ImagePreviewModalProps & {
|
||||
onBlockIdChange: (blockId: string) => void;
|
||||
onClose: () => void;
|
||||
}): ReactElement | null => {
|
||||
const { doc, workspace } = useEditor(docId);
|
||||
const blocksuiteDoc = doc?.blockSuiteDoc;
|
||||
const docCollection = workspace.docCollection;
|
||||
const blockModel = useMemo(() => {
|
||||
const block = blocksuiteDoc?.getBlock(blockId);
|
||||
if (!block) {
|
||||
return null;
|
||||
}
|
||||
return block.model as ImageBlockModel;
|
||||
}, [blockId, blocksuiteDoc]);
|
||||
const caption = useMemo(() => {
|
||||
return blockModel?.props.caption ?? '';
|
||||
}, [blockModel?.props.caption]);
|
||||
const [blocks, setBlocks] = useState<ImageBlockModel[]>([]);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
blobId,
|
||||
}: ImagePreviewProps): ReactElement => {
|
||||
const zoomRef = useRef<HTMLDivElement | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const {
|
||||
@@ -174,107 +100,23 @@ const ImagePreviewModalImpl = ({
|
||||
currentScale,
|
||||
} = useZoomControls({ zoomRef, imageRef });
|
||||
|
||||
const goto = useCallback(
|
||||
(index: number) => {
|
||||
const block = blocks[index];
|
||||
|
||||
if (!block) return;
|
||||
|
||||
setCursor(index);
|
||||
onBlockIdChange(block.id);
|
||||
resetZoom();
|
||||
},
|
||||
[blocks, onBlockIdChange, resetZoom]
|
||||
);
|
||||
|
||||
const deleteHandler = useCallback(
|
||||
(index: number) => {
|
||||
if (!blocksuiteDoc) {
|
||||
return;
|
||||
}
|
||||
|
||||
let block = blocks[index];
|
||||
|
||||
if (!block) return;
|
||||
const newBlocks = blocks.toSpliced(index, 1);
|
||||
setBlocks(newBlocks);
|
||||
|
||||
blocksuiteDoc.deleteBlock(block);
|
||||
|
||||
// next
|
||||
block = newBlocks[index];
|
||||
|
||||
// prev
|
||||
if (!block) {
|
||||
index -= 1;
|
||||
block = newBlocks[index];
|
||||
|
||||
if (!block) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
setCursor(index);
|
||||
}
|
||||
|
||||
onBlockIdChange(block.id);
|
||||
|
||||
resetZoom();
|
||||
},
|
||||
[blocksuiteDoc, blocks, onBlockIdChange, resetZoom, onClose]
|
||||
);
|
||||
const downloadHandler = useAsyncCallback(async () => {
|
||||
const image = imageRef.current;
|
||||
if (!image?.src) return;
|
||||
const filename = caption || blockModel?.id || 'image';
|
||||
await downloadResourceWithUrl(image.src, filename);
|
||||
}, [caption, blockModel?.id]);
|
||||
if (!image.url) return;
|
||||
const filename = image.caption || 'image';
|
||||
await downloadResourceWithUrl(image.url, filename);
|
||||
}, [image]);
|
||||
|
||||
const copyHandler = useAsyncCallback(async () => {
|
||||
const image = imageRef.current;
|
||||
if (!image?.src) return;
|
||||
await copyImageToClipboard(image.src);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!blockModel || !blocksuiteDoc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevs = blocksuiteDoc.getPrevs(blockModel).filter(filterImageBlock);
|
||||
const nexts = blocksuiteDoc.getNexts(blockModel).filter(filterImageBlock);
|
||||
|
||||
const blocks = [...prevs, blockModel, ...nexts];
|
||||
setBlocks(blocks);
|
||||
setCursor(blocks.length ? prevs.length : 0);
|
||||
}, [setBlocks, blockModel, blocksuiteDoc]);
|
||||
if (!image.url) return;
|
||||
await copyImageToClipboard(image.url);
|
||||
}, [image.url]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (!blocksuiteDoc || !blockModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
const prevBlock = blocksuiteDoc
|
||||
.getPrevs(blockModel)
|
||||
.findLast(
|
||||
(block): block is ImageBlockModel =>
|
||||
block.flavour === 'affine:image'
|
||||
);
|
||||
if (prevBlock) {
|
||||
onBlockIdChange(prevBlock.id);
|
||||
}
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
const nextBlock = blocksuiteDoc
|
||||
.getNexts(blockModel)
|
||||
.find(
|
||||
(block): block is ImageBlockModel =>
|
||||
block.flavour === 'affine:image'
|
||||
);
|
||||
if (nextBlock) {
|
||||
onBlockIdChange(nextBlock.id);
|
||||
}
|
||||
if (event.key === 'ArrowLeft' && image.previous) {
|
||||
image.previous();
|
||||
} else if (event.key === 'ArrowRight' && image.next) {
|
||||
image.next();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -285,7 +127,6 @@ const ImagePreviewModalImpl = ({
|
||||
const onCopyEvent = (event: ClipboardEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
copyHandler();
|
||||
};
|
||||
|
||||
@@ -295,7 +136,7 @@ const ImagePreviewModalImpl = ({
|
||||
document.removeEventListener('keyup', handleKeyUp);
|
||||
document.removeEventListener('copy', onCopyEvent);
|
||||
};
|
||||
}, [blockModel, blocksuiteDoc, copyHandler, onBlockIdChange]);
|
||||
}, [copyHandler, image]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -309,13 +150,11 @@ const ImagePreviewModalImpl = ({
|
||||
ref={zoomRef}
|
||||
>
|
||||
<div className={styles.imagePreviewModalCenterStyle}>
|
||||
<ImagePreview
|
||||
data-blob-id={blockId}
|
||||
<GenericImagePreview
|
||||
data-blob-id={blobId}
|
||||
src={image.url}
|
||||
alt={image.caption}
|
||||
data-testid="image-content"
|
||||
docCollection={docCollection}
|
||||
docId={docId}
|
||||
blockId={blockId}
|
||||
alt={caption}
|
||||
tabIndex={0}
|
||||
ref={imageRef}
|
||||
draggable={isZoomedBigger}
|
||||
@@ -329,7 +168,7 @@ const ImagePreviewModalImpl = ({
|
||||
data-testid="image-caption-zoomedout"
|
||||
className={styles.imagePreviewModalCaptionStyle}
|
||||
>
|
||||
{caption}
|
||||
{image.caption}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -337,12 +176,12 @@ const ImagePreviewModalImpl = ({
|
||||
</div>
|
||||
|
||||
<div className={styles.imageBottomContainerStyle}>
|
||||
{isZoomedBigger && caption !== '' ? (
|
||||
{isZoomedBigger && image.caption ? (
|
||||
<p
|
||||
data-testid={'image-caption-zoomedin'}
|
||||
data-testid="image-caption-zoomedin"
|
||||
className={styles.captionStyle}
|
||||
>
|
||||
{caption}
|
||||
{image.caption}
|
||||
</p>
|
||||
) : null}
|
||||
<div className={styles.imagePreviewActionBarStyle}>
|
||||
@@ -350,18 +189,20 @@ const ImagePreviewModalImpl = ({
|
||||
data-testid="previous-image-button"
|
||||
tooltip="Previous"
|
||||
icon={<ArrowLeftSmallIcon />}
|
||||
disabled={cursor < 1}
|
||||
onClick={() => goto(cursor - 1)}
|
||||
disabled={!image.previous}
|
||||
onClick={image.previous}
|
||||
/>
|
||||
<div className={styles.cursorStyle}>
|
||||
{`${blocks.length ? cursor + 1 : 0}/${blocks.length}`}
|
||||
</div>
|
||||
{image.index != null && total != null && (
|
||||
<div className={styles.cursorStyle}>
|
||||
{`${image.index + 1}/${total}`}
|
||||
</div>
|
||||
)}
|
||||
<IconButton
|
||||
data-testid="next-image-button"
|
||||
tooltip="Next"
|
||||
icon={<ArrowRightSmallIcon />}
|
||||
disabled={cursor + 1 === blocks.length}
|
||||
onClick={() => goto(cursor + 1)}
|
||||
disabled={!image.next}
|
||||
onClick={image.next}
|
||||
/>
|
||||
<Divider size="thinner" orientation="vertical" />
|
||||
<IconButton
|
||||
@@ -403,15 +244,14 @@ const ImagePreviewModalImpl = ({
|
||||
icon={<CopyIcon />}
|
||||
onClick={copyHandler}
|
||||
/>
|
||||
{blockModel && !blockModel.doc.readonly && (
|
||||
{image.onDelete && (
|
||||
<>
|
||||
<Divider size="thinner" orientation="vertical" />
|
||||
<IconButton
|
||||
data-testid="delete-button"
|
||||
tooltip="Delete"
|
||||
icon={<DeleteIcon />}
|
||||
disabled={blocks.length === 0}
|
||||
onClick={() => deleteHandler(cursor)}
|
||||
onClick={image.onDelete}
|
||||
variant="danger"
|
||||
/>
|
||||
</>
|
||||
@@ -422,6 +262,171 @@ const ImagePreviewModalImpl = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Adapter layer
|
||||
export type ImagePreviewModalProps = {
|
||||
docId: string;
|
||||
blockId: string;
|
||||
};
|
||||
|
||||
const useImageBlob = (
|
||||
docCollection: Workspace,
|
||||
docId: string,
|
||||
blockId: string
|
||||
) => {
|
||||
const { data, error, isLoading } = useSWR(
|
||||
['workspace', 'image', docId, blockId],
|
||||
{
|
||||
fetcher: async ([_, __, pageId, blockId]) => {
|
||||
const page = docCollection.getDoc(pageId)?.getStore();
|
||||
const block = page?.getBlock(blockId);
|
||||
if (!block) {
|
||||
return null;
|
||||
}
|
||||
const blockModel = block.model as ImageBlockModel;
|
||||
return await docCollection.blobSync.get(
|
||||
blockModel.props.sourceId as string
|
||||
);
|
||||
},
|
||||
suspense: false,
|
||||
}
|
||||
);
|
||||
|
||||
return { data, error, isLoading };
|
||||
};
|
||||
|
||||
const ImagePreviewModalImpl = ({
|
||||
docId,
|
||||
blockId,
|
||||
onBlockIdChange,
|
||||
onClose,
|
||||
}: ImagePreviewModalProps & {
|
||||
onBlockIdChange: (blockId: string) => void;
|
||||
onClose: () => void;
|
||||
}): ReactElement | null => {
|
||||
const { doc, workspace } = useEditor(docId);
|
||||
const blocksuiteDoc = doc?.blockSuiteDoc;
|
||||
const docCollection = workspace.docCollection;
|
||||
const blockModel = useMemo(() => {
|
||||
const block = blocksuiteDoc?.getBlock(blockId);
|
||||
if (!block) {
|
||||
return null;
|
||||
}
|
||||
return block.model as ImageBlockModel;
|
||||
}, [blockId, blocksuiteDoc]);
|
||||
|
||||
const {
|
||||
data: blobData,
|
||||
error,
|
||||
isLoading,
|
||||
} = useImageBlob(docCollection, docId, blockId);
|
||||
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let blobUrl = null;
|
||||
if (blobData) {
|
||||
blobUrl = URL.createObjectURL(blobData);
|
||||
setBlobUrl(blobUrl);
|
||||
}
|
||||
return () => {
|
||||
if (blobUrl) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
};
|
||||
}, [blobData]);
|
||||
|
||||
const [blocks, setBlocks] = useState<ImageBlockModel[]>([]);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!blockModel || !blocksuiteDoc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevs = blocksuiteDoc.getPrevs(blockModel).filter(filterImageBlock);
|
||||
const nexts = blocksuiteDoc.getNexts(blockModel).filter(filterImageBlock);
|
||||
|
||||
const blocks = [...prevs, blockModel, ...nexts];
|
||||
setBlocks(blocks);
|
||||
setCursor(blocks.length ? prevs.length : 0);
|
||||
}, [blockModel, blocksuiteDoc]);
|
||||
|
||||
if (error || !blobUrl || isLoading || !blockModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createImageData = (index: number): ImageData => {
|
||||
const prevBlock = blocks[index - 1];
|
||||
const nextBlock = blocks[index + 1];
|
||||
return {
|
||||
index,
|
||||
url: blobUrl,
|
||||
caption: blockModel.props.caption,
|
||||
onDelete: !blockModel.doc.readonly
|
||||
? () => {
|
||||
handleDelete();
|
||||
}
|
||||
: undefined,
|
||||
previous: prevBlock
|
||||
? () => {
|
||||
onBlockIdChange(prevBlock.id);
|
||||
return createImageData(index - 1);
|
||||
}
|
||||
: undefined,
|
||||
next: nextBlock
|
||||
? () => {
|
||||
onBlockIdChange(nextBlock.id);
|
||||
return createImageData(index + 1);
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const imageData: ImageData = createImageData(cursor);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!blocksuiteDoc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentBlock = blocks[cursor];
|
||||
if (!currentBlock) return;
|
||||
|
||||
const newBlocks = blocks.toSpliced(cursor, 1);
|
||||
setBlocks(newBlocks);
|
||||
blocksuiteDoc.deleteBlock(currentBlock);
|
||||
|
||||
let nextBlock = newBlocks[cursor];
|
||||
|
||||
if (!nextBlock) {
|
||||
const prevIndex = cursor - 1;
|
||||
nextBlock = newBlocks[prevIndex];
|
||||
|
||||
if (!nextBlock) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
setCursor(prevIndex);
|
||||
}
|
||||
|
||||
onBlockIdChange(nextBlock.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<GenericImagePreviewModal
|
||||
total={blocks.length}
|
||||
image={imageData}
|
||||
onClose={onClose}
|
||||
blobId={blockId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const filterImageBlock = (block: BlockModel): block is ImageBlockModel => {
|
||||
return block.flavour === 'affine:image';
|
||||
};
|
||||
|
||||
export const ImagePreviewPeekView = (
|
||||
props: ImagePreviewModalProps
|
||||
): ReactElement | null => {
|
||||
@@ -455,3 +460,51 @@ export const ImagePreviewPeekView = (
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const GenericImagePreviewModalWithClose = (
|
||||
props: Omit<ImagePreviewProps, 'onClose'>
|
||||
) => {
|
||||
const peekViewService = useService(PeekViewService);
|
||||
const handleClose = useCallback(() => {
|
||||
peekViewService.peekView.close();
|
||||
}, [peekViewService]);
|
||||
|
||||
const [image, setImage] = useState<ImageData>(props.image);
|
||||
|
||||
const prevImage = useCallback(() => {
|
||||
const prev = image.previous?.();
|
||||
if (!prev) return;
|
||||
setImage(prev);
|
||||
return prev;
|
||||
}, [image]);
|
||||
|
||||
const nextImage = useCallback(() => {
|
||||
const next = image.next?.();
|
||||
if (!next) return;
|
||||
setImage(next);
|
||||
return next;
|
||||
}, [image]);
|
||||
return (
|
||||
<>
|
||||
<GenericImagePreviewModal
|
||||
total={props.total}
|
||||
image={{
|
||||
index: image.index,
|
||||
url: image.url,
|
||||
caption: image.caption,
|
||||
onDelete: image.onDelete,
|
||||
previous: prevImage,
|
||||
next: nextImage,
|
||||
}}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
<button
|
||||
data-testid="image-preview-close-button"
|
||||
onClick={handleClose}
|
||||
className={styles.imagePreviewModalCloseButtonStyle}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,10 @@ import { PeekViewService } from '../services/peek-view';
|
||||
import { AIChatBlockPeekView } from './ai-chat-block-peek-view';
|
||||
import { AttachmentPreviewPeekView } from './attachment-preview';
|
||||
import { DocPeekPreview } from './doc-preview';
|
||||
import { ImagePreviewPeekView } from './image-preview';
|
||||
import {
|
||||
GenericImagePreviewModalWithClose,
|
||||
ImagePreviewPeekView,
|
||||
} from './image-preview';
|
||||
import {
|
||||
PeekViewModalContainer,
|
||||
type PeekViewModalContainerProps,
|
||||
@@ -45,6 +48,10 @@ function renderPeekView({ info }: ActivePeekView, animating?: boolean) {
|
||||
);
|
||||
}
|
||||
|
||||
if (info.type === 'image-list') {
|
||||
return <GenericImagePreviewModalWithClose {...info.data} />;
|
||||
}
|
||||
|
||||
if (info.type === 'ai-chat-block') {
|
||||
return <AIChatBlockPeekView model={info.model} host={info.host} />;
|
||||
}
|
||||
@@ -61,7 +68,7 @@ const renderControls = ({ info }: ActivePeekView) => {
|
||||
return <AttachmentPeekViewControls docRef={info.docRef} />;
|
||||
}
|
||||
|
||||
if (info.type === 'image') {
|
||||
if (info.type === 'image' || info.type === 'image-list') {
|
||||
return null; // image controls are rendered in the image preview
|
||||
}
|
||||
|
||||
@@ -69,7 +76,7 @@ const renderControls = ({ info }: ActivePeekView) => {
|
||||
};
|
||||
|
||||
const getMode = (info: ActivePeekView['info']) => {
|
||||
if (info.type === 'image') {
|
||||
if (info.type === 'image' || info.type === 'image-list') {
|
||||
return 'full';
|
||||
}
|
||||
return 'fit';
|
||||
@@ -94,7 +101,7 @@ const getRendererProps = (
|
||||
: undefined,
|
||||
mode: getMode(activePeekView.info),
|
||||
animation: 'fadeBottom',
|
||||
dialogFrame: activePeekView.info.type !== 'image',
|
||||
dialogFrame: !['image', 'image-list'].includes(activePeekView.info.type),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user