feat(ui): delete confirmation for videos

This commit is contained in:
psychedelicious
2025-08-26 19:34:45 +10:00
committed by Mary Hipp Rogers
parent 82893804ff
commit 6972cd708d
12 changed files with 291 additions and 32 deletions

View File

@@ -365,6 +365,9 @@
"deleteImage_one": "Delete Image",
"deleteImage_other": "Delete {{count}} Images",
"deleteImagePermanent": "Deleted images cannot be restored.",
"deleteVideo_one": "Delete Video",
"deleteVideo_other": "Delete {{count}} Videos",
"deleteVideoPermanent": "Deleted videos cannot be restored.",
"displayBoardSearch": "Board Search",
"displaySearch": "Image Search",
"download": "Download",

View File

@@ -3,6 +3,7 @@ import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardMo
import { CanvasPasteModal } from 'features/controlLayers/components/CanvasPasteModal';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { DeleteImageModal } from 'features/deleteImageModal/components/DeleteImageModal';
import { DeleteVideoModal } from 'features/deleteVideoModal/components/DeleteVideoModal';
import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone';
import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal';
import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal';
@@ -32,6 +33,7 @@ export const GlobalModalIsolator = memo(() => {
return (
<>
<DeleteImageModal />
<DeleteVideoModal />
<ChangeBoardModal />
<DynamicPromptsModal />
<StylePresetModal />

View File

@@ -1,5 +1,6 @@
import { useAppStore } from 'app/store/storeHooks';
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
import { useDeleteVideoModalApi } from 'features/deleteVideoModal/store/state';
import { selectSelection } from 'features/gallery/store/gallerySelectors';
import { useClearQueue } from 'features/queue/hooks/useClearQueue';
import { useDeleteCurrentQueueItem } from 'features/queue/hooks/useDeleteCurrentQueueItem';
@@ -123,6 +124,8 @@ export const useGlobalHotkeys = () => {
});
const deleteImageModalApi = useDeleteImageModalApi();
const deleteVideoModalApi = useDeleteVideoModalApi();
useRegisteredHotkeys({
id: 'deleteSelection',
category: 'gallery',
@@ -135,7 +138,13 @@ export const useGlobalHotkeys = () => {
if (!selection.length) {
return;
}
deleteImageModalApi.delete(selection.map((s) => s.id));
if (selection.every(({ type }) => type === 'image')) {
deleteImageModalApi.delete(selection.map((s) => s.id));
} else if (selection.every(({ type }) => type === 'video')) {
deleteVideoModalApi.delete(selection.map((s) => s.id));
} else {
// no-op, we expect selections to always be only images or only video
}
},
dependencies: [getState, deleteImageModalApi],
});

View File

@@ -0,0 +1,28 @@
import { useDeleteVideoModalApi } from 'features/deleteVideoModal/store/state';
import { useCallback, useMemo } from 'react';
import type { VideoDTO } from 'services/api/types';
export const useDeleteVideo = (videoDTO?: VideoDTO | null) => {
const deleteImageModal = useDeleteVideoModalApi();
const isEnabled = useMemo(() => {
if (!videoDTO) {
return;
}
return true;
}, [videoDTO]);
const _delete = useCallback(() => {
if (!videoDTO) {
return;
}
if (!isEnabled) {
return;
}
deleteImageModal.delete([videoDTO.video_id]);
}, [deleteImageModal, videoDTO, isEnabled]);
return {
delete: _delete,
isEnabled,
};
};

View File

@@ -0,0 +1,36 @@
import type { IconButtonProps } from '@invoke-ai/ui-library';
import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { selectSelectionCount } from 'features/gallery/store/gallerySelectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold } from 'react-icons/pi';
import { $isConnected } from 'services/events/stores';
type Props = Omit<IconButtonProps, 'aria-label'> & {
onClick: () => void;
};
export const DeleteVideoButton = memo((props: Props) => {
const { onClick, isDisabled } = props;
const { t } = useTranslation();
const isConnected = useStore($isConnected);
const count = useAppSelector(selectSelectionCount);
const labelMessage: string = `${t('gallery.deleteVideo', { count })} (Del)`;
return (
<IconButton
onClick={onClick}
icon={<PiTrashSimpleBold />}
tooltip={labelMessage}
aria-label={labelMessage}
isDisabled={isDisabled || !isConnected}
colorScheme="error"
variant="link"
alignSelf="stretch"
/>
);
});
DeleteVideoButton.displayName = 'DeleteVideoButton';

View File

@@ -0,0 +1,43 @@
import { ConfirmationAlertDialog, Flex, FormControl, FormLabel, Switch, Text } from '@invoke-ai/ui-library';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { useDeleteVideoModalApi, useDeleteVideoModalState } from 'features/deleteVideoModal/store/state';
import { selectSystemShouldConfirmOnDelete, setShouldConfirmOnDelete } from 'features/system/store/systemSlice';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const DeleteVideoModal = memo(() => {
const state = useDeleteVideoModalState();
const api = useDeleteVideoModalApi();
const { dispatch } = useAppStore();
const { t } = useTranslation();
const shouldConfirmOnDelete = useAppSelector(selectSystemShouldConfirmOnDelete);
const handleChangeShouldConfirmOnDelete = useCallback(
(e: ChangeEvent<HTMLInputElement>) => dispatch(setShouldConfirmOnDelete(!e.target.checked)),
[dispatch]
);
return (
<ConfirmationAlertDialog
title={`${t('gallery.deleteVideo', { count: state.video_ids.length })}`}
isOpen={state.isOpen}
onClose={api.close}
cancelButtonText={t('common.cancel')}
acceptButtonText={t('common.delete')}
acceptCallback={api.confirm}
cancelCallback={api.cancel}
useInert={false}
>
<Flex direction="column" gap={3}>
<Text>{t('gallery.deleteVideoPermanent')}</Text>
<Text>{t('common.areYouSure')}</Text>
<FormControl>
<FormLabel>{t('common.dontAskMeAgain')}</FormLabel>
<Switch isChecked={!shouldConfirmOnDelete} onChange={handleChangeShouldConfirmOnDelete} />
</FormControl>
</Flex>
</ConfirmationAlertDialog>
);
});
DeleteVideoModal.displayName = 'DeleteVideoModal';

View File

@@ -0,0 +1,111 @@
import { useStore } from '@nanostores/react';
import type { AppStore } from 'app/store/store';
import { useAppStore } from 'app/store/storeHooks';
import { intersection } from 'es-toolkit/compat';
import { selectGetVideoIdsQueryArgs } from 'features/gallery/store/gallerySelectors';
import { itemSelected } from 'features/gallery/store/gallerySlice';
import { selectSystemShouldConfirmOnDelete } from 'features/system/store/systemSlice';
import { atom } from 'nanostores';
import { useMemo } from 'react';
import { videosApi } from 'services/api/endpoints/videos';
// Implements an awaitable modal dialog for deleting images
type DeleteVideosModalState = {
video_ids: string[];
isOpen: boolean;
resolve?: () => void;
reject?: (reason?: string) => void;
};
const getInitialState = (): DeleteVideosModalState => ({
video_ids: [],
isOpen: false,
});
const $deleteVideosModalState = atom<DeleteVideosModalState>(getInitialState());
const deleteVideosWithDialog = async (video_ids: string[], store: AppStore): Promise<void> => {
const { getState } = store;
const shouldConfirmOnDelete = selectSystemShouldConfirmOnDelete(getState());
if (!shouldConfirmOnDelete) {
// If we don't need to confirm and the resources are not in use, delete them directly
await handleDeletions(video_ids, store);
return;
}
return new Promise<void>((resolve, reject) => {
$deleteVideosModalState.set({
video_ids,
isOpen: true,
resolve,
reject,
});
});
};
const handleDeletions = async (video_ids: string[], store: AppStore) => {
try {
const { dispatch, getState } = store;
const state = getState();
const { data } = videosApi.endpoints.getVideoIds.select(selectGetVideoIdsQueryArgs(state))(state);
const index = data?.video_ids.findIndex((id) => id === video_ids[0]);
const { deleted_videos } = await dispatch(
videosApi.endpoints.deleteVideos.initiate({ video_ids }, { track: false })
).unwrap();
const newVideoIds = data?.video_ids.filter((id) => !deleted_videos.includes(id)) || [];
const newSelectedVideoId = newVideoIds[index ?? 0] || null;
if (
intersection(
state.gallery.selection.map((s) => s.id),
video_ids
).length > 0 &&
newSelectedVideoId
) {
// Some selected images were deleted, clear selection
dispatch(itemSelected({ type: 'video', id: newSelectedVideoId }));
}
} catch {
// no-op
}
};
const confirmDeletion = async (store: AppStore) => {
const state = $deleteVideosModalState.get();
await handleDeletions(state.video_ids, store);
state.resolve?.();
closeSilently();
};
const cancelDeletion = () => {
const state = $deleteVideosModalState.get();
state.reject?.('User canceled');
closeSilently();
};
const closeSilently = () => {
$deleteVideosModalState.set(getInitialState());
};
export const useDeleteVideoModalState = () => {
const state = useStore($deleteVideosModalState);
return state;
};
export const useDeleteVideoModalApi = () => {
const store = useAppStore();
const api = useMemo(
() => ({
delete: (video_ids: string[]) => deleteVideosWithDialog(video_ids, store),
confirm: () => confirmDeletion(store),
cancel: cancelDeletion,
close: closeSilently,
}),
[store]
);
return api;
};

View File

@@ -4,27 +4,22 @@ import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold } from 'react-icons/pi';
import { useDeleteVideosMutation } from 'services/api/endpoints/videos';
import { isImageDTO } from 'services/api/types';
export const ContextMenuItemDelete = memo(() => {
export const ContextMenuItemDeleteImage = memo(() => {
const { t } = useTranslation();
const deleteImageModal = useDeleteImageModalApi();
const [deleteVideos] = useDeleteVideosMutation();
const itemDTO = useItemDTOContext();
const onClick = useCallback(async () => {
try {
if (isImageDTO(itemDTO)) {
await deleteImageModal.delete([itemDTO.image_name]);
} else {
// TODO: Add confirm on delete and video usage functionality
await deleteVideos({ video_ids: [itemDTO.video_id] });
}
} catch {
// noop;
}
}, [deleteImageModal, deleteVideos, itemDTO]);
}, [deleteImageModal, itemDTO]);
return (
<IconMenuItem
@@ -37,4 +32,4 @@ export const ContextMenuItemDelete = memo(() => {
);
});
ContextMenuItemDelete.displayName = 'ContextMenuItemDelete';
ContextMenuItemDeleteImage.displayName = 'ContextMenuItemDeleteImage';

View File

@@ -0,0 +1,35 @@
import { IconMenuItem } from 'common/components/IconMenuItem';
import { useDeleteVideoModalApi } from 'features/deleteVideoModal/store/state';
import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold } from 'react-icons/pi';
import { isVideoDTO } from 'services/api/types';
export const ContextMenuItemDeleteVideo = memo(() => {
const { t } = useTranslation();
const deleteVideoModal = useDeleteVideoModalApi();
const itemDTO = useItemDTOContext();
const onClick = useCallback(async () => {
try {
if (isVideoDTO(itemDTO)) {
await deleteVideoModal.delete([itemDTO.video_id]);
}
} catch {
// noop;
}
}, [deleteVideoModal, itemDTO]);
return (
<IconMenuItem
icon={<PiTrashSimpleBold />}
onClickCapture={onClick}
aria-label={t('gallery.deleteVideo', { count: 1 })}
tooltip={t('gallery.deleteVideo', { count: 1 })}
isDestructive
/>
);
});
ContextMenuItemDeleteVideo.displayName = 'ContextMenuItemDeleteVideo';

View File

@@ -3,7 +3,6 @@ import { useAppSelector } from 'app/store/storeHooks';
import { IconMenuItemGroup } from 'common/components/IconMenuItem';
import { ContextMenuItemChangeBoard } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard';
import { ContextMenuItemCopy } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemCopy';
import { ContextMenuItemDelete } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDelete';
import { ContextMenuItemDownload } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownload';
import { ContextMenuItemLoadWorkflow } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLoadWorkflow';
import { ContextMenuItemLocateInGalery } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLocateInGalery';
@@ -23,6 +22,7 @@ import { ItemDTOContextProvider } from 'features/gallery/contexts/ItemDTOContext
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import type { ImageDTO } from 'services/api/types';
import { ContextMenuItemDeleteImage } from './MenuItems/ContextMenuItemDeleteImage';
import { ContextMenuItemMetadataRecallActionsUpscaleTab } from './MenuItems/ContextMenuItemMetadataRecallActionsUpscaleTab';
type SingleSelectionMenuItemsProps = {
@@ -40,7 +40,7 @@ const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) =
<ContextMenuItemDownload />
<ContextMenuItemOpenInViewer />
<ContextMenuItemSelectForCompare />
<ContextMenuItemDelete />
<ContextMenuItemDeleteImage />
</IconMenuItemGroup>
<MenuDivider />
<ContextMenuItemLoadWorkflow />

View File

@@ -2,7 +2,6 @@ import { MenuDivider } from '@invoke-ai/ui-library';
import { IconMenuItemGroup } from 'common/components/IconMenuItem';
import { ContextMenuItemChangeBoard } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard';
import { ContextMenuItemCopy } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemCopy';
import { ContextMenuItemDelete } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDelete';
import { ContextMenuItemDownload } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownload';
import { ContextMenuItemOpenInNewTab } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab';
import { ContextMenuItemOpenInViewer } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer';
@@ -10,6 +9,7 @@ import { ContextMenuItemSelectForCompare } from 'features/gallery/components/Con
import { ItemDTOContextProvider } from 'features/gallery/contexts/ItemDTOContext';
import type { VideoDTO } from 'services/api/types';
import { ContextMenuItemDeleteVideo } from './MenuItems/ContextMenuItemDeleteVideo';
import { ContextMenuItemStarUnstar } from './MenuItems/ContextMenuItemStarUnstar';
type SingleSelectionVideoMenuItemsProps = {
@@ -25,7 +25,7 @@ const SingleSelectionVideoMenuItems = ({ videoDTO }: SingleSelectionVideoMenuIte
<ContextMenuItemDownload />
<ContextMenuItemOpenInViewer />
<ContextMenuItemSelectForCompare />
<ContextMenuItemDelete />
<ContextMenuItemDeleteVideo />
</IconMenuItemGroup>
<MenuDivider />
<ContextMenuItemStarUnstar />

View File

@@ -1,6 +1,7 @@
import { Button, Divider, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
import { useDeleteVideo } from 'features/deleteImageModal/hooks/use-delete-video';
import { DeleteVideoButton } from 'features/deleteVideoModal/components/DeleteVideoButton';
import SingleSelectionVideoMenuItems from 'features/gallery/components/ContextMenu/SingleSelectionVideoMenuItems';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
@@ -12,7 +13,6 @@ import { memo, useCallback, useState } from 'react';
import { flushSync } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { PiCameraBold, PiCrosshairBold, PiDotsThreeOutlineFill, PiSpinnerBold } from 'react-icons/pi';
import { useDeleteVideosMutation } from 'services/api/endpoints/videos';
import type { VideoDTO } from 'services/api/types';
export const CurrentVideoButtons = memo(({ videoDTO }: { videoDTO: VideoDTO }) => {
@@ -21,7 +21,7 @@ export const CurrentVideoButtons = memo(({ videoDTO }: { videoDTO: VideoDTO }) =
const dispatch = useAppDispatch();
const activeTab = useAppSelector(selectActiveTab);
const galleryPanel = useGalleryPanel(activeTab);
const [deleteVideos] = useDeleteVideosMutation();
const deleteVideo = useDeleteVideo(videoDTO);
const captureVideoFrame = useCaptureVideoFrame();
const { videoRef } = useVideoViewerContext();
@@ -43,10 +43,6 @@ export const CurrentVideoButtons = memo(({ videoDTO }: { videoDTO: VideoDTO }) =
});
}, [dispatch, galleryPanel, videoDTO]);
const handleDelete = useCallback(() => {
deleteVideos({ video_ids: [videoDTO.video_id] });
}, [deleteVideos, videoDTO]);
const onClickSaveFrame = useCallback(async () => {
setCapturing(true);
await captureVideoFrame(videoRef.current);
@@ -98,20 +94,21 @@ export const CurrentVideoButtons = memo(({ videoDTO }: { videoDTO: VideoDTO }) =
<Divider orientation="vertical" h={8} mx={2} />
{doesTabHaveGallery && (
<IconButton
icon={<PiCrosshairBold />}
aria-label={t('boards.locateInGalery')}
tooltip={t('boards.locateInGalery')}
onClick={locateInGallery}
variant="link"
size="sm"
alignSelf="stretch"
/>
<>
<IconButton
icon={<PiCrosshairBold />}
aria-label={t('boards.locateInGalery')}
tooltip={t('boards.locateInGalery')}
onClick={locateInGallery}
variant="link"
size="sm"
alignSelf="stretch"
/>
<Divider orientation="vertical" h={8} mx={2} />
</>
)}
<Divider orientation="vertical" h={8} mx={2} />
<DeleteImageButton onClick={handleDelete} />
<DeleteVideoButton onClick={deleteVideo.delete} isDisabled={!deleteVideo.isEnabled} />
</>
);
});