Add Invert Selection feature to list toolbars (#6491)

This commit is contained in:
RyanAtNight 2026-01-13 19:20:07 -08:00 committed by GitHub
parent 0fa132cf60
commit 211f06963e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 87 additions and 29 deletions

View file

@ -76,7 +76,8 @@ const Toolbar: React.FC<IFilteredListToolbar> = ({
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<IFilteredListToolbar> = ({
<ListOperationButtons
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
onInvertSelection={onInvertSelection}
itemsSelected={getSelected().length > 0}
otherOperations={operations}
onEdit={onEdit}

View file

@ -99,13 +99,15 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
filter,
setFilter,
});
const { selectedIds, onSelectAll, onSelectNone } = listSelect;
const { selectedIds, onSelectAll, onSelectNone, onInvertSelection } =
listSelect;
const hasSelection = selectedIds.size > 0;
const renderOperations = operationComponent ?? (
<ListOperationButtons
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
onInvertSelection={onInvertSelection}
otherOperations={operations}
itemsSelected={selectedIds.size > 0}
onEdit={onEdit}

View file

@ -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 = <T extends QueryResult, E extends IHasID, M = unknown>(
onSelectChange,
onSelectAll,
onSelectNone,
onInvertSelection,
} = listSelect;
// scroll to the top of the page when the page changes
@ -212,6 +214,7 @@ export const ItemList = <T extends QueryResult, E extends IHasID, M = unknown>(
onChangePage,
onSelectAll,
onSelectNone,
onInvertSelection,
pages,
showEditFilter,
});

View file

@ -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<IListOperationButtonsProps> = ({
onSelectAll,
onSelectNone,
onInvertSelection,
onEdit,
onDelete,
itemsSelected,
@ -82,6 +84,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
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<IListOperationButtonsProps> = ({
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<IListOperationButtonsProps> = ({
}
}
const options = [renderSelectAll(), renderSelectNone()].filter((o) => o);
function renderInvertSelection() {
if (onInvertSelection) {
return (
<Dropdown.Item
key="invert-selection"
className="bg-secondary text-white"
onClick={() => onInvertSelection?.()}
>
<FormattedMessage id="actions.invert_selection" />
</Dropdown.Item>
);
}
}
const options = [
renderSelectAll(),
renderSelectNone(),
renderInvertSelection(),
].filter((o) => o);
if (otherOperations) {
otherOperations
@ -219,7 +248,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
{options.length > 0 ? options : undefined}
</OperationDropdown>
);
}, [otherOperations, onSelectAll, onSelectNone]);
}, [otherOperations, onSelectAll, onSelectNone, onInvertSelection]);
// don't render anything if there are no buttons or operations
if (buttons.length === 0 && !moreDropdown) {

View file

@ -63,6 +63,7 @@ const emptyState: IListContextState = {
onSelectChange: () => {},
onSelectAll: () => {},
onSelectNone: () => {},
onInvertSelection: () => {},
items: [],
hasSelection: false,
selectedItems: [],

View file

@ -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<T extends IHasID = IHasID>(items: T[]) {
@ -420,6 +424,14 @@ export function useListSelect<T extends IHasID = IHasID>(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<T extends IHasID = IHasID>(items: T[]) {
onSelectChange,
onSelectAll,
onSelectNone,
onInvertSelection,
hasSelection,
};
}

View file

@ -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(
<EditScenesDialog selected={selectedItems} onClose={onCloseEditDelete} />
);
}, [showModal, selectedItems, onCloseEditDelete]);
const onDelete = useCallback(() => {
showModal(
<DeleteScenesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}, [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(
<EditScenesDialog selected={selectedItems} onClose={onCloseEditDelete} />
);
}
function onDelete() {
showModal(
<DeleteScenesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}
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,

View file

@ -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 |

View file

@ -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",