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 { Popover, uniReactRoot } from '@affine/component';
|
||||||
import { Button } from '@affine/component/ui/button';
|
import { Button } from '@affine/component/ui/button';
|
||||||
import { Menu, MenuItem } from '@affine/component/ui/menu';
|
import { Menu, MenuItem } from '@affine/component/ui/menu';
|
||||||
|
import { PeekViewService } from '@affine/core/modules/peek-view';
|
||||||
import {
|
import {
|
||||||
type Cell,
|
type Cell,
|
||||||
type CellRenderProps,
|
type CellRenderProps,
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
|
|
||||||
import { WorkspaceDialogService } from '../../../../modules/dialogs';
|
import { WorkspaceDialogService } from '../../../../modules/dialogs';
|
||||||
import { useSignalValue } from '../../../../modules/doc-info/utils';
|
import { useSignalValue } from '../../../../modules/doc-info/utils';
|
||||||
|
import type { ImageData } from '../../../../modules/peek-view/view/image-preview';
|
||||||
import { CircularProgress } from '../../components/loading';
|
import { CircularProgress } from '../../components/loading';
|
||||||
import { progressIconContainer } from '../../components/loading.css';
|
import { progressIconContainer } from '../../components/loading.css';
|
||||||
import type {
|
import type {
|
||||||
@@ -216,7 +218,8 @@ class FileCellManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
props: CellRenderProps<{}, FileCellRawValueType, FileCellJsonValueType>
|
props: CellRenderProps<{}, FileCellRawValueType, FileCellJsonValueType>,
|
||||||
|
private readonly peekViewService: PeekViewService
|
||||||
) {
|
) {
|
||||||
this.cell = props.cell;
|
this.cell = props.cell;
|
||||||
this.selectCurrentCell = props.selectCurrentCell;
|
this.selectCurrentCell = props.selectCurrentCell;
|
||||||
@@ -298,6 +301,46 @@ class FileCellManager {
|
|||||||
a.order > b.order ? 1 : -1
|
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([
|
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
|
||||||
@@ -317,8 +360,9 @@ const FileCellComponent: ForwardRefRenderFunction<
|
|||||||
DataViewCellLifeCycle,
|
DataViewCellLifeCycle,
|
||||||
CellRenderProps<{}, FileCellRawValueType, FileCellJsonValueType>
|
CellRenderProps<{}, FileCellRawValueType, FileCellJsonValueType>
|
||||||
> = (props, ref): ReactNode => {
|
> = (props, ref): ReactNode => {
|
||||||
|
const peekView = useService(PeekViewService);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const manager = useMemo(() => new FileCellManager(props), []);
|
const manager = useMemo(() => new FileCellManager(props, peekView), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -396,7 +440,7 @@ const FileCellComponent: ForwardRefRenderFunction<
|
|||||||
key={file.id}
|
key={file.id}
|
||||||
file={file}
|
file={file}
|
||||||
handleRemoveFile={manager.removeFile}
|
handleRemoveFile={manager.removeFile}
|
||||||
fileUploadManager={manager.fileUploadManager}
|
manager={manager}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -440,10 +484,7 @@ const FileCellComponent: ForwardRefRenderFunction<
|
|||||||
<div className={styles.cellContainer}>
|
<div className={styles.cellContainer}>
|
||||||
{fileList.map(file => (
|
{fileList.map(file => (
|
||||||
<div key={file.id} className={styles.fileItemCell}>
|
<div key={file.id} className={styles.fileItemCell}>
|
||||||
<FilePreview
|
<FilePreview file={file} manager={manager} />
|
||||||
file={file}
|
|
||||||
fileUploadManager={manager.fileUploadManager}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -453,11 +494,13 @@ const FileCellComponent: ForwardRefRenderFunction<
|
|||||||
|
|
||||||
const useFilePreview = (
|
const useFilePreview = (
|
||||||
file: FileItemRenderType,
|
file: FileItemRenderType,
|
||||||
fileUploadManager?: FileUploadManager
|
manager: FileCellManager
|
||||||
): {
|
): {
|
||||||
preview: ReactNode;
|
preview: ReactNode;
|
||||||
|
onPreview?: () => void;
|
||||||
fileType: 'uploading' | 'loading' | 'image' | 'file';
|
fileType: 'uploading' | 'loading' | 'image' | 'file';
|
||||||
} => {
|
} => {
|
||||||
|
const fileUploadManager = manager.fileUploadManager;
|
||||||
const uploadProgress = useSignalValue(
|
const uploadProgress = useSignalValue(
|
||||||
file.type === 'uploading'
|
file.type === 'uploading'
|
||||||
? fileUploadManager?.getUploadProgress(file.id)
|
? fileUploadManager?.getUploadProgress(file.id)
|
||||||
@@ -486,9 +529,18 @@ const useFilePreview = (
|
|||||||
fileType: 'loading',
|
fileType: 'loading',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const onPreview = () => {
|
||||||
|
manager.openPreview(file.id);
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
|
onPreview,
|
||||||
preview: (
|
preview: (
|
||||||
<img
|
<img
|
||||||
|
onClick={(e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onPreview();
|
||||||
|
}}
|
||||||
className={styles.imagePreviewIcon}
|
className={styles.imagePreviewIcon}
|
||||||
src={loadFileData.url}
|
src={loadFileData.url}
|
||||||
alt={file.name}
|
alt={file.name}
|
||||||
@@ -507,11 +559,11 @@ const useFilePreview = (
|
|||||||
export const FileListItem = (props: {
|
export const FileListItem = (props: {
|
||||||
file: FileItemRenderType;
|
file: FileItemRenderType;
|
||||||
handleRemoveFile: (file: FileItemRenderType, e?: MouseEvent) => void;
|
handleRemoveFile: (file: FileItemRenderType, e?: MouseEvent) => void;
|
||||||
fileUploadManager?: FileUploadManager;
|
manager: FileCellManager;
|
||||||
}) => {
|
}) => {
|
||||||
const { file, handleRemoveFile, fileUploadManager } = props;
|
const { file, handleRemoveFile, manager } = props;
|
||||||
|
const { preview, fileType, onPreview } = useFilePreview(file, manager);
|
||||||
const { preview, fileType } = useFilePreview(file, fileUploadManager);
|
const fileUploadManager = manager.fileUploadManager;
|
||||||
|
|
||||||
const handleDownloadFile = useCallback(
|
const handleDownloadFile = useCallback(
|
||||||
async (fileId: string, e?: MouseEvent) => {
|
async (fileId: string, e?: MouseEvent) => {
|
||||||
@@ -541,18 +593,17 @@ export const FileListItem = (props: {
|
|||||||
},
|
},
|
||||||
[fileUploadManager, file.name]
|
[fileUploadManager, file.name]
|
||||||
);
|
);
|
||||||
|
|
||||||
const menuItems = (
|
const menuItems = (
|
||||||
<>
|
<>
|
||||||
{/* {fileType === 'image' && (
|
{fileType === 'image' && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={onPreview}
|
||||||
console.log('Preview image:', file.id);
|
|
||||||
}}
|
|
||||||
prefixIcon={<FileIcon width={20} height={20} />}
|
prefixIcon={<FileIcon width={20} height={20} />}
|
||||||
>
|
>
|
||||||
Preview
|
Preview
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)} */}
|
)}
|
||||||
{(fileType === 'file' || fileType === 'image') && (
|
{(fileType === 'file' || fileType === 'image') && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
@@ -579,7 +630,10 @@ export const FileListItem = (props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.fileItem}>
|
<div className={styles.fileItem}>
|
||||||
<div className={styles.fileItemContent}>
|
<div
|
||||||
|
className={styles.fileItemContent}
|
||||||
|
style={fileType === 'image' ? { cursor: 'pointer' } : undefined}
|
||||||
|
>
|
||||||
{fileType === 'image' ? (
|
{fileType === 'image' ? (
|
||||||
<div className={styles.fileItemImagePreview}>{preview}</div>
|
<div className={styles.fileItemImagePreview}>{preview}</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -606,15 +660,22 @@ export const FileListItem = (props: {
|
|||||||
|
|
||||||
const FilePreview = (props: {
|
const FilePreview = (props: {
|
||||||
file: FileItemRenderType;
|
file: FileItemRenderType;
|
||||||
fileUploadManager?: FileUploadManager;
|
manager: FileCellManager;
|
||||||
}) => {
|
}) => {
|
||||||
const { file, fileUploadManager } = props;
|
const { file, manager } = props;
|
||||||
const { preview, fileType } = useFilePreview(file, fileUploadManager);
|
const { preview, fileType } = useFilePreview(file, manager);
|
||||||
if (fileType === 'file') {
|
if (fileType === 'file') {
|
||||||
return <div className={styles.filePreviewContainer}>{file.name}</div>;
|
return <div className={styles.filePreviewContainer}>{file.name}</div>;
|
||||||
}
|
}
|
||||||
if (fileType === 'image') {
|
if (fileType === 'image') {
|
||||||
return <div className={styles.imagePreviewContainer}>{preview}</div>;
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.imagePreviewContainer}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{preview}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return preview;
|
return preview;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { firstValueFrom, map, race } from 'rxjs';
|
|||||||
import type { AIChatBlockModel } from '../../../blocksuite/ai/blocks';
|
import type { AIChatBlockModel } from '../../../blocksuite/ai/blocks';
|
||||||
import { resolveLinkToDoc } from '../../navigation';
|
import { resolveLinkToDoc } from '../../navigation';
|
||||||
import type { WorkbenchService } from '../../workbench';
|
import type { WorkbenchService } from '../../workbench';
|
||||||
|
import type { ImagePreviewData } from '../view/image-preview';
|
||||||
|
|
||||||
export type DocReferenceInfo = {
|
export type DocReferenceInfo = {
|
||||||
docId: string;
|
docId: string;
|
||||||
@@ -49,6 +50,11 @@ export interface DocPeekViewInfo {
|
|||||||
docRef: DocReferenceInfo;
|
docRef: DocReferenceInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImageListPeekViewInfo = {
|
||||||
|
type: 'image-list';
|
||||||
|
data: ImagePreviewData;
|
||||||
|
};
|
||||||
|
|
||||||
export type ImagePeekViewInfo = {
|
export type ImagePeekViewInfo = {
|
||||||
type: 'image';
|
type: 'image';
|
||||||
docRef: DocReferenceInfo;
|
docRef: DocReferenceInfo;
|
||||||
@@ -78,7 +84,8 @@ export type ActivePeekView = {
|
|||||||
| ImagePeekViewInfo
|
| ImagePeekViewInfo
|
||||||
| AttachmentPeekViewInfo
|
| AttachmentPeekViewInfo
|
||||||
| CustomTemplatePeekViewInfo
|
| CustomTemplatePeekViewInfo
|
||||||
| AIChatBlockPeekViewInfo;
|
| AIChatBlockPeekViewInfo
|
||||||
|
| ImageListPeekViewInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEmbedLinkedDocModel = (
|
const isEmbedLinkedDocModel = (
|
||||||
@@ -241,11 +248,21 @@ export class PeekViewEntity extends Entity {
|
|||||||
|
|
||||||
// return true if the peek view will be handled
|
// return true if the peek view will be handled
|
||||||
open = async (
|
open = async (
|
||||||
target: ActivePeekView['target'],
|
targetOrInfo: ActivePeekView['target'] | ActivePeekView['info'],
|
||||||
template?: TemplateResult,
|
template?: TemplateResult,
|
||||||
abortSignal?: AbortSignal
|
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) {
|
if (!resolvedInfo) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Divider, Loading, toast } from '@affine/component';
|
import { Divider, Loading, toast } from '@affine/component';
|
||||||
import { Button, IconButton } from '@affine/component/ui/button';
|
import { Button, IconButton } from '@affine/component/ui/button';
|
||||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import type { ImageBlockModel } from '@blocksuite/affine/model';
|
import type { ImageBlockModel } from '@blocksuite/affine/model';
|
||||||
import type { BlockModel, Workspace } from '@blocksuite/affine/store';
|
import type { BlockModel, Workspace } from '@blocksuite/affine/store';
|
||||||
import {
|
import {
|
||||||
@@ -37,9 +36,24 @@ import { useEditor } from '../utils';
|
|||||||
import { useZoomControls } from './hooks/use-zoom';
|
import { useZoomControls } from './hooks/use-zoom';
|
||||||
import * as styles from './index.css';
|
import * as styles from './index.css';
|
||||||
|
|
||||||
const filterImageBlock = (block: BlockModel): block is ImageBlockModel => {
|
export interface ImageData {
|
||||||
return block.flavour === 'affine:image';
|
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) {
|
async function copyImageToClipboard(url: string) {
|
||||||
const blob = await resourceUrlToBlob(url);
|
const blob = await resourceUrlToBlob(url);
|
||||||
@@ -55,111 +69,23 @@ async function copyImageToClipboard(url: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImagePreviewModalProps = {
|
const GenericImagePreview = forwardRef<
|
||||||
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<
|
|
||||||
HTMLImageElement,
|
HTMLImageElement,
|
||||||
{
|
ImgHTMLAttributes<HTMLImageElement>
|
||||||
docCollection: Workspace;
|
>(function GenericImagePreview(props, ref) {
|
||||||
docId: string;
|
if (!props.src) {
|
||||||
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) {
|
|
||||||
return <Loading size={24} />;
|
return <Loading size={24} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <img data-testid="image-content" ref={ref} {...props} />;
|
||||||
<img
|
|
||||||
data-blob-id={blockId}
|
|
||||||
data-testid="image-content"
|
|
||||||
src={blobUrl}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const ImagePreviewModalImpl = ({
|
export const GenericImagePreviewModal = ({
|
||||||
docId,
|
image,
|
||||||
blockId,
|
total,
|
||||||
onBlockIdChange,
|
|
||||||
onClose,
|
onClose,
|
||||||
}: ImagePreviewModalProps & {
|
blobId,
|
||||||
onBlockIdChange: (blockId: string) => void;
|
}: ImagePreviewProps): ReactElement => {
|
||||||
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);
|
|
||||||
const zoomRef = useRef<HTMLDivElement | null>(null);
|
const zoomRef = useRef<HTMLDivElement | null>(null);
|
||||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||||
const {
|
const {
|
||||||
@@ -174,107 +100,23 @@ const ImagePreviewModalImpl = ({
|
|||||||
currentScale,
|
currentScale,
|
||||||
} = useZoomControls({ zoomRef, imageRef });
|
} = 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 downloadHandler = useAsyncCallback(async () => {
|
||||||
const image = imageRef.current;
|
if (!image.url) return;
|
||||||
if (!image?.src) return;
|
const filename = image.caption || 'image';
|
||||||
const filename = caption || blockModel?.id || 'image';
|
await downloadResourceWithUrl(image.url, filename);
|
||||||
await downloadResourceWithUrl(image.src, filename);
|
}, [image]);
|
||||||
}, [caption, blockModel?.id]);
|
|
||||||
|
|
||||||
const copyHandler = useAsyncCallback(async () => {
|
const copyHandler = useAsyncCallback(async () => {
|
||||||
const image = imageRef.current;
|
if (!image.url) return;
|
||||||
if (!image?.src) return;
|
await copyImageToClipboard(image.url);
|
||||||
await copyImageToClipboard(image.src);
|
}, [image.url]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyUp = (event: KeyboardEvent) => {
|
const handleKeyUp = (event: KeyboardEvent) => {
|
||||||
if (!blocksuiteDoc || !blockModel) {
|
if (event.key === 'ArrowLeft' && image.previous) {
|
||||||
return;
|
image.previous();
|
||||||
}
|
} else if (event.key === 'ArrowRight' && image.next) {
|
||||||
|
image.next();
|
||||||
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);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -285,7 +127,6 @@ const ImagePreviewModalImpl = ({
|
|||||||
const onCopyEvent = (event: ClipboardEvent) => {
|
const onCopyEvent = (event: ClipboardEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
copyHandler();
|
copyHandler();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -295,7 +136,7 @@ const ImagePreviewModalImpl = ({
|
|||||||
document.removeEventListener('keyup', handleKeyUp);
|
document.removeEventListener('keyup', handleKeyUp);
|
||||||
document.removeEventListener('copy', onCopyEvent);
|
document.removeEventListener('copy', onCopyEvent);
|
||||||
};
|
};
|
||||||
}, [blockModel, blocksuiteDoc, copyHandler, onBlockIdChange]);
|
}, [copyHandler, image]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -309,13 +150,11 @@ const ImagePreviewModalImpl = ({
|
|||||||
ref={zoomRef}
|
ref={zoomRef}
|
||||||
>
|
>
|
||||||
<div className={styles.imagePreviewModalCenterStyle}>
|
<div className={styles.imagePreviewModalCenterStyle}>
|
||||||
<ImagePreview
|
<GenericImagePreview
|
||||||
data-blob-id={blockId}
|
data-blob-id={blobId}
|
||||||
|
src={image.url}
|
||||||
|
alt={image.caption}
|
||||||
data-testid="image-content"
|
data-testid="image-content"
|
||||||
docCollection={docCollection}
|
|
||||||
docId={docId}
|
|
||||||
blockId={blockId}
|
|
||||||
alt={caption}
|
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
ref={imageRef}
|
ref={imageRef}
|
||||||
draggable={isZoomedBigger}
|
draggable={isZoomedBigger}
|
||||||
@@ -329,7 +168,7 @@ const ImagePreviewModalImpl = ({
|
|||||||
data-testid="image-caption-zoomedout"
|
data-testid="image-caption-zoomedout"
|
||||||
className={styles.imagePreviewModalCaptionStyle}
|
className={styles.imagePreviewModalCaptionStyle}
|
||||||
>
|
>
|
||||||
{caption}
|
{image.caption}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -337,12 +176,12 @@ const ImagePreviewModalImpl = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.imageBottomContainerStyle}>
|
<div className={styles.imageBottomContainerStyle}>
|
||||||
{isZoomedBigger && caption !== '' ? (
|
{isZoomedBigger && image.caption ? (
|
||||||
<p
|
<p
|
||||||
data-testid={'image-caption-zoomedin'}
|
data-testid="image-caption-zoomedin"
|
||||||
className={styles.captionStyle}
|
className={styles.captionStyle}
|
||||||
>
|
>
|
||||||
{caption}
|
{image.caption}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={styles.imagePreviewActionBarStyle}>
|
<div className={styles.imagePreviewActionBarStyle}>
|
||||||
@@ -350,18 +189,20 @@ const ImagePreviewModalImpl = ({
|
|||||||
data-testid="previous-image-button"
|
data-testid="previous-image-button"
|
||||||
tooltip="Previous"
|
tooltip="Previous"
|
||||||
icon={<ArrowLeftSmallIcon />}
|
icon={<ArrowLeftSmallIcon />}
|
||||||
disabled={cursor < 1}
|
disabled={!image.previous}
|
||||||
onClick={() => goto(cursor - 1)}
|
onClick={image.previous}
|
||||||
/>
|
/>
|
||||||
<div className={styles.cursorStyle}>
|
{image.index != null && total != null && (
|
||||||
{`${blocks.length ? cursor + 1 : 0}/${blocks.length}`}
|
<div className={styles.cursorStyle}>
|
||||||
</div>
|
{`${image.index + 1}/${total}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
data-testid="next-image-button"
|
data-testid="next-image-button"
|
||||||
tooltip="Next"
|
tooltip="Next"
|
||||||
icon={<ArrowRightSmallIcon />}
|
icon={<ArrowRightSmallIcon />}
|
||||||
disabled={cursor + 1 === blocks.length}
|
disabled={!image.next}
|
||||||
onClick={() => goto(cursor + 1)}
|
onClick={image.next}
|
||||||
/>
|
/>
|
||||||
<Divider size="thinner" orientation="vertical" />
|
<Divider size="thinner" orientation="vertical" />
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -403,15 +244,14 @@ const ImagePreviewModalImpl = ({
|
|||||||
icon={<CopyIcon />}
|
icon={<CopyIcon />}
|
||||||
onClick={copyHandler}
|
onClick={copyHandler}
|
||||||
/>
|
/>
|
||||||
{blockModel && !blockModel.doc.readonly && (
|
{image.onDelete && (
|
||||||
<>
|
<>
|
||||||
<Divider size="thinner" orientation="vertical" />
|
<Divider size="thinner" orientation="vertical" />
|
||||||
<IconButton
|
<IconButton
|
||||||
data-testid="delete-button"
|
data-testid="delete-button"
|
||||||
tooltip="Delete"
|
tooltip="Delete"
|
||||||
icon={<DeleteIcon />}
|
icon={<DeleteIcon />}
|
||||||
disabled={blocks.length === 0}
|
onClick={image.onDelete}
|
||||||
onClick={() => deleteHandler(cursor)}
|
|
||||||
variant="danger"
|
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 = (
|
export const ImagePreviewPeekView = (
|
||||||
props: ImagePreviewModalProps
|
props: ImagePreviewModalProps
|
||||||
): ReactElement | null => {
|
): 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 { AIChatBlockPeekView } from './ai-chat-block-peek-view';
|
||||||
import { AttachmentPreviewPeekView } from './attachment-preview';
|
import { AttachmentPreviewPeekView } from './attachment-preview';
|
||||||
import { DocPeekPreview } from './doc-preview';
|
import { DocPeekPreview } from './doc-preview';
|
||||||
import { ImagePreviewPeekView } from './image-preview';
|
import {
|
||||||
|
GenericImagePreviewModalWithClose,
|
||||||
|
ImagePreviewPeekView,
|
||||||
|
} from './image-preview';
|
||||||
import {
|
import {
|
||||||
PeekViewModalContainer,
|
PeekViewModalContainer,
|
||||||
type PeekViewModalContainerProps,
|
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') {
|
if (info.type === 'ai-chat-block') {
|
||||||
return <AIChatBlockPeekView model={info.model} host={info.host} />;
|
return <AIChatBlockPeekView model={info.model} host={info.host} />;
|
||||||
}
|
}
|
||||||
@@ -61,7 +68,7 @@ const renderControls = ({ info }: ActivePeekView) => {
|
|||||||
return <AttachmentPeekViewControls docRef={info.docRef} />;
|
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
|
return null; // image controls are rendered in the image preview
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +76,7 @@ const renderControls = ({ info }: ActivePeekView) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getMode = (info: ActivePeekView['info']) => {
|
const getMode = (info: ActivePeekView['info']) => {
|
||||||
if (info.type === 'image') {
|
if (info.type === 'image' || info.type === 'image-list') {
|
||||||
return 'full';
|
return 'full';
|
||||||
}
|
}
|
||||||
return 'fit';
|
return 'fit';
|
||||||
@@ -94,7 +101,7 @@ const getRendererProps = (
|
|||||||
: undefined,
|
: undefined,
|
||||||
mode: getMode(activePeekView.info),
|
mode: getMode(activePeekView.info),
|
||||||
animation: 'fadeBottom',
|
animation: 'fadeBottom',
|
||||||
dialogFrame: activePeekView.info.type !== 'image',
|
dialogFrame: !['image', 'image-list'].includes(activePeekView.info.type),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user