diff --git a/frontend/src/components/dialog/image-dialog.js b/frontend/src/components/dialog/image-dialog.js
index d205688004..c70fcb0141 100644
--- a/frontend/src/components/dialog/image-dialog.js
+++ b/frontend/src/components/dialog/image-dialog.js
@@ -42,8 +42,8 @@ class ImageDialog extends React.Component {
};
render() {
- const imageItems = this.props.imageItems;
- const imageIndex = this.props.imageIndex;
+ const { imageItems, imageIndex, closeImagePopup, moveToPrevImage, moveToNextImage, onDeleteImage } = this.props;
+
const imageItemsLength = imageItems.length;
const name = imageItems[imageIndex].name;
const imageTitle = `${name} (${imageIndex + 1}/${imageItemsLength})`;
@@ -61,9 +61,9 @@ class ImageDialog extends React.Component {
mainSrc={mainSrc}
nextSrc={nextSrc}
prevSrc={prevSrc}
- onCloseRequest={this.props.closeImagePopup}
- onMovePrevRequest={this.props.moveToPrevImage}
- onMoveNextRequest={this.props.moveToNextImage}
+ onCloseRequest={closeImagePopup}
+ onMovePrevRequest={moveToPrevImage}
+ onMoveNextRequest={moveToNextImage}
imagePadding={70}
imageLoadErrorMessage={gettext('The image could not be loaded.')}
prevLabel={gettext('Previous (Left arrow key)')}
@@ -72,8 +72,8 @@ class ImageDialog extends React.Component {
zoomInLabel={gettext('Zoom in')}
zoomOutLabel={gettext('Zoom out')}
enableRotate={true}
- onClickDownload={() => this.downloadImage(imageItems[imageIndex].downloadURL)}
- onClickDelete={this.props.onDeleteImage ? () => this.props.onDeleteImage(imageItems[imageIndex].name) : null}
+ onClickDownload={() => this.downloadImage(imageItems[imageIndex].url)}
+ onClickDelete={onDeleteImage ? () => onDeleteImage(imageItems[imageIndex].name) : null}
onViewOriginal={this.onViewOriginal}
viewOriginalImageLabel={gettext('View original image')}
onRotateImage={this.onRotateImage}
diff --git a/frontend/src/metadata/api.js b/frontend/src/metadata/api.js
index 84d0e04e14..927db70bc4 100644
--- a/frontend/src/metadata/api.js
+++ b/frontend/src/metadata/api.js
@@ -221,6 +221,32 @@ class MetadataManagerAPI {
};
return this.req.post(url, params);
};
+
+ zipDownload(repoID, parent_dir, dirents) {
+ const url = this.server + '/api/v2.1/repos/' + repoID + '/zip-task/';
+ const form = new FormData();
+ form.append('parent_dir', parent_dir);
+ dirents.forEach(item => {
+ form.append('dirents', item);
+ });
+
+ return this._sendPostRequest(url, form);
+ }
+
+ /**
+ * Delete multiple files or folders in a repository, used to delete images in gallery originally
+ * @param {string} repoID - The ID of the repository
+ * @param {string[]} dirents - Array of file/folder paths to delete
+ * @returns {Promise} Axios delete request promise
+ */
+ deleteImages(repoID, dirents) {
+ const url = this.server + '/api/v2.1/repos/batch-delete-folders-item/';
+ const data = {
+ repo_id: repoID,
+ file_names: dirents
+ };
+ return this.req.delete(url, { data });
+ }
}
const metadataAPI = new MetadataManagerAPI();
diff --git a/frontend/src/metadata/views/gallery/context-menu/index.css b/frontend/src/metadata/views/gallery/context-menu/index.css
new file mode 100644
index 0000000000..1159fae5c2
--- /dev/null
+++ b/frontend/src/metadata/views/gallery/context-menu/index.css
@@ -0,0 +1,6 @@
+.sf-metadata-contextmenu {
+ display: block;
+ opacity: 1;
+ box-shadow: 0 0 5px #ccc;
+ position: fixed;
+}
diff --git a/frontend/src/metadata/views/gallery/context-menu/index.js b/frontend/src/metadata/views/gallery/context-menu/index.js
new file mode 100644
index 0000000000..6660ca6448
--- /dev/null
+++ b/frontend/src/metadata/views/gallery/context-menu/index.js
@@ -0,0 +1,139 @@
+import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
+import PropTypes from 'prop-types';
+import { gettext } from '../../../../utils/constants';
+import './index.css';
+
+const OPERATION = {
+ DOWNLOAD: 'download',
+ DELETE: 'delete',
+};
+
+const ContextMenu = ({ getContentRect, getContainerRect, onDownload, onDelete }) => {
+ const menuRef = useRef(null);
+ const [visible, setVisible] = useState(false);
+ const [position, setPosition] = useState({ top: 0, left: 0 });
+
+ const options = useMemo(() => {
+ if (!visible) return [];
+ return [
+ { value: OPERATION.DOWNLOAD, label: gettext('Download') },
+ { value: OPERATION.DELETE, label: gettext('Delete') }
+ ];
+ }, [visible]);
+
+ const handleHide = useCallback((event) => {
+ if (menuRef.current && !menuRef.current.contains(event.target)) {
+ setVisible(false);
+ }
+ }, [menuRef]);
+
+ const handleOptionClick = useCallback((event, option) => {
+ event.stopPropagation();
+ switch (option.value) {
+ case OPERATION.DOWNLOAD: {
+ onDownload && onDownload();
+ break;
+ }
+ case OPERATION.DELETE: {
+ onDelete && onDelete();
+ break;
+ }
+ default: {
+ break;
+ }
+ }
+ setVisible(false);
+ }, [onDownload, onDelete]);
+
+ const getMenuPosition = useCallback((x = 0, y = 0) => {
+ let menuStyles = {
+ top: y,
+ left: x
+ };
+ if (!menuRef.current) return menuStyles;
+ const rect = menuRef.current.getBoundingClientRect();
+ const containerRect = getContainerRect();
+ const { right: innerWidth, bottom: innerHeight } = getContentRect();
+ menuStyles.top = menuStyles.top - containerRect.top;
+ menuStyles.left = menuStyles.left - containerRect.left;
+
+ if (y + rect.height > innerHeight - 10) {
+ menuStyles.top -= rect.height;
+ }
+ if (x + rect.width > innerWidth) {
+ menuStyles.left -= rect.width;
+ }
+ if (menuStyles.top < 0) {
+ menuStyles.top = rect.bottom > innerHeight ? (innerHeight - 10 - rect.height) / 2 : 0;
+ }
+ if (menuStyles.left < 0) {
+ menuStyles.left = rect.width < innerWidth ? (innerWidth - rect.width) / 2 : 0;
+ }
+ return menuStyles;
+ }, [getContentRect, getContainerRect]);
+
+ useEffect(() => {
+ const handleShow = (event) => {
+ event.preventDefault();
+ if (menuRef.current && menuRef.current.contains(event.target)) return;
+
+ if (event.target.tagName.toLowerCase() !== 'img') {
+ return;
+ }
+
+ setVisible(true);
+
+ const position = getMenuPosition(event.clientX, event.clientY);
+ setPosition(position);
+ };
+
+ document.addEventListener('contextmenu', handleShow);
+
+ return () => {
+ document.removeEventListener('contextmenu', handleShow);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ if (visible) {
+ document.addEventListener('mousedown', handleHide);
+ } else {
+ document.removeEventListener('mousedown', handleHide);
+ }
+
+ return () => {
+ document.removeEventListener('mousedown', handleHide);
+ };
+ }, [visible, handleHide]);
+
+ if (!visible) return null;
+ if (options.length === 0) return null;
+
+ return (
+
{childrenStartIndex === 0 && (
{name}
)}
{
paddingBottom: (children.length - 1 - childrenEndIndex) * imageHeight,
}}
>
- {children.slice(childrenStartIndex, childrenEndIndex + 1).map((row) => {
- return row.children.map(img => {
+ {children.slice(childrenStartIndex, childrenEndIndex + 1).map((row, rowIndex) => {
+ return row.children.map((img) => {
+ const isSelected = selectedImages.includes(img);
return (
-
+
onImageClick(e, img)}
+ onDoubleClick={(e) => onImageDoubleClick(e, img)}
+ onContextMenu={(e) => onImageRightClick(e, img)}
+ >
);
@@ -55,7 +70,7 @@ const GalleryMain = ({ groups, overScan, columns, size, gap }) => {
);
- }, [overScan, columns, size, imageHeight]);
+ }, [overScan, columns, size, imageHeight, selectedImages, onImageClick, onImageDoubleClick, onImageRightClick]);
if (!Array.isArray(groups) || groups.length === 0) {
return
;
@@ -87,6 +102,10 @@ GalleryMain.propTypes = {
columns: PropTypes.number.isRequired,
size: PropTypes.number.isRequired,
gap: PropTypes.number.isRequired,
+ selectedImages: PropTypes.array.isRequired,
+ onImageClick: PropTypes.func.isRequired,
+ onImageDoubleClick: PropTypes.func.isRequired,
+ onImageRightClick: PropTypes.func.isRequired,
};
export default GalleryMain;
diff --git a/frontend/src/metadata/views/gallery/index.css b/frontend/src/metadata/views/gallery/index.css
index d47e2eb49f..0cf8109d90 100644
--- a/frontend/src/metadata/views/gallery/index.css
+++ b/frontend/src/metadata/views/gallery/index.css
@@ -43,7 +43,7 @@
overflow: hidden;
}
-.metadata-gallery-image-item:focus {
+.metadata-gallery-image-item-selected {
border: 2px solid #ff8000;
}
diff --git a/frontend/src/metadata/views/gallery/index.js b/frontend/src/metadata/views/gallery/index.js
index 051d15f965..9ad007c01e 100644
--- a/frontend/src/metadata/views/gallery/index.js
+++ b/frontend/src/metadata/views/gallery/index.js
@@ -1,11 +1,17 @@
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { CenteredLoading } from '@seafile/sf-metadata-ui-component';
+import metadataAPI from '../../api';
+import URLDecorator from '../../../utils/url-decorator';
import toaster from '../../../components/toast';
import GalleryMain from './gallery-main';
+import ContextMenu from './context-menu';
+import ImageDialog from '../../../components/dialog/image-dialog';
+import ZipDownloadDialog from '../../../components/dialog/zip-download-dialog';
+import ModalPortal from '../../../components/modal-portal';
import { useMetadataView } from '../../hooks/metadata-view';
import { Utils } from '../../../utils/utils';
import { getDateDisplayString } from '../../utils/cell';
-import { siteRoot, thumbnailSizeForGrid } from '../../../utils/constants';
+import { siteRoot, thumbnailSizeForGrid, fileServerRoot, useGoFileserver, gettext } from '../../../utils/constants';
import { EVENT_BUS_TYPE, PER_LOAD_NUMBER, PRIVATE_COLUMN_KEY, GALLERY_DATE_MODE, DATE_TAG_HEIGHT } from '../../constants';
import './index.css';
@@ -19,6 +25,11 @@ const Gallery = () => {
const [containerWidth, setContainerWidth] = useState(0);
const [overScan, setOverScan] = useState({ top: 0, bottom: 0 });
const [mode, setMode] = useState(GALLERY_DATE_MODE.DAY);
+ const [isImagePopupOpen, setIsImagePopupOpen] = useState(false);
+ const [isZipDialogOpen, setIsZipDialogOpen] = useState(false);
+ const [imageIndex, setImageIndex] = useState(0);
+ const [selectedImages, setSelectedImages] = useState([]);
+ const [groups, setGroups] = useState([]);
const containerRef = useRef(null);
const renderMoreTimer = useRef(null);
@@ -48,18 +59,20 @@ const Gallery = () => {
}
}, [mode]);
- const groups = useMemo(() => {
+ const calculateGroups = useMemo(() => {
if (isFirstLoading) return [];
const firstSort = metadata.view.sorts[0];
- let init = metadata.rows
- .filter(row => Utils.imageCheck(row[PRIVATE_COLUMN_KEY.FILE_NAME]))
+ let init = metadata.rows.filter(row => Utils.imageCheck(row[PRIVATE_COLUMN_KEY.FILE_NAME]))
.reduce((_init, record) => {
+ const id = record[PRIVATE_COLUMN_KEY.ID];
const fileName = record[PRIVATE_COLUMN_KEY.FILE_NAME];
const parentDir = record[PRIVATE_COLUMN_KEY.PARENT_DIR];
const path = Utils.encodePath(Utils.joinPath(parentDir, fileName));
const date = mode !== GALLERY_DATE_MODE.ALL ? getDateDisplayString(record[firstSort.column_key], dateMode) : '';
const img = {
+ id,
name: fileName,
+ path: parentDir,
url: `${siteRoot}lib/${repoID}/file${path}`,
src: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForGrid}${path}`,
date: date,
@@ -107,6 +120,10 @@ const Gallery = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFirstLoading, metadata, metadata.recordsCount, repoID, columns, imageSize, mode]);
+ useEffect(() => {
+ setGroups(calculateGroups);
+ }, [calculateGroups]);
+
const loadMore = useCallback(async () => {
if (isLoadingMore) return;
if (!metadata.hasMore) return;
@@ -173,6 +190,18 @@ const Gallery = () => {
};
}, []);
+ useEffect(() => {
+ const handleClickOutside = (e) => {
+ if (containerRef.current && !containerRef.current.contains(e.target) || e.target.tagName.toLowerCase() !== 'img') {
+ setSelectedImages([]);
+ }
+ };
+ document.addEventListener('click', handleClickOutside);
+ return () => {
+ document.removeEventListener('click', handleClickOutside);
+ };
+ }, []);
+
const handleScroll = useCallback(() => {
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
@@ -190,12 +219,134 @@ const Gallery = () => {
}
}, [imageSize, loadMore, renderMoreTimer]);
+ const imageItems = useMemo(() => {
+ return groups.flatMap(group => group.children.flatMap(row => row.children));
+ }, [groups]);
+
+ const handleClick = useCallback((event, image) => {
+ if (event.metaKey || event.ctrlKey) {
+ setSelectedImages(prev =>
+ prev.includes(image) ? prev.filter(img => img !== image) : [...prev, image]
+ );
+ } else if (event.shiftKey && selectedImages.length > 0) {
+ const lastSelected = selectedImages[selectedImages.length - 1];
+ const start = imageItems.indexOf(lastSelected);
+ const end = imageItems.indexOf(image);
+ const range = imageItems.slice(Math.min(start, end), Math.max(start, end) + 1);
+ setSelectedImages(prev => Array.from(new Set([...prev, ...range])));
+ } else {
+ setSelectedImages([image]);
+ }
+ }, [imageItems, selectedImages]);
+
+ const handleDoubleClick = useCallback((event, image) => {
+ const index = imageItems.findIndex(item => item.id === image.id);
+ setImageIndex(index);
+ setIsImagePopupOpen(true);
+ }, [imageItems]);
+
+
+ const handleRightClick = useCallback((event, image) => {
+ event.preventDefault();
+ const index = imageItems.findIndex(item => item.id === image.id);
+ if (isNaN(index) || index === -1) return;
+
+ setSelectedImages(prev => prev.length < 2 ? [image] : [...prev]);
+ }, [imageItems]);
+
+ const moveToPrevImage = () => {
+ const imageItemsLength = imageItems.length;
+ setImageIndex((prevState) => (prevState + imageItemsLength - 1) % imageItemsLength);
+ };
+
+ const moveToNextImage = () => {
+ const imageItemsLength = imageItems.length;
+ setImageIndex((prevState) => (prevState + 1) % imageItemsLength);
+ };
+
+
+ const closeImagePopup = () => {
+ setIsImagePopupOpen(false);
+ };
+
+ const handleDownload = useCallback(() => {
+ if (selectedImages.length) {
+ if (selectedImages.length === 1) {
+ const image = selectedImages[0];
+ let direntPath = image.path === '/' ? image.name : Utils.joinPath(image.path, image.name);
+ let url = URLDecorator.getUrl({ type: 'download_file_url', repoID: repoID, filePath: direntPath });
+ location.href = url;
+ } else {
+ if (!useGoFileserver) {
+ setIsZipDialogOpen(true);
+ } else {
+ const dirents = selectedImages.map(image => {
+ const value = image.path === '/' ? image.name : `${image.path}/${image.name}`;
+ return value;
+ });
+ metadataAPI.zipDownload(repoID, '/', dirents).then((res) => {
+ const zipToken = res.data['zip_token'];
+ location.href = `${fileServerRoot}zip/${zipToken}`;
+ }).catch(error => {
+ const errMessage = Utils.getErrorMsg(error);
+ toaster.danger(errMessage);
+ });
+ }
+ }
+
+ }
+ }, [repoID, selectedImages]);
+
+ const handleDelete = () => {
+ if (selectedImages.length) {
+ const imagesToDelete = selectedImages.map(image => image.name);
+
+ setGroups(prevGroups => prevGroups.map(group => ({
+ ...group,
+ children: group.children.map(row => ({
+ ...row,
+ children: row.children.filter(img => !imagesToDelete.includes(img.name))
+ })).filter(row => row.children.length > 0)
+ })).filter(group => group.children.length > 0));
+
+ setSelectedImages([]);
+
+ metadataAPI.deleteImages(repoID, selectedImages.map(image => image.path === '/' ? image.name : `${image.path}/${image.name}`))
+ .then(() => {
+ setSelectedImages([]);
+ let msg = selectedImages.length > 1
+ ? gettext('Successfully deleted {n} images.')
+ : gettext('Successfully deleted {name}');
+ msg = msg.replace('{name}', selectedImages[0].name)
+ .replace('{n}', selectedImages.length);
+ toaster.success(msg, { duration: 3 });
+ }).catch(error => {
+ const errMessage = Utils.getErrorMsg(error);
+ toaster.danger(errMessage);
+ });
+ }
+ };
+
+ const closeZipDialog = () => {
+ setIsZipDialogOpen(false);
+ };
+
return (
{!isFirstLoading && (
<>
-
+
{isLoadingMore &&
@@ -204,6 +355,33 @@ const Gallery = () => {
>
)}
+
containerRef.current.getBoundingClientRect()}
+ getContainerRect={() => containerRef.current.getBoundingClientRect()}
+ onDownload={handleDownload}
+ onDelete={handleDelete}
+ />
+ {isImagePopupOpen &&
+
+
+
+ }
+ {isZipDialogOpen &&
+
+ image.path === '/' ? image.name : `${image.path}/${image.name}`)}
+ toggleDialog={closeZipDialog}
+ />
+
+ }
);
};
diff --git a/frontend/src/metadata/views/table/context-menu/index.js b/frontend/src/metadata/views/table/context-menu/index.js
index e4ef7f04b3..903644c95d 100644
--- a/frontend/src/metadata/views/table/context-menu/index.js
+++ b/frontend/src/metadata/views/table/context-menu/index.js
@@ -6,7 +6,7 @@ import { gettext, siteRoot } from '../../../../utils/constants';
import { Utils } from '../../../../utils/utils';
import { useMetadataView } from '../../../hooks/metadata-view';
import { PRIVATE_COLUMN_KEY } from '../../../constants';
-
+import { VIEW_TYPE } from '../../../constants/view';
import './index.css';
const OPERATION = {
@@ -16,6 +16,8 @@ const OPERATION = {
OPEN_IN_NEW_TAB: 'open-new-tab',
GENERATE_DESCRIPTION: 'generate-description',
IMAGE_CAPTION: 'image-caption',
+ DOWNLOAD: 'download',
+ DELETE: 'delete',
};
const ContextMenu = ({
@@ -29,6 +31,8 @@ const ContextMenu = ({
updateRecords,
getTableContentRect,
getTableCanvasContainerRect,
+ onDownload,
+ onDelete,
}) => {
const menuRef = useRef(null);
const [visible, setVisible] = useState(false);
@@ -44,6 +48,11 @@ const ContextMenu = ({
const canModifyRow = window.sfMetadataContext.canModifyRow;
let list = [];
+ if (metadata.view.type === VIEW_TYPE.GALLERY) {
+ list.push({ value: OPERATION.DOWNLOAD, label: gettext('Download') });
+ list.push({ value: OPERATION.DELETE, label: gettext('Delete') });
+ }
+
if (selectedRange) {
!isReadonly && list.push({ value: OPERATION.CLEAR_SELECTED, label: gettext('Clear selected') });
list.push({ value: OPERATION.COPY_SELECTED, label: gettext('Copy selected') });
@@ -62,7 +71,7 @@ const ContextMenu = ({
return list;
}
- const selectedRecords = Object.keys(recordMetrics.idSelectedRecordMap);
+ const selectedRecords = recordMetrics ? Object.keys(recordMetrics.idSelectedRecordMap) : [];
if (selectedRecords.length > 1) {
if (descriptionColumn) {
const isIncludeSdocRecord = selectedRecords.filter(id => {
@@ -229,12 +238,20 @@ const ContextMenu = ({
imageCaption && imageCaption();
break;
}
+ case OPERATION.DOWNLOAD: {
+ onDownload && onDownload();
+ break;
+ }
+ case OPERATION.DELETE: {
+ onDelete && onDelete();
+ break;
+ }
default: {
break;
}
}
setVisible(false);
- }, [onOpenFileInNewTab, onOpenParentFolder, onCopySelected, onClearSelected, generateDescription, imageCaption]);
+ }, [onOpenFileInNewTab, onOpenParentFolder, onCopySelected, onClearSelected, generateDescription, imageCaption, onDownload, onDelete]);
const getMenuPosition = useCallback((x = 0, y = 0) => {
let menuStyles = {