From be1509c027bcc935f3f3b7e1acfb66f0584e3378 Mon Sep 17 00:00:00 2001 From: Aries Date: Fri, 27 Sep 2024 15:51:24 +0800 Subject: [PATCH] Support mouse drag for multiple selection, improve interactivity (#6823) --- frontend/src/metadata/constants/index.js | 2 + .../metadata/views/gallery/gallery-main.js | 101 ++++++++++++++++-- frontend/src/metadata/views/gallery/index.css | 6 ++ frontend/src/metadata/views/gallery/index.js | 28 ++--- 4 files changed, 109 insertions(+), 28 deletions(-) diff --git a/frontend/src/metadata/constants/index.js b/frontend/src/metadata/constants/index.js index cb95954ca8..bb2cb6f625 100644 --- a/frontend/src/metadata/constants/index.js +++ b/frontend/src/metadata/constants/index.js @@ -125,6 +125,8 @@ export const GALLERY_ZOOM_GEAR_MIN = -2; export const GALLERY_ZOOM_GEAR_MAX = 2; +export const GALLERY_IMAGE_GAP = 2; + export const GALLERY_DATE_MODE = { YEAR: 'year', MONTH: 'month', diff --git a/frontend/src/metadata/views/gallery/gallery-main.js b/frontend/src/metadata/views/gallery/gallery-main.js index 4f4089975e..502c84d1de 100644 --- a/frontend/src/metadata/views/gallery/gallery-main.js +++ b/frontend/src/metadata/views/gallery/gallery-main.js @@ -1,14 +1,85 @@ -import React, { useCallback, useMemo, useRef } from 'react'; +import React, { useState, 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, selectedImages, onImageClick, onImageDoubleClick, onImageRightClick }) => { +const GalleryMain = ({ + groups, + overScan, + columns, + size, + gap, + selectedImages, + setSelectedImages, + onImageClick, + onImageDoubleClick, + onImageRightClick +}) => { + const containerRef = useRef(null); const imageRef = useRef(null); + const animationFrameRef = useRef(null); + + const [isSelecting, setIsSelecting] = useState(false); + const [selectionStart, setSelectionStart] = useState(null); const imageHeight = useMemo(() => size + gap, [size, gap]); + const handleMouseDown = useCallback((e) => { + if (e.button !== 0) return; + if (e.ctrlKey || e.metaKey || e.shiftKey) return; + + setIsSelecting(true); + setSelectionStart({ x: e.clientX, y: e.clientY }); + setSelectedImages([]); + + }, [setSelectedImages]); + + const handleMouseMove = useCallback((e) => { + if (!isSelecting) return; + + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + + animationFrameRef.current = requestAnimationFrame(() => { + e.preventDefault(); + e.stopPropagation(); + + const selectionEnd = { x: e.clientX, y: e.clientY }; + const selected = []; + + groups.forEach(group => { + group.children.forEach((row) => { + row.children.forEach((img) => { + const imgElement = document.getElementById(img.id); + if (imgElement) { + const rect = imgElement.getBoundingClientRect(); + if ( + rect.left < Math.max(selectionStart.x, selectionEnd.x) && + rect.right > Math.min(selectionStart.x, selectionEnd.x) && + rect.top < Math.max(selectionStart.y, selectionEnd.y) && + rect.bottom > Math.min(selectionStart.y, selectionEnd.y) + ) { + selected.push(img); + } + } + }); + }); + }); + + setSelectedImages(selected); + }); + }, [groups, isSelecting, selectionStart, setSelectedImages]); + + const handleMouseUp = useCallback((e) => { + if (e.button !== 0) return; + + e.preventDefault(); + e.stopPropagation(); + setIsSelecting(false); + }, []); + const renderDisplayGroup = useCallback((group) => { const { top: overScanTop, bottom: overScanBottom } = overScan; const { name, children, height, top, paddingTop } = group; @@ -36,7 +107,12 @@ const GalleryMain = ({ groups, overScan, columns, size, gap, selectedImages, onI } return ( -
+
{childrenStartIndex === 0 && (
{name}
)}
onImageDoubleClick(e, img)} onContextMenu={(e) => onImageRightClick(e, img)} > - {img.name} + {img.name}
); }); @@ -76,9 +153,19 @@ const GalleryMain = ({ groups, overScan, columns, size, gap, selectedImages, onI return ; } - return groups.map((group, index) => { - return renderDisplayGroup(group, index); - }); + return ( +
+ {groups.map((group) => { + return renderDisplayGroup(group); + })} +
+ ); }; GalleryMain.propTypes = { diff --git a/frontend/src/metadata/views/gallery/index.css b/frontend/src/metadata/views/gallery/index.css index 0cf8109d90..d9ae3515ad 100644 --- a/frontend/src/metadata/views/gallery/index.css +++ b/frontend/src/metadata/views/gallery/index.css @@ -11,6 +11,7 @@ } .metadata-gallery-date-group { + width: 100%; position: relative; } @@ -28,6 +29,10 @@ user-select: none; } +.metadata-gallery-main { + flex: 1; +} + .metadata-gallery-image-list { display: grid; gap: 2px; @@ -52,6 +57,7 @@ height: 100%; object-fit: cover; transition: transform 0.2s ease-in-out; + user-select: none; } .metadata-gallery-grid-image:hover { diff --git a/frontend/src/metadata/views/gallery/index.js b/frontend/src/metadata/views/gallery/index.js index aeb26ef9b6..f533d09600 100644 --- a/frontend/src/metadata/views/gallery/index.js +++ b/frontend/src/metadata/views/gallery/index.js @@ -12,12 +12,10 @@ import { useMetadataView } from '../../hooks/metadata-view'; import { Utils } from '../../../utils/utils'; import { getDateDisplayString } from '../../utils/cell'; import { siteRoot, fileServerRoot, useGoFileserver, gettext, thumbnailSizeForGrid, thumbnailSizeForOriginal } 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, GALLERY_IMAGE_GAP } from '../../constants'; import './index.css'; -const IMAGE_GAP = 2; - const Gallery = () => { const [isFirstLoading, setFirstLoading] = useState(true); const [isLoadingMore, setLoadingMore] = useState(false); @@ -90,7 +88,7 @@ const Gallery = () => { }, []); let _groups = []; - const imageHeight = imageSize + IMAGE_GAP; + const imageHeight = imageSize + GALLERY_IMAGE_GAP; init.forEach((_init, index) => { const { children, ...__init } = _init; let top = 0; @@ -160,7 +158,7 @@ const Gallery = () => { // Calculate initial overScan information const columns = 8 - gear; const imageSize = (offsetWidth - columns * 2 - 2) / columns; - setOverScan({ top: 0, bottom: clientHeight + (imageSize + IMAGE_GAP) * 2 }); + setOverScan({ top: 0, bottom: clientHeight + (imageSize + GALLERY_IMAGE_GAP) * 2 }); } setFirstLoading(false); @@ -186,18 +184,6 @@ 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; @@ -207,8 +193,8 @@ const Gallery = () => { renderMoreTimer.current && clearTimeout(renderMoreTimer.current); renderMoreTimer.current = setTimeout(() => { const { scrollTop, clientHeight } = containerRef.current; - const overScanTop = Math.max(0, scrollTop - (imageSize + IMAGE_GAP) * 3); - const overScanBottom = scrollTop + clientHeight + (imageSize + IMAGE_GAP) * 3; + const overScanTop = Math.max(0, scrollTop - (imageSize + GALLERY_IMAGE_GAP) * 3); + const overScanBottom = scrollTop + clientHeight + (imageSize + GALLERY_IMAGE_GAP) * 3; setOverScan({ top: overScanTop, bottom: overScanBottom }); renderMoreTimer.current = null; }, 200); @@ -241,7 +227,6 @@ const Gallery = () => { setIsImagePopupOpen(true); }, [imageItems]); - const handleRightClick = useCallback((event, image) => { event.preventDefault(); const index = imageItems.findIndex(item => item.id === image.id); @@ -333,8 +318,9 @@ const Gallery = () => { size={imageSize} columns={columns} overScan={overScan} - gap={IMAGE_GAP} + gap={GALLERY_IMAGE_GAP} selectedImages={selectedImages} + setSelectedImages={setSelectedImages} onImageClick={handleClick} onImageDoubleClick={handleDoubleClick} onImageRightClick={handleRightClick}