mirror of
https://github.com/haiwen/seahub.git
synced 2025-09-03 16:10:26 +00:00
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>
This commit is contained in:
@@ -42,8 +42,8 @@ class ImageDialog extends React.Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const imageItems = this.props.imageItems;
|
const { imageItems, imageIndex, closeImagePopup, moveToPrevImage, moveToNextImage, onDeleteImage } = this.props;
|
||||||
const imageIndex = this.props.imageIndex;
|
|
||||||
const imageItemsLength = imageItems.length;
|
const imageItemsLength = imageItems.length;
|
||||||
const name = imageItems[imageIndex].name;
|
const name = imageItems[imageIndex].name;
|
||||||
const imageTitle = `${name} (${imageIndex + 1}/${imageItemsLength})`;
|
const imageTitle = `${name} (${imageIndex + 1}/${imageItemsLength})`;
|
||||||
@@ -61,9 +61,9 @@ class ImageDialog extends React.Component {
|
|||||||
mainSrc={mainSrc}
|
mainSrc={mainSrc}
|
||||||
nextSrc={nextSrc}
|
nextSrc={nextSrc}
|
||||||
prevSrc={prevSrc}
|
prevSrc={prevSrc}
|
||||||
onCloseRequest={this.props.closeImagePopup}
|
onCloseRequest={closeImagePopup}
|
||||||
onMovePrevRequest={this.props.moveToPrevImage}
|
onMovePrevRequest={moveToPrevImage}
|
||||||
onMoveNextRequest={this.props.moveToNextImage}
|
onMoveNextRequest={moveToNextImage}
|
||||||
imagePadding={70}
|
imagePadding={70}
|
||||||
imageLoadErrorMessage={gettext('The image could not be loaded.')}
|
imageLoadErrorMessage={gettext('The image could not be loaded.')}
|
||||||
prevLabel={gettext('Previous (Left arrow key)')}
|
prevLabel={gettext('Previous (Left arrow key)')}
|
||||||
@@ -72,8 +72,8 @@ class ImageDialog extends React.Component {
|
|||||||
zoomInLabel={gettext('Zoom in')}
|
zoomInLabel={gettext('Zoom in')}
|
||||||
zoomOutLabel={gettext('Zoom out')}
|
zoomOutLabel={gettext('Zoom out')}
|
||||||
enableRotate={true}
|
enableRotate={true}
|
||||||
onClickDownload={() => this.downloadImage(imageItems[imageIndex].downloadURL)}
|
onClickDownload={() => this.downloadImage(imageItems[imageIndex].url)}
|
||||||
onClickDelete={this.props.onDeleteImage ? () => this.props.onDeleteImage(imageItems[imageIndex].name) : null}
|
onClickDelete={onDeleteImage ? () => onDeleteImage(imageItems[imageIndex].name) : null}
|
||||||
onViewOriginal={this.onViewOriginal}
|
onViewOriginal={this.onViewOriginal}
|
||||||
viewOriginalImageLabel={gettext('View original image')}
|
viewOriginalImageLabel={gettext('View original image')}
|
||||||
onRotateImage={this.onRotateImage}
|
onRotateImage={this.onRotateImage}
|
||||||
|
@@ -221,6 +221,32 @@ class MetadataManagerAPI {
|
|||||||
};
|
};
|
||||||
return this.req.post(url, params);
|
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();
|
const metadataAPI = new MetadataManagerAPI();
|
||||||
|
@@ -0,0 +1,6 @@
|
|||||||
|
.sf-metadata-contextmenu {
|
||||||
|
display: block;
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 0 5px #ccc;
|
||||||
|
position: fixed;
|
||||||
|
}
|
139
frontend/src/metadata/views/gallery/context-menu/index.js
Normal file
139
frontend/src/metadata/views/gallery/context-menu/index.js
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className='dropdown-menu sf-metadata-contextmenu'
|
||||||
|
style={position}
|
||||||
|
>
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
className='dropdown-item sf-metadata-contextmenu-item'
|
||||||
|
onClick={(event) => handleOptionClick(event, option)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ContextMenu.propTypes = {
|
||||||
|
getContentRect: PropTypes.func.isRequired,
|
||||||
|
getContainerRect: PropTypes.func.isRequired,
|
||||||
|
onDownload: PropTypes.func,
|
||||||
|
onDelete: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContextMenu;
|
@@ -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 PropTypes from 'prop-types';
|
||||||
import EmptyTip from '../../../components/empty-tip';
|
import EmptyTip from '../../../components/empty-tip';
|
||||||
import { gettext } from '../../../utils/constants';
|
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 imageHeight = useMemo(() => size + gap, [size, gap]);
|
||||||
|
|
||||||
const renderDisplayGroup = useCallback((group) => {
|
const renderDisplayGroup = useCallback((group, groupIndex) => {
|
||||||
const { top: overScanTop, bottom: overScanBottom } = overScan;
|
const { top: overScanTop, bottom: overScanBottom } = overScan;
|
||||||
const { name, children, height, top, paddingTop } = group;
|
const { name, children, height, top, paddingTop } = group;
|
||||||
|
|
||||||
@@ -36,6 +39,7 @@ const GalleryMain = ({ groups, overScan, columns, size, gap }) => {
|
|||||||
<div key={name} className="metadata-gallery-date-group w-100" style={{ height, paddingTop }}>
|
<div key={name} className="metadata-gallery-date-group w-100" style={{ height, paddingTop }}>
|
||||||
{childrenStartIndex === 0 && (<div className="metadata-gallery-date-tag">{name}</div>)}
|
{childrenStartIndex === 0 && (<div className="metadata-gallery-date-tag">{name}</div>)}
|
||||||
<div
|
<div
|
||||||
|
ref={imageRef}
|
||||||
className="metadata-gallery-image-list"
|
className="metadata-gallery-image-list"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||||
@@ -43,10 +47,21 @@ const GalleryMain = ({ groups, overScan, columns, size, gap }) => {
|
|||||||
paddingBottom: (children.length - 1 - childrenEndIndex) * imageHeight,
|
paddingBottom: (children.length - 1 - childrenEndIndex) * imageHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children.slice(childrenStartIndex, childrenEndIndex + 1).map((row) => {
|
{children.slice(childrenStartIndex, childrenEndIndex + 1).map((row, rowIndex) => {
|
||||||
return row.children.map(img => {
|
return row.children.map((img) => {
|
||||||
|
const isSelected = selectedImages.includes(img);
|
||||||
return (
|
return (
|
||||||
<div key={img.src} tabIndex={1} className="metadata-gallery-image-item" style={{ width: size, height: size, background: '#f1f1f1' }}>
|
<div
|
||||||
|
key={img.src}
|
||||||
|
tabIndex={1}
|
||||||
|
className={classnames('metadata-gallery-image-item', {
|
||||||
|
'metadata-gallery-image-item-selected': isSelected,
|
||||||
|
})}
|
||||||
|
style={{ width: size, height: size, background: '#f1f1f1' }}
|
||||||
|
onClick={(e) => onImageClick(e, img)}
|
||||||
|
onDoubleClick={(e) => onImageDoubleClick(e, img)}
|
||||||
|
onContextMenu={(e) => onImageRightClick(e, img)}
|
||||||
|
>
|
||||||
<img className="metadata-gallery-grid-image" src={img.src} alt={img.name} />
|
<img className="metadata-gallery-grid-image" src={img.src} alt={img.name} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -55,7 +70,7 @@ const GalleryMain = ({ groups, overScan, columns, size, gap }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [overScan, columns, size, imageHeight]);
|
}, [overScan, columns, size, imageHeight, selectedImages, onImageClick, onImageDoubleClick, onImageRightClick]);
|
||||||
|
|
||||||
if (!Array.isArray(groups) || groups.length === 0) {
|
if (!Array.isArray(groups) || groups.length === 0) {
|
||||||
return <EmptyTip text={gettext('No record')}/>;
|
return <EmptyTip text={gettext('No record')}/>;
|
||||||
@@ -87,6 +102,10 @@ GalleryMain.propTypes = {
|
|||||||
columns: PropTypes.number.isRequired,
|
columns: PropTypes.number.isRequired,
|
||||||
size: PropTypes.number.isRequired,
|
size: PropTypes.number.isRequired,
|
||||||
gap: 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;
|
export default GalleryMain;
|
||||||
|
@@ -43,7 +43,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-gallery-image-item:focus {
|
.metadata-gallery-image-item-selected {
|
||||||
border: 2px solid #ff8000;
|
border: 2px solid #ff8000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,11 +1,17 @@
|
|||||||
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { CenteredLoading } from '@seafile/sf-metadata-ui-component';
|
import { CenteredLoading } from '@seafile/sf-metadata-ui-component';
|
||||||
|
import metadataAPI from '../../api';
|
||||||
|
import URLDecorator from '../../../utils/url-decorator';
|
||||||
import toaster from '../../../components/toast';
|
import toaster from '../../../components/toast';
|
||||||
import GalleryMain from './gallery-main';
|
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 { useMetadataView } from '../../hooks/metadata-view';
|
||||||
import { Utils } from '../../../utils/utils';
|
import { Utils } from '../../../utils/utils';
|
||||||
import { getDateDisplayString } from '../../utils/cell';
|
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 { EVENT_BUS_TYPE, PER_LOAD_NUMBER, PRIVATE_COLUMN_KEY, GALLERY_DATE_MODE, DATE_TAG_HEIGHT } from '../../constants';
|
||||||
|
|
||||||
import './index.css';
|
import './index.css';
|
||||||
@@ -19,6 +25,11 @@ const Gallery = () => {
|
|||||||
const [containerWidth, setContainerWidth] = useState(0);
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
const [overScan, setOverScan] = useState({ top: 0, bottom: 0 });
|
const [overScan, setOverScan] = useState({ top: 0, bottom: 0 });
|
||||||
const [mode, setMode] = useState(GALLERY_DATE_MODE.DAY);
|
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 containerRef = useRef(null);
|
||||||
const renderMoreTimer = useRef(null);
|
const renderMoreTimer = useRef(null);
|
||||||
@@ -48,18 +59,20 @@ const Gallery = () => {
|
|||||||
}
|
}
|
||||||
}, [mode]);
|
}, [mode]);
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
const calculateGroups = useMemo(() => {
|
||||||
if (isFirstLoading) return [];
|
if (isFirstLoading) return [];
|
||||||
const firstSort = metadata.view.sorts[0];
|
const firstSort = metadata.view.sorts[0];
|
||||||
let init = metadata.rows
|
let init = metadata.rows.filter(row => Utils.imageCheck(row[PRIVATE_COLUMN_KEY.FILE_NAME]))
|
||||||
.filter(row => Utils.imageCheck(row[PRIVATE_COLUMN_KEY.FILE_NAME]))
|
|
||||||
.reduce((_init, record) => {
|
.reduce((_init, record) => {
|
||||||
|
const id = record[PRIVATE_COLUMN_KEY.ID];
|
||||||
const fileName = record[PRIVATE_COLUMN_KEY.FILE_NAME];
|
const fileName = record[PRIVATE_COLUMN_KEY.FILE_NAME];
|
||||||
const parentDir = record[PRIVATE_COLUMN_KEY.PARENT_DIR];
|
const parentDir = record[PRIVATE_COLUMN_KEY.PARENT_DIR];
|
||||||
const path = Utils.encodePath(Utils.joinPath(parentDir, fileName));
|
const path = Utils.encodePath(Utils.joinPath(parentDir, fileName));
|
||||||
const date = mode !== GALLERY_DATE_MODE.ALL ? getDateDisplayString(record[firstSort.column_key], dateMode) : '';
|
const date = mode !== GALLERY_DATE_MODE.ALL ? getDateDisplayString(record[firstSort.column_key], dateMode) : '';
|
||||||
const img = {
|
const img = {
|
||||||
|
id,
|
||||||
name: fileName,
|
name: fileName,
|
||||||
|
path: parentDir,
|
||||||
url: `${siteRoot}lib/${repoID}/file${path}`,
|
url: `${siteRoot}lib/${repoID}/file${path}`,
|
||||||
src: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForGrid}${path}`,
|
src: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForGrid}${path}`,
|
||||||
date: date,
|
date: date,
|
||||||
@@ -107,6 +120,10 @@ const Gallery = () => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isFirstLoading, metadata, metadata.recordsCount, repoID, columns, imageSize, mode]);
|
}, [isFirstLoading, metadata, metadata.recordsCount, repoID, columns, imageSize, mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setGroups(calculateGroups);
|
||||||
|
}, [calculateGroups]);
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
if (isLoadingMore) return;
|
if (isLoadingMore) return;
|
||||||
if (!metadata.hasMore) 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(() => {
|
const handleScroll = useCallback(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||||
@@ -190,12 +219,134 @@ const Gallery = () => {
|
|||||||
}
|
}
|
||||||
}, [imageSize, loadMore, renderMoreTimer]);
|
}, [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 (
|
return (
|
||||||
<div className="sf-metadata-container">
|
<div className="sf-metadata-container">
|
||||||
<div className="sf-metadata-gallery-container" ref={containerRef} onScroll={handleScroll} >
|
<div className="sf-metadata-gallery-container" ref={containerRef} onScroll={handleScroll} >
|
||||||
{!isFirstLoading && (
|
{!isFirstLoading && (
|
||||||
<>
|
<>
|
||||||
<GalleryMain groups={groups} size={imageSize} columns={columns} overScan={overScan} gap={IMAGE_GAP} />
|
<GalleryMain
|
||||||
|
groups={groups}
|
||||||
|
size={imageSize}
|
||||||
|
columns={columns}
|
||||||
|
overScan={overScan}
|
||||||
|
gap={IMAGE_GAP}
|
||||||
|
selectedImages={selectedImages}
|
||||||
|
onImageClick={handleClick}
|
||||||
|
onImageDoubleClick={handleDoubleClick}
|
||||||
|
onImageRightClick={handleRightClick}
|
||||||
|
/>
|
||||||
{isLoadingMore &&
|
{isLoadingMore &&
|
||||||
<div className="sf-metadata-gallery-loading-more">
|
<div className="sf-metadata-gallery-loading-more">
|
||||||
<CenteredLoading />
|
<CenteredLoading />
|
||||||
@@ -204,6 +355,33 @@ const Gallery = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ContextMenu
|
||||||
|
getContentRect={() => containerRef.current.getBoundingClientRect()}
|
||||||
|
getContainerRect={() => containerRef.current.getBoundingClientRect()}
|
||||||
|
onDownload={handleDownload}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
{isImagePopupOpen &&
|
||||||
|
<ModalPortal>
|
||||||
|
<ImageDialog
|
||||||
|
imageItems={imageItems}
|
||||||
|
imageIndex={imageIndex}
|
||||||
|
closeImagePopup={closeImagePopup}
|
||||||
|
moveToPrevImage={moveToPrevImage}
|
||||||
|
moveToNextImage={moveToNextImage}
|
||||||
|
/>
|
||||||
|
</ModalPortal>
|
||||||
|
}
|
||||||
|
{isZipDialogOpen &&
|
||||||
|
<ModalPortal>
|
||||||
|
<ZipDownloadDialog
|
||||||
|
repoID={repoID}
|
||||||
|
path={'/'}
|
||||||
|
target={selectedImages.map(image => image.path === '/' ? image.name : `${image.path}/${image.name}`)}
|
||||||
|
toggleDialog={closeZipDialog}
|
||||||
|
/>
|
||||||
|
</ModalPortal>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -6,7 +6,7 @@ import { gettext, siteRoot } from '../../../../utils/constants';
|
|||||||
import { Utils } from '../../../../utils/utils';
|
import { Utils } from '../../../../utils/utils';
|
||||||
import { useMetadataView } from '../../../hooks/metadata-view';
|
import { useMetadataView } from '../../../hooks/metadata-view';
|
||||||
import { PRIVATE_COLUMN_KEY } from '../../../constants';
|
import { PRIVATE_COLUMN_KEY } from '../../../constants';
|
||||||
|
import { VIEW_TYPE } from '../../../constants/view';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
const OPERATION = {
|
const OPERATION = {
|
||||||
@@ -16,6 +16,8 @@ const OPERATION = {
|
|||||||
OPEN_IN_NEW_TAB: 'open-new-tab',
|
OPEN_IN_NEW_TAB: 'open-new-tab',
|
||||||
GENERATE_DESCRIPTION: 'generate-description',
|
GENERATE_DESCRIPTION: 'generate-description',
|
||||||
IMAGE_CAPTION: 'image-caption',
|
IMAGE_CAPTION: 'image-caption',
|
||||||
|
DOWNLOAD: 'download',
|
||||||
|
DELETE: 'delete',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContextMenu = ({
|
const ContextMenu = ({
|
||||||
@@ -29,6 +31,8 @@ const ContextMenu = ({
|
|||||||
updateRecords,
|
updateRecords,
|
||||||
getTableContentRect,
|
getTableContentRect,
|
||||||
getTableCanvasContainerRect,
|
getTableCanvasContainerRect,
|
||||||
|
onDownload,
|
||||||
|
onDelete,
|
||||||
}) => {
|
}) => {
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
@@ -44,6 +48,11 @@ const ContextMenu = ({
|
|||||||
const canModifyRow = window.sfMetadataContext.canModifyRow;
|
const canModifyRow = window.sfMetadataContext.canModifyRow;
|
||||||
let list = [];
|
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) {
|
if (selectedRange) {
|
||||||
!isReadonly && list.push({ value: OPERATION.CLEAR_SELECTED, label: gettext('Clear selected') });
|
!isReadonly && list.push({ value: OPERATION.CLEAR_SELECTED, label: gettext('Clear selected') });
|
||||||
list.push({ value: OPERATION.COPY_SELECTED, label: gettext('Copy selected') });
|
list.push({ value: OPERATION.COPY_SELECTED, label: gettext('Copy selected') });
|
||||||
@@ -62,7 +71,7 @@ const ContextMenu = ({
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedRecords = Object.keys(recordMetrics.idSelectedRecordMap);
|
const selectedRecords = recordMetrics ? Object.keys(recordMetrics.idSelectedRecordMap) : [];
|
||||||
if (selectedRecords.length > 1) {
|
if (selectedRecords.length > 1) {
|
||||||
if (descriptionColumn) {
|
if (descriptionColumn) {
|
||||||
const isIncludeSdocRecord = selectedRecords.filter(id => {
|
const isIncludeSdocRecord = selectedRecords.filter(id => {
|
||||||
@@ -229,12 +238,20 @@ const ContextMenu = ({
|
|||||||
imageCaption && imageCaption();
|
imageCaption && imageCaption();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case OPERATION.DOWNLOAD: {
|
||||||
|
onDownload && onDownload();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case OPERATION.DELETE: {
|
||||||
|
onDelete && onDelete();
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
}, [onOpenFileInNewTab, onOpenParentFolder, onCopySelected, onClearSelected, generateDescription, imageCaption]);
|
}, [onOpenFileInNewTab, onOpenParentFolder, onCopySelected, onClearSelected, generateDescription, imageCaption, onDownload, onDelete]);
|
||||||
|
|
||||||
const getMenuPosition = useCallback((x = 0, y = 0) => {
|
const getMenuPosition = useCallback((x = 0, y = 0) => {
|
||||||
let menuStyles = {
|
let menuStyles = {
|
||||||
|
Reference in New Issue
Block a user