From 211f06963eb248dbeeebcee877e506650d94f7dd Mon Sep 17 00:00:00 2001 From: RyanAtNight <232988350+RyanAtNight@users.noreply.github.com> Date: Tue, 13 Jan 2026 19:20:07 -0800 Subject: [PATCH] Add Invert Selection feature to list toolbars (#6491) --- .../GroupDetails/GroupSubGroupsPanel.tsx | 4 +- .../components/List/FilteredListToolbar.tsx | 4 +- ui/v2.5/src/components/List/ItemList.tsx | 5 +- .../components/List/ListOperationButtons.tsx | 35 +++++++++++-- ui/v2.5/src/components/List/ListProvider.tsx | 1 + ui/v2.5/src/components/List/util.ts | 15 +++++- ui/v2.5/src/components/Scenes/SceneList.tsx | 50 +++++++++++-------- .../src/docs/en/Manual/KeyboardShortcuts.md | 1 + ui/v2.5/src/locales/en-GB.json | 1 + 9 files changed, 87 insertions(+), 29 deletions(-) diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx index bd66490f9..32836ab24 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx @@ -76,7 +76,8 @@ const Toolbar: React.FC = ({ onDelete, operations, }) => { - const { getSelected, onSelectAll, onSelectNone } = useListContext(); + const { getSelected, onSelectAll, onSelectNone, onInvertSelection } = + useListContext(); const { filter, setFilter } = useFilter(); return ( @@ -91,6 +92,7 @@ const Toolbar: React.FC = ({ 0} otherOperations={operations} onEdit={onEdit} diff --git a/ui/v2.5/src/components/List/FilteredListToolbar.tsx b/ui/v2.5/src/components/List/FilteredListToolbar.tsx index 4e101ee4b..162b30ff3 100644 --- a/ui/v2.5/src/components/List/FilteredListToolbar.tsx +++ b/ui/v2.5/src/components/List/FilteredListToolbar.tsx @@ -99,13 +99,15 @@ export const FilteredListToolbar: React.FC = ({ filter, setFilter, }); - const { selectedIds, onSelectAll, onSelectNone } = listSelect; + const { selectedIds, onSelectAll, onSelectNone, onInvertSelection } = + listSelect; const hasSelection = selectedIds.size > 0; const renderOperations = operationComponent ?? ( 0} onEdit={onEdit} diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index 4c360ecc9..67d09e721 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -73,7 +73,7 @@ export function useFilteredItemList< const { result, items, totalCount, pages } = queryResult; const listSelect = useListSelect(items); - const { onSelectAll, onSelectNone } = listSelect; + const { onSelectAll, onSelectNone, onInvertSelection } = listSelect; const modalState = useModal(); const { showModal, closeModal } = modalState; @@ -99,6 +99,7 @@ export function useFilteredItemList< onChangePage: setPage, onSelectAll, onSelectNone, + onInvertSelection, pages, showEditFilter, }); @@ -164,6 +165,7 @@ export const ItemList = ( onSelectChange, onSelectAll, onSelectNone, + onInvertSelection, } = listSelect; // scroll to the top of the page when the page changes @@ -212,6 +214,7 @@ export const ItemList = ( onChangePage, onSelectAll, onSelectNone, + onInvertSelection, pages, showEditFilter, }); diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx index 66f4b46f3..b377cedba 100644 --- a/ui/v2.5/src/components/List/ListOperationButtons.tsx +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -63,6 +63,7 @@ export interface IListFilterOperation { interface IListOperationButtonsProps { onSelectAll?: () => void; onSelectNone?: () => void; + onInvertSelection?: () => void; onEdit?: () => void; onDelete?: () => void; itemsSelected?: boolean; @@ -72,6 +73,7 @@ interface IListOperationButtonsProps { export const ListOperationButtons: React.FC = ({ onSelectAll, onSelectNone, + onInvertSelection, onEdit, onDelete, itemsSelected, @@ -82,6 +84,7 @@ export const ListOperationButtons: React.FC = ({ useEffect(() => { Mousetrap.bind("s a", () => onSelectAll?.()); Mousetrap.bind("s n", () => onSelectNone?.()); + Mousetrap.bind("s i", () => onInvertSelection?.()); Mousetrap.bind("e", () => { if (itemsSelected) { @@ -98,10 +101,18 @@ export const ListOperationButtons: React.FC = ({ return () => { Mousetrap.unbind("s a"); Mousetrap.unbind("s n"); + Mousetrap.unbind("s i"); Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; - }); + }, [ + onSelectAll, + onSelectNone, + onInvertSelection, + itemsSelected, + onEdit, + onDelete, + ]); const buttons = useMemo(() => { const ret = (otherOperations ?? []).filter((o) => { @@ -185,7 +196,25 @@ export const ListOperationButtons: React.FC = ({ } } - const options = [renderSelectAll(), renderSelectNone()].filter((o) => o); + function renderInvertSelection() { + if (onInvertSelection) { + return ( + onInvertSelection?.()} + > + + + ); + } + } + + const options = [ + renderSelectAll(), + renderSelectNone(), + renderInvertSelection(), + ].filter((o) => o); if (otherOperations) { otherOperations @@ -219,7 +248,7 @@ export const ListOperationButtons: React.FC = ({ {options.length > 0 ? options : undefined} ); - }, [otherOperations, onSelectAll, onSelectNone]); + }, [otherOperations, onSelectAll, onSelectNone, onInvertSelection]); // don't render anything if there are no buttons or operations if (buttons.length === 0 && !moreDropdown) { diff --git a/ui/v2.5/src/components/List/ListProvider.tsx b/ui/v2.5/src/components/List/ListProvider.tsx index 0584a61c6..8b9ee7bfb 100644 --- a/ui/v2.5/src/components/List/ListProvider.tsx +++ b/ui/v2.5/src/components/List/ListProvider.tsx @@ -63,6 +63,7 @@ const emptyState: IListContextState = { onSelectChange: () => {}, onSelectAll: () => {}, onSelectNone: () => {}, + onInvertSelection: () => {}, items: [], hasSelection: false, selectedItems: [], diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index c15c3335a..707346848 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -229,6 +229,7 @@ export function useListKeyboardShortcuts(props: { pages?: number; onSelectAll?: () => void; onSelectNone?: () => void; + onInvertSelection?: () => void; }) { const { currentPage, @@ -237,6 +238,7 @@ export function useListKeyboardShortcuts(props: { pages = 0, onSelectAll, onSelectNone, + onInvertSelection, } = props; // set up hotkeys @@ -298,12 +300,14 @@ export function useListKeyboardShortcuts(props: { useEffect(() => { Mousetrap.bind("s a", () => onSelectAll?.()); Mousetrap.bind("s n", () => onSelectNone?.()); + Mousetrap.bind("s i", () => onInvertSelection?.()); return () => { Mousetrap.unbind("s a"); Mousetrap.unbind("s n"); + Mousetrap.unbind("s i"); }; - }, [onSelectAll, onSelectNone]); + }, [onSelectAll, onSelectNone, onInvertSelection]); } export function useListSelect(items: T[]) { @@ -420,6 +424,14 @@ export function useListSelect(items: T[]) { setLastClickedId(undefined); } + function onInvertSelection() { + setItemsSelected((prevSelected) => { + const selectedSet = new Set(prevSelected.map((item) => item.id)); + return items.filter((item) => !selectedSet.has(item.id)); + }); + setLastClickedId(undefined); + } + // TODO - this is for backwards compatibility const getSelected = useCallback(() => itemsSelected, [itemsSelected]); @@ -433,6 +445,7 @@ export function useListSelect(items: T[]) { onSelectChange, onSelectAll, onSelectNone, + onInvertSelection, hasSelection, }; } diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index b85ca7ad8..ff5237c9f 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -522,6 +522,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => { onSelectChange, onSelectAll, onSelectNone, + onInvertSelection, hasSelection, } = listSelect; @@ -539,6 +540,27 @@ export const FilteredSceneList = (props: IFilteredScenes) => { setShowSidebar, }); + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); + + const onEdit = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); + + const onDelete = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); + useEffect(() => { Mousetrap.bind("e", () => { if (hasSelection) { @@ -556,18 +578,12 @@ export const FilteredSceneList = (props: IFilteredScenes) => { Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; - }); + }, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]); useZoomKeybinds({ zoomIndex: filter.zoomIndex, onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)), }); - const onCloseEditDelete = useCloseEditDelete({ - closeModal, - onSelectNone, - result, - }); - const metadataByline = useMemo(() => { if (cachedResult.loading) return null; @@ -636,21 +652,6 @@ export const FilteredSceneList = (props: IFilteredScenes) => { ); } - function onEdit() { - showModal( - - ); - } - - function onDelete() { - showModal( - - ); - } - const otherOperations = [ { text: intl.formatMessage({ id: "actions.play" }), @@ -677,6 +678,11 @@ export const FilteredSceneList = (props: IFilteredScenes) => { onClick: () => onSelectNone(), isDisplayed: () => hasSelection, }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, { text: intl.formatMessage({ id: "actions.play_random" }), onClick: playRandom, diff --git a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md index 69006d429..f6cd29334 100644 --- a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md +++ b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md @@ -41,6 +41,7 @@ | `Ctrl + End` | Go to last page of results | | `s a` | Select all on page | | `s n` | Unselect all | +| `s i` | Invert selection | | `e` | Edit selected | | `d d` | Delete selected | diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index dadf8fd24..9fc6f0c0d 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -118,6 +118,7 @@ "select_entity": "Select {entityType}", "select_folders": "Select folders", "select_none": "Select None", + "invert_selection": "Invert Selection", "selective_auto_tag": "Selective Auto Tag", "selective_clean": "Selective Clean", "selective_scan": "Selective Scan",