1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-26 23:34:45 +00:00

Gallery image selection (#8144)

* [metadata] 'Gallery' view: display a transparent blue rectangle to show the selected area when use a mouse to select items

* [metadata] 'Gallery' view: added a top toolbar for the selected images(it offers 'unselect', 'download', 'delete' & 'copy' in the toolbar)

* [metadata] 'Gallery' view: fixup for 'moving the mouse to select images'

* [metadata] 'Gallery' view: code cleanup
This commit is contained in:
llj
2025-08-19 18:29:27 +08:00
committed by GitHub
parent 10c2f53cb1
commit 83572b20cc
10 changed files with 219 additions and 48 deletions

View File

@@ -0,0 +1,89 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { gettext } from '../../utils/constants';
import { EVENT_BUS_TYPE } from '../../metadata/constants';
import RowUtils from '../../metadata/views/table/utils/row-utils';
import { Dirent } from '../../models';
import { useFileOperations } from '../../hooks/file-operations';
const GalleryFilesToolbar = () => {
const [selectedRecordIds, setSelectedRecordIds] = useState([]);
const metadataRef = useRef([]);
const { handleDownload: handleDownloadAPI, handleCopy: handleCopyAPI } = useFileOperations();
const eventBus = window.sfMetadataContext && window.sfMetadataContext.eventBus;
const checkCanDeleteRow = window.sfMetadataContext.checkCanDeleteRow();
const canDuplicateRow = window.sfMetadataContext.canDuplicateRow();
useEffect(() => {
const unsubscribeSelectedFileIds = eventBus && eventBus.subscribe(EVENT_BUS_TYPE.SELECT_RECORDS, (ids, metadata) => {
metadataRef.current = metadata || [];
setSelectedRecordIds(ids);
});
return () => {
unsubscribeSelectedFileIds && unsubscribeSelectedFileIds();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const records = useMemo(() => selectedRecordIds.map(id => RowUtils.getRecordById(id, metadataRef.current)).filter(Boolean) || [], [selectedRecordIds]);
const unSelect = useCallback(() => {
setSelectedRecordIds([]);
eventBus && eventBus.dispatch(EVENT_BUS_TYPE.UPDATE_SELECTED_RECORD_IDS, []);
eventBus.dispatch(EVENT_BUS_TYPE.SELECT_NONE);
}, [eventBus]);
const handleDownload = useCallback(() => {
const list = records.map(record => {
const { _parent_dir: parentDir, _name: fileName } = record || {};
const name = parentDir === '/' ? fileName : `${parentDir}/${fileName}`;
return { name };
});
handleDownloadAPI('/', list);
}, [handleDownloadAPI, records]);
const deleteRecords = useCallback(() => {
eventBus && eventBus.dispatch(EVENT_BUS_TYPE.DELETE_RECORDS, selectedRecordIds, {
success_callback: () => {
eventBus.dispatch(EVENT_BUS_TYPE.SELECT_NONE);
}
});
}, [eventBus, selectedRecordIds]);
const handleDuplicate = useCallback((destRepo, dirent, destPath, nodeParentPath, isByDialog) => {
eventBus && eventBus.dispatch(EVENT_BUS_TYPE.DUPLICATE_RECORD, selectedRecordIds[0],
destRepo, dirent, destPath, nodeParentPath, isByDialog);
}, [eventBus, selectedRecordIds]);
const handleCopy = useCallback(() => {
const { _parent_dir: parentDir, _name: fileName } = records[0] || {};
const dirent = new Dirent({ name: fileName });
handleCopyAPI(parentDir, dirent, false, handleDuplicate);
}, [records, handleCopyAPI, handleDuplicate]);
const length = selectedRecordIds.length;
return (
<div className="selected-dirents-toolbar">
<span className="cur-view-path-btn px-2" onClick={unSelect}>
<span className="sf3-font-x-01 sf3-font mr-2" aria-label={gettext('Unselect')} title={gettext('Unselect')}></span>
<span>{length}{' '}{gettext('selected')}</span>
</span>
<span className="cur-view-path-btn" onClick={handleDownload}>
<span className="sf3-font-download1 sf3-font" aria-label={gettext('Download')} title={gettext('Download')}></span>
</span>
{checkCanDeleteRow &&
<span className="cur-view-path-btn" onClick={deleteRecords}>
<span className="sf3-font-delete1 sf3-font" aria-label={gettext('Delete')} title={gettext('Delete')}></span>
</span>
}
{(canDuplicateRow && length === 1) &&
<span className="cur-view-path-btn" onClick={handleCopy}>
<span className="sf3-font-copy1 sf3-font" aria-label={gettext('Copy')} title={gettext('Copy')}></span>
</span>
}
</div>
);
};
export default GalleryFilesToolbar;

View File

@@ -1,21 +1,38 @@
import React from 'react';
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import { TAGS_MODE } from '../dir-view-mode/constants';
import { ALL_TAGS_ID } from '../../tag/constants';
import { useMetadata } from '../../metadata/hooks';
import { VIEW_TYPE } from '../../metadata/constants';
import AllTagsToolbar from './all-tags-toolbar';
import TagFilesToolbar from './tag-files-toolbar';
import TableFilesToolbar from './table-files-toolbar';
import GalleryFilesToolbar from './gallery-files-toolbar';
const MetadataPathToolbar = ({ repoID, repoInfo, mode, path, viewId }) => {
const { idViewMap } = useMetadata();
const view = useMemo(() => idViewMap[viewId], [viewId, idViewMap]);
const type = view?.type;
if (type === VIEW_TYPE.GALLERY) {
return (
<GalleryFilesToolbar />
);
}
if (type === VIEW_TYPE.TABLE) {
return (
<TableFilesToolbar repoID={repoID} />
);
}
const MetadataPathToolbar = ({ repoID, repoInfo, mode, path }) => {
if (mode === TAGS_MODE) {
const isAllTagsView = path.split('/').pop() === ALL_TAGS_ID;
if (isAllTagsView) return <AllTagsToolbar />;
return <TagFilesToolbar currentRepoInfo={repoInfo} />;
}
return (
<TableFilesToolbar repoID={repoID} />
);
};
MetadataPathToolbar.propTypes = {

View File

@@ -42,6 +42,7 @@ export const EVENT_BUS_TYPE = {
SELECT_RECORDS: 'select_records',
TOGGLE_MOVE_DIALOG: 'toggle_move_dialog',
MOVE_RECORD: 'move_record',
DUPLICATE_RECORD: 'duplicate_record',
DELETE_RECORDS: 'delete_records',
UPDATE_RECORD_DETAILS: 'update_record_details',
UPDATE_FACE_RECOGNITION: 'update_face_recognition',

View File

@@ -18,3 +18,12 @@ export const GALLERY_DATE_MODE = {
export const STORAGE_GALLERY_DATE_MODE_KEY = 'gallery_date_mode';
export const STORAGE_GALLERY_ZOOM_GEAR_KEY = 'gallery_zoom_gear';
export const GALLERY_OPERATION_KEYS = {
DOWNLOAD: 'download',
DELETE: 'delete',
DUPLICATE: 'duplicate',
REMOVE: 'remove',
SET_PEOPLE_PHOTO: 'set_people_photo',
ADD_PHOTO_TO_GROUPS: 'add_photo_to_groups'
};

View File

@@ -633,6 +633,7 @@ export const MetadataViewProvider = ({
const unsubscribeLocalColumnChanged = eventBus.subscribe(EVENT_BUS_TYPE.LOCAL_COLUMN_DATA_CHANGED, updateLocalColumnData);
const unsubscribeUpdateSelectedRecordIds = eventBus.subscribe(EVENT_BUS_TYPE.UPDATE_SELECTED_RECORD_IDS, updateSelectedRecordIds);
const unsubscribeMoveRecord = eventBus.subscribe(EVENT_BUS_TYPE.MOVE_RECORD, moveRecord);
const unsubscribeDuplicateRecord = eventBus.subscribe(EVENT_BUS_TYPE.DUPLICATE_RECORD, duplicateRecord);
const unsubscribeDeleteRecords = eventBus.subscribe(EVENT_BUS_TYPE.DELETE_RECORDS, deleteRecords);
const unsubscribeUpdateDetails = eventBus.subscribe(EVENT_BUS_TYPE.UPDATE_RECORD_DETAILS, updateRecordDetails);
const unsubscribeUpdateFaceRecognition = eventBus.subscribe(EVENT_BUS_TYPE.UPDATE_FACE_RECOGNITION, updateFaceRecognition);
@@ -661,6 +662,7 @@ export const MetadataViewProvider = ({
unsubscribeLocalColumnChanged();
unsubscribeUpdateSelectedRecordIds();
unsubscribeMoveRecord();
unsubscribeDuplicateRecord();
unsubscribeDeleteRecords();
unsubscribeUpdateDetails();
unsubscribeUpdateFaceRecognition();

View File

@@ -26,6 +26,7 @@ const Content = ({
const [isSelecting, setIsSelecting] = useState(false);
const [selectionStart, setSelectionStart] = useState(null);
const [selectionEnd, setSelectionEnd] = useState(null);
const selectedImageIds = useMemo(() => selectedImages.map(img => img.id), [selectedImages]);
@@ -50,6 +51,7 @@ const Content = ({
const selectionEnd = { x: e.clientX, y: e.clientY };
const selected = [];
setSelectionEnd(selectionEnd);
groups.forEach(group => {
group.children.forEach((row) => {
@@ -80,6 +82,7 @@ const Content = ({
e.preventDefault();
e.stopPropagation();
setIsSelecting(false);
setSelectionEnd(null);
}, []);
const renderDisplayGroup = useCallback((group) => {
@@ -185,12 +188,30 @@ const Content = ({
);
}, [overScan, mode, columns, rowHeight, onImageClick, onImageDoubleClick, onContextMenu, size, selectedImageIds, onDateTagClick]);
const renderSelectionBox = useCallback(() => {
if (!isSelecting) return null;
if (!selectionEnd) return null;
const containerBounds = containerRef.current.getBoundingClientRect();
const left = Math.min(selectionStart.x, selectionEnd.x) - containerBounds.left;
const top = Math.min(selectionStart.y, selectionEnd.y) - containerBounds.top;
const width = Math.abs(selectionStart.x - selectionEnd.x);
const height = Math.abs(selectionStart.y - selectionEnd.y);
return (
<div
className="selection-box"
style={{ left, top, width, height }}
>
</div>
);
}, [isSelecting, selectionStart, selectionEnd]);
if (!Array.isArray(groups) || groups.length === 0) return (<EmptyTip text={gettext('No record')}/>);
return (
<div
ref={containerRef}
className="metadata-gallery-main"
className="metadata-gallery-main position-relative"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
@@ -198,6 +219,7 @@ const Content = ({
{groups.map((group) => {
return renderDisplayGroup(group);
})}
{renderSelectionBox()}
</div>
);
};

View File

@@ -6,15 +6,7 @@ import PeoplesDialog from '../../../components/dialog/peoples-dialog';
import { gettext } from '../../../../utils/constants';
import { Dirent } from '../../../../models';
import { useFileOperations } from '../../../../hooks/file-operations';
const CONTEXT_MENU_KEY = {
DOWNLOAD: 'download',
DELETE: 'delete',
DUPLICATE: 'duplicate',
REMOVE: 'remove',
SET_PEOPLE_PHOTO: 'set_people_photo',
ADD_PHOTO_TO_GROUPS: 'add_photo_to_groups',
};
import { GALLERY_OPERATION_KEYS } from '../../../constants';
const GalleryContextMenu = ({ selectedImages, onDelete, onDuplicate, onRemoveImage, onAddImage, onSetPeoplePhoto }) => {
const [isPeoplesDialogShow, setPeoplesDialogShow] = useState(false);
@@ -28,21 +20,21 @@ const GalleryContextMenu = ({ selectedImages, onDelete, onDuplicate, onRemoveIma
const canSetPeoplePhoto = window.sfMetadataContext.canSetPeoplePhoto();
const options = useMemo(() => {
let validOptions = [{ value: CONTEXT_MENU_KEY.DOWNLOAD, label: gettext('Download') }];
let validOptions = [{ value: GALLERY_OPERATION_KEYS.DOWNLOAD, label: gettext('Download') }];
if (onDelete && checkCanDeleteRow) {
validOptions.push({ value: CONTEXT_MENU_KEY.DELETE, label: selectedImages.length > 1 ? gettext('Delete') : gettext('Delete file') });
validOptions.push({ value: GALLERY_OPERATION_KEYS.DELETE, label: gettext('Delete') });
}
if (onDuplicate && canDuplicateRow && selectedImages.length === 1) {
validOptions.push({ value: CONTEXT_MENU_KEY.DUPLICATE, label: gettext('Duplicate') });
validOptions.push({ value: GALLERY_OPERATION_KEYS.DUPLICATE, label: gettext('Copy') });
}
if (onRemoveImage && canRemovePhotoFromPeople) {
validOptions.push({ value: CONTEXT_MENU_KEY.REMOVE, label: gettext('Remove from this group') });
validOptions.push({ value: GALLERY_OPERATION_KEYS.REMOVE, label: gettext('Remove from this group') });
}
if (onAddImage && canAddPhotoToPeople) {
validOptions.push({ value: CONTEXT_MENU_KEY.ADD_PHOTO_TO_GROUPS, label: gettext('Add to groups') });
validOptions.push({ value: GALLERY_OPERATION_KEYS.ADD_PHOTO_TO_GROUPS, label: gettext('Add to groups') });
}
if (onSetPeoplePhoto && canSetPeoplePhoto) {
validOptions.push({ value: CONTEXT_MENU_KEY.SET_PEOPLE_PHOTO, label: gettext('Set as cover photo') });
validOptions.push({ value: GALLERY_OPERATION_KEYS.SET_PEOPLE_PHOTO, label: gettext('Set as cover photo') });
}
return validOptions;
}, [checkCanDeleteRow, canDuplicateRow, canRemovePhotoFromPeople, canAddPhotoToPeople, selectedImages, onDuplicate, onDelete, onRemoveImage, onAddImage, canSetPeoplePhoto, onSetPeoplePhoto]);
@@ -70,22 +62,22 @@ const GalleryContextMenu = ({ selectedImages, onDelete, onDuplicate, onRemoveIma
const handleOptionClick = useCallback(option => {
switch (option.value) {
case CONTEXT_MENU_KEY.DOWNLOAD:
case GALLERY_OPERATION_KEYS.DOWNLOAD:
handleDownload();
break;
case CONTEXT_MENU_KEY.DELETE:
case GALLERY_OPERATION_KEYS.DELETE:
onDelete(selectedImages);
break;
case CONTEXT_MENU_KEY.DUPLICATE:
case GALLERY_OPERATION_KEYS.DUPLICATE:
handleCopy();
break;
case CONTEXT_MENU_KEY.REMOVE:
case GALLERY_OPERATION_KEYS.REMOVE:
onRemoveImage(selectedImages);
break;
case CONTEXT_MENU_KEY.ADD_PHOTO_TO_GROUPS:
case GALLERY_OPERATION_KEYS.ADD_PHOTO_TO_GROUPS:
setPeoplesDialogShow(true);
break;
case CONTEXT_MENU_KEY.SET_PEOPLE_PHOTO:
case GALLERY_OPERATION_KEYS.SET_PEOPLE_PHOTO:
onSetPeoplePhoto(selectedImages[0]);
break;
default:

View File

@@ -99,3 +99,9 @@
justify-content: center;
flex-shrink: 0;
}
.selection-box {
position: absolute;
background-color: rgba(0, 120, 215, 0.3);
border: 1px solid rgba(0, 120, 215, 0.8);
}

View File

@@ -35,7 +35,7 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore, duplicateRecord,
const scrollContainer = useRef(null);
const lastState = useRef({ scrollPos: 0 });
const { repoID, updateCurrentDirent } = useMetadataView();
const { repoID, updateCurrentDirent, updateSelectedRecordIds } = useMetadataView();
const repoInfo = window.sfMetadataContext.getSetting('repoInfo');
const canPreview = window.sfMetadataContext.canPreview();
@@ -191,12 +191,19 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore, duplicateRecord,
});
}, [metadata, updateCurrentDirent]);
const updateSelectedImages = useCallback((selectedImages) => {
const ids = selectedImages.map(item => item.id);
updateSelectedRecordIds(ids);
}, [updateSelectedRecordIds]);
const handleClick = useCallback((event, image) => {
if (event.metaKey || event.ctrlKey) {
setSelectedImages(prev =>
prev.includes(image) ? prev.filter(img => img !== image) : [...prev, image]
);
const updatedSelectedImages = selectedImages.includes(image)
? selectedImages.filter(img => img !== image)
: [...selectedImages, image];
setSelectedImages(updatedSelectedImages);
updateSelectedImage(image);
updateSelectedImages(updatedSelectedImages);
return;
}
if (event.shiftKey && lastSelectedImage) {
@@ -205,14 +212,18 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore, duplicateRecord,
const start = Math.min(lastSelectedIndex, currentIndex);
const end = Math.max(lastSelectedIndex, currentIndex);
const range = images.slice(start, end + 1);
setSelectedImages(prev => Array.from(new Set([...prev, ...range])));
const updatedSelectedImages = Array.from(new Set([...selectedImages, ...range]));
setSelectedImages(updatedSelectedImages);
updateSelectedImage(null);
updateSelectedImages(updatedSelectedImages);
return;
}
setSelectedImages([image]);
const updatedSelectedImages = [image];
setSelectedImages(updatedSelectedImages);
updateSelectedImage(image);
setLastSelectedImage(image);
}, [images, updateSelectedImage, lastSelectedImage]);
updateSelectedImages(updatedSelectedImages);
}, [images, selectedImages, updateSelectedImage, lastSelectedImage, updateSelectedImages]);
const handleDoubleClick = useCallback((event, image) => {
event.preventDefault();
@@ -231,28 +242,40 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore, duplicateRecord,
const index = images.findIndex(item => item.id === image.id);
if (isNaN(index) || index === -1) return;
setSelectedImages(prev => prev.length < 2 ? [image] : [...prev]);
}, [images]);
const updatedSelectedImages = selectedImages.length < 2 ? [image] : [...selectedImages];
setSelectedImages(updatedSelectedImages);
updateSelectedImages(updatedSelectedImages);
}, [images, selectedImages, updateSelectedImages]);
const moveToPrevImage = useCallback(() => {
const imageItemsLength = images.length;
const selectedImage = images[(imageIndex + imageItemsLength - 1) % imageItemsLength];
setImageIndex((prevState) => (prevState + imageItemsLength - 1) % imageItemsLength);
setSelectedImages([selectedImage]);
const updatedSelectedImages = [selectedImage];
setSelectedImages(updatedSelectedImages);
updateSelectedImage(selectedImage);
}, [images, imageIndex, updateSelectedImage]);
updateSelectedImages(updatedSelectedImages);
}, [images, imageIndex, updateSelectedImage, updateSelectedImages]);
const moveToNextImage = useCallback(() => {
const imageItemsLength = images.length;
const selectedImage = images[(imageIndex + 1) % imageItemsLength];
setImageIndex((prevState) => (prevState + 1) % imageItemsLength);
setSelectedImages([selectedImage]);
const updatedSelectedImages = [selectedImage];
setSelectedImages(updatedSelectedImages);
updateSelectedImage(selectedImage);
}, [images, imageIndex, updateSelectedImage]);
updateSelectedImages(updatedSelectedImages);
}, [images, imageIndex, updateSelectedImage, updateSelectedImages]);
const handleImageSelection = useCallback((selectedImages) => {
setSelectedImages(selectedImages);
}, []);
updateSelectedImages(selectedImages);
}, [updateSelectedImages]);
const selectNone = useCallback(() => {
setSelectedImages([]);
updateSelectedImages([]);
}, [updateSelectedImages]);
const closeImagePopup = useCallback(() => {
setIsImagePopupOpen(false);
@@ -264,26 +287,29 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore, duplicateRecord,
success_callback: () => {
updateCurrentDirent();
setSelectedImages([]);
updateSelectedImages([]);
}
});
}, [onDelete, updateCurrentDirent]);
}, [onDelete, updateCurrentDirent, updateSelectedImages]);
const handleRemoveSelectedImages = useCallback((selectedImages) => {
if (!selectedImages.length) return;
onRemoveImage && onRemoveImage(selectedImages, () => {
updateCurrentDirent();
setSelectedImages([]);
updateSelectedImages([]);
});
}, [onRemoveImage, updateCurrentDirent]);
}, [onRemoveImage, updateCurrentDirent, updateSelectedImages]);
const handleMakeSelectedAsCoverPhoto = useCallback((selectedImage) => {
onSetPeoplePhoto(selectedImage, {
success_callback: () => {
updateCurrentDirent();
setSelectedImages([]);
updateSelectedImages([]);
}
});
}, [onSetPeoplePhoto, updateCurrentDirent]);
}, [onSetPeoplePhoto, updateCurrentDirent, updateSelectedImages]);
const handleClickOutside = useCallback((event) => {
const className = getEventClassName(event);
@@ -306,6 +332,7 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore, duplicateRecord,
if (newImageItems.length === 0) {
setSelectedImages([]);
updateSelectedImages([]);
setIsImagePopupOpen(false);
setImageIndex(0);
} else {
@@ -314,9 +341,11 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore, duplicateRecord,
setImageIndex(newIndex);
}
setSelectedImages(newSelectedImage ? [newSelectedImage] : []);
const updatedSelectedImages = newSelectedImage ? [newSelectedImage] : [];
setSelectedImages(updatedSelectedImages);
updateSelectedImage(newSelectedImage);
}, [selectedImages, images, onDelete, updateSelectedImage]);
updateSelectedImages(updatedSelectedImages);
}, [selectedImages, images, onDelete, updateSelectedImage, updateSelectedImages]);
const handleDateTagClick = useCallback((event, groupName) => {
event.preventDefault();
@@ -348,6 +377,7 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore, duplicateRecord,
EVENT_BUS_TYPE.SWITCH_GALLERY_GROUP_BY,
(mode) => {
setSelectedImages([]);
updateSelectedImages([]);
setMode(mode);
lastState.current = { ...lastState.current, mode };
window.sfMetadataContext.localStorage.setItem(STORAGE_GALLERY_DATE_MODE_KEY, mode);
@@ -378,9 +408,12 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore, duplicateRecord,
setZoomGear(zoomGear);
});
const unsubscribeSelectNone = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_NONE, selectNone);
return () => {
container && resizeObserver.unobserve(container);
modifyGalleryZoomGearSubscribe();
unsubscribeSelectNone();
switchGalleryModeSubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -2311,7 +2311,7 @@ class LibContentView extends React.Component {
render() {
const { repoID } = this.props;
let { currentRepoInfo, userPerm, isCopyMoveProgressDialogShow, isDeleteFolderDialogOpen, errorMsg,
path, usedRepoTags, isDirentSelected, currentMode, currentNode } = this.state;
path, usedRepoTags, isDirentSelected, currentMode, currentNode, viewId } = this.state;
if (this.state.libNeedDecrypt) {
return (
@@ -2417,7 +2417,7 @@ class LibContentView extends React.Component {
})}>
{isDirentSelected ? (
currentMode === TAGS_MODE || currentMode === METADATA_MODE ? (
<MetadataPathToolbar repoID={repoID} repoInfo={currentRepoInfo} mode={currentMode} path={path} />
<MetadataPathToolbar repoID={repoID} repoInfo={currentRepoInfo} mode={currentMode} path={path} viewId={viewId} />
) : (
<SelectedDirentsToolbar
repoID={this.props.repoID}