diff --git a/src/features/images/ImagesTable.jsx b/src/features/images/ImagesTable.jsx index 9b5c1fb1..f159a11d 100644 --- a/src/features/images/ImagesTable.jsx +++ b/src/features/images/ImagesTable.jsx @@ -4,6 +4,7 @@ import React, { useEffect, useRef, useCallback, + forwardRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { DateTime } from 'luxon'; @@ -26,10 +27,14 @@ import { } from './imagesSlice'; import { setFocus, + labelsAdded, selectFocusIndex, selectFocusChangeType } from '../review/reviewSlice'; import { toggleOpenLoupe, selectLoupeOpen } from '../loupe/loupeSlice'; +import { selectUserUsername, selectUserCurrentRoles } from '../user/userSlice.js'; +import { selectAvailLabels } from '../filters/filtersSlice.js'; +import { selectIsAddingLabel, addLabelStart, addLabelEnd } from '../loupe/loupeSlice.js'; import { Image } from '../../components/Image'; import LabelPills from './LabelPills'; import { SimpleSpinner, SpinnerOverlay } from '../../components/Spinner'; @@ -42,6 +47,9 @@ import { ContextMenuSeparator, ContextMenuItemIconLeft } from '../../components/ContextMenu'; +import CreatableSelect from 'react-select/creatable'; +import { createFilter } from 'react-select'; + // TODO: make table horizontally scrollable on smaller screens @@ -252,20 +260,17 @@ const ImagesTable = ({ workingImages, hasNext, loadNextPage }) => { setSelectedRows([]); } }, [focusIndex.image]); - console.log('selectedRows: ', selectedRows); - const handleRowClick = useCallback((e, rowId) => { + const handleRowClick = useCallback((e, rowIdx) => { if (e.shiftKey) { - // TODO: allow for selection of mulitple images to perform bulk actions on - console.log('shift + click detected. Current focusIndex: ', focusIndex); - console.log('row clicked: ', Number(rowId)); - const start = Math.min(focusIndex.image, rowId); - const end = Math.max(focusIndex.image, rowId); + // allow for selection of multiple images to perform bulk actions on + const start = Math.min(focusIndex.image, rowIdx); + const end = Math.max(focusIndex.image, rowIdx); let selection = []; for (let i = start; i <= end; i++) { selection.push(i); } setSelectedRows(selection); } else { - const newIndex = { image: Number(rowId), object: null, label: null } + const newIndex = { image: Number(rowIdx), object: null, label: null } dispatch(setFocus({ index: newIndex, type: 'manual' })); dispatch(toggleOpenLoupe(true)); } @@ -409,6 +414,23 @@ const ImagesTable = ({ workingImages, hasNext, loadNextPage }) => { const row = rows[index]; prepareRow(row); const selected = selectedRows.includes(index); + const selectedImages = selectedRows.map((rowIdx) => workingImages[rowIdx]); + + // manage category selector state (open/closed) + const isAddingLabel = useSelector(selectIsAddingLabel); + const [ catSelectorOpen, setCatSelectorOpen ] = useState((isAddingLabel === 'from-image-table')); + useEffect(() => { + setCatSelectorOpen(((isAddingLabel === 'from-image-table'))); + }, [isAddingLabel]); + + const catSelectorRef = useRef(null); + + const handleEditAllLabelsButtonClick = (e) => { + e.stopPropagation(); + e.preventDefault(); + dispatch(addLabelStart('from-image-table')); + }; + return ( @@ -467,6 +489,18 @@ const ImagesTable = ({ workingImages, hasNext, loadNextPage }) => { Invalidate + {catSelectorOpen + ? () + : ( + + + + Edit all labels + ) + } ); @@ -616,6 +650,112 @@ function makeRows(workingImages, focusIndex, selectedRows) { ...img, } }) -} +}; + +// TODO: make this it's own component. +// Used in ImageReviewToolbar and BoundingBoxLabel + +const StyledCategorySelector = styled(CreatableSelect, { + width: '155px', + fontFamily: '$mono', + fontSize: '$2', + fontWeight: '$1', + zIndex: '$5', + '.react-select__control': { + boxSizing: 'border-box', + // height: '24px', + minHeight: 'unset', + border: '1px solid', + borderColor: '$border', + borderRadius: '$2', + cursor: 'pointer', + }, + '.react-select__single-value': { + // position: 'relative', + }, + '.react-select__indicator-separator': { + display: 'none', + }, + '.react-select__dropdown-indicator': { + paddingTop: '0', + paddingBottom: '0', + }, + '.react-select__control--is-focused': { + transition: 'all 0.2s ease', + boxShadow: '0 0 0 3px $blue200', + borderColor: '$blue500', + '&:hover': { + boxShadow: '0 0 0 3px $blue200', + borderColor: '$blue500', + }, + }, + '.react-select__menu': { + color: '$textDark', + fontSize: '$3', + '.react-select__option': { + cursor: 'pointer', + }, + '.react-select__option--is-selected': { + color: '$blue500', + backgroundColor: '$blue200', + }, + '.react-select__option--is-focused': { + backgroundColor: '$gray3', + }, + } +}); + +const CategorySelector = ({ selectedImages }) => { + const userId = useSelector(selectUserUsername); + const dispatch = useDispatch(); + // update selector options when new labels become available + const createOption = (category) => ({ value: category.toLowerCase(), label: category }); + const availLabels = useSelector(selectAvailLabels); + const options = availLabels.ids.map((id) => createOption(id)); + + const handleCategoryChange = (newValue) => { + if (!newValue) return; + let labelsToAdd = []; + for (const image of selectedImages) { + const newLabels = image.objects + .filter((obj) => !obj.locked) + .map((obj) => ({ + objIsTemp: obj.isTemp, + userId, + bbox: obj.bbox, + category: newValue.value || newValue, + objId: obj._id, + imgId: image._id + })); + labelsToAdd = labelsToAdd.concat(newLabels); + } + dispatch(labelsAdded({ labels: labelsToAdd })); + }; + + const handleCategorySelectorBlur = (e) => { + console.log('handleCategorySelectorBlur') + dispatch(addLabelEnd()); + }; + + return ( + + ); +}; export default ImagesTable; diff --git a/src/features/loupe/BoundingBox.jsx b/src/features/loupe/BoundingBox.jsx index 5610d382..82d90d7b 100644 --- a/src/features/loupe/BoundingBox.jsx +++ b/src/features/loupe/BoundingBox.jsx @@ -125,6 +125,8 @@ const BoundingBox = ({ const username = useSelector(selectUserUsername); const isAuthorized = hasRole(userRoles, WRITE_OBJECTS_ROLES); const handleRef = useRef(null); + // TODO: I'm pretty sure we want to wrap the bounding box label in react.forwardRef + // https://react.dev/reference/react/forwardRef const catSelectorRef = useRef(null); const focusRef = useRef(null); const dispatch = useDispatch(); @@ -248,7 +250,7 @@ const BoundingBox = ({ focusRef.current = catSelectorRef.current; const newIndex = { image: focusIndex.image, object: objectIndex, label: null } dispatch(setFocus({ index: newIndex, type: 'manual'})); - dispatch(addLabelStart('to-single-object')); + dispatch(addLabelStart('from-object')); }; const handleUnlockMenuItemClick = (e) => { diff --git a/src/features/loupe/BoundingBoxLabel.jsx b/src/features/loupe/BoundingBoxLabel.jsx index 421d3441..1c5705f2 100644 --- a/src/features/loupe/BoundingBoxLabel.jsx +++ b/src/features/loupe/BoundingBoxLabel.jsx @@ -145,10 +145,10 @@ const BoundingBoxLabel = ({ // manage category selector state (open/closed) const isAddingLabel = useSelector(selectIsAddingLabel); - const open = ((isAddingLabel === 'to-single-object') && selected); + const open = ((isAddingLabel === 'from-object') && selected); const [ catSelectorOpen, setCatSelectorOpen ] = useState(open); useEffect(() => { - setCatSelectorOpen(((isAddingLabel === 'to-single-object') && selected)); + setCatSelectorOpen(((isAddingLabel === 'from-object') && selected)); }, [isAddingLabel, selected]); // manually focus catSelector if it's open @@ -160,7 +160,7 @@ const BoundingBoxLabel = ({ e.stopPropagation(); if (!object.locked && isAuthorized && !catSelectorOpen) { dispatch(setFocus({ index, type: 'manual' })); - dispatch(addLabelStart('to-single-object')); + dispatch(addLabelStart('from-object')); } }; diff --git a/src/features/loupe/DrawBboxOverlay.jsx b/src/features/loupe/DrawBboxOverlay.jsx index 5dd60c5e..d1a0d515 100644 --- a/src/features/loupe/DrawBboxOverlay.jsx +++ b/src/features/loupe/DrawBboxOverlay.jsx @@ -91,7 +91,7 @@ const DrawBboxOverlay = ({ imgContainerDims, imgDims, setTempObject }) => { setTempBBox(defaultBBox); dispatch(setFocus({ index: { object: null }, type: 'auto' })); dispatch(drawBboxEnd()); - dispatch(addLabelStart('to-single-object')); + dispatch(addLabelStart('from-object')); }; const startDrawingBBox = () => { diff --git a/src/features/loupe/ImageReviewToolbar.jsx b/src/features/loupe/ImageReviewToolbar.jsx index cc365e1b..3a7dd874 100644 --- a/src/features/loupe/ImageReviewToolbar.jsx +++ b/src/features/loupe/ImageReviewToolbar.jsx @@ -209,14 +209,14 @@ const ImageReviewToolbar = ({ // manage category selector state (open/closed) const isAddingLabel = useSelector(selectIsAddingLabel); - const [ catSelectorOpen, setCatSelectorOpen ] = useState((isAddingLabel === 'to-all-objects')); + const [ catSelectorOpen, setCatSelectorOpen ] = useState((isAddingLabel === 'from-review-toolbar')); useEffect(() => { - setCatSelectorOpen(((isAddingLabel === 'to-all-objects'))); + setCatSelectorOpen(((isAddingLabel === 'from-review-toolbar'))); }, [isAddingLabel]); const handleEditAllLabelsButtonClick = (e) => { e.stopPropagation(); - dispatch(addLabelStart('to-all-objects')); + dispatch(addLabelStart('from-review-toolbar')); }; const allObjectsLocked = image.objects && image.objects.every((obj) => obj.locked); diff --git a/src/features/loupe/Loupe.jsx b/src/features/loupe/Loupe.jsx index 88929e58..d44509b1 100644 --- a/src/features/loupe/Loupe.jsx +++ b/src/features/loupe/Loupe.jsx @@ -284,7 +284,7 @@ const Loupe = () => { // ctrl-e (edit all) if (((e.ctrlKey || e.metaKey) && charCode === 'e') && hasRole(userRoles, WRITE_OBJECTS_ROLES)) { - dispatch(addLabelStart('to-all-objects')); + dispatch(addLabelStart('from-review-toolbar')); } // ctrl-v (repeat last action) diff --git a/src/features/loupe/loupeSlice.js b/src/features/loupe/loupeSlice.js index e887894c..4c424738 100644 --- a/src/features/loupe/loupeSlice.js +++ b/src/features/loupe/loupeSlice.js @@ -38,7 +38,7 @@ export const loupeSlice = createSlice({ }, addLabelStart: (state, { payload }) => { - // payload can be 'to-single-object' or 'all-objects + // payload can be 'from-object' or 'all-objects state.isAddingLabel = payload; },