From adf6591e59c09f08ab3d051b82ed819669d3cfe5 Mon Sep 17 00:00:00 2001 From: Aries Date: Sat, 21 Sep 2024 16:41:21 +0800 Subject: [PATCH] Feature/improve gallery interactivity (#6701) * zip download images in gallery * delete images in gallery * clean up code * update gallery context menu * fix bug - select failed when gallery have several groups * change function name --------- Co-authored-by: Michael An <2331806369@qq.com> --- .../src/components/dialog/image-dialog.js | 14 +- frontend/src/metadata/api.js | 26 +++ .../views/gallery/context-menu/index.css | 6 + .../views/gallery/context-menu/index.js | 139 +++++++++++++ .../metadata/views/gallery/gallery-main.js | 33 ++- frontend/src/metadata/views/gallery/index.css | 2 +- frontend/src/metadata/views/gallery/index.js | 188 +++++++++++++++++- .../views/table/context-menu/index.js | 23 ++- 8 files changed, 408 insertions(+), 23 deletions(-) create mode 100644 frontend/src/metadata/views/gallery/context-menu/index.css create mode 100644 frontend/src/metadata/views/gallery/context-menu/index.js 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 ( +
+ {options.map((option, index) => ( + + ))} +
+ ); +}; + +ContextMenu.propTypes = { + getContentRect: PropTypes.func.isRequired, + getContainerRect: PropTypes.func.isRequired, + onDownload: PropTypes.func, + onDelete: PropTypes.func, +}; + +export default ContextMenu; diff --git a/frontend/src/metadata/views/gallery/gallery-main.js b/frontend/src/metadata/views/gallery/gallery-main.js index b3d3d84425..b76f54de65 100644 --- a/frontend/src/metadata/views/gallery/gallery-main.js +++ b/frontend/src/metadata/views/gallery/gallery-main.js @@ -1,12 +1,15 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; +import classnames from 'classnames'; import PropTypes from 'prop-types'; import EmptyTip from '../../../components/empty-tip'; import { gettext } from '../../../utils/constants'; -const GalleryMain = ({ groups, overScan, columns, size, gap }) => { +const GalleryMain = ({ groups, overScan, columns, size, gap, selectedImages, onImageClick, onImageDoubleClick, onImageRightClick }) => { + const imageRef = useRef(null); + const imageHeight = useMemo(() => size + gap, [size, gap]); - const renderDisplayGroup = useCallback((group) => { + const renderDisplayGroup = useCallback((group, groupIndex) => { const { top: overScanTop, bottom: overScanBottom } = overScan; const { name, children, height, top, paddingTop } = group; @@ -36,6 +39,7 @@ const GalleryMain = ({ groups, overScan, columns, size, gap }) => {
{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)} + > {img.name}
); @@ -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 = {