feat(ui): video dnd

This commit is contained in:
psychedelicious
2025-08-22 20:26:40 +10:00
committed by Mary Hipp Rogers
parent 84dc4e4ea9
commit f5fdba795a
8 changed files with 97 additions and 16 deletions

View File

@@ -1219,6 +1219,10 @@
"height": "Height",
"imageFit": "Fit Initial Image To Output Size",
"images": "Images",
"images_withCount_one": "Image",
"images_withCount_other": "Images",
"videos_withCount_one": "Video",
"videos_withCount_other": "Videos",
"infillMethod": "Infill Method",
"infillColorValue": "Fill Color",
"info": "Info",

View File

@@ -21,7 +21,7 @@ const DndDragPreviewMultipleImage = memo(({ image_names }: { image_names: string
borderRadius="base"
>
<Heading>{image_names.length}</Heading>
<Heading size="sm">{t('parameters.images')}</Heading>
<Heading size="sm">{t('parameters.images_withCount', { count: image_names.length })}</Heading>
</Flex>
);
});

View File

@@ -8,7 +8,7 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { Param0 } from 'tsafe';
const DndDragPreviewMultipleVideo = memo(({ ids }: { ids: string[] }) => {
const DndDragPreviewMultipleVideo = memo(({ video_ids }: { video_ids: string[] }) => {
const { t } = useTranslation();
return (
<Flex
@@ -20,8 +20,8 @@ const DndDragPreviewMultipleVideo = memo(({ ids }: { ids: string[] }) => {
bg="base.900"
borderRadius="base"
>
<Heading>{ids.length}</Heading>
<Heading size="sm">{t('parameters.videos')}</Heading>
<Heading>{video_ids.length}</Heading>
<Heading size="sm">{t('parameters.videos_withCount', { count: video_ids.length })}</Heading>
</Flex>
);
});
@@ -31,11 +31,11 @@ DndDragPreviewMultipleVideo.displayName = 'DndDragPreviewMultipleVideo';
export type DndDragPreviewMultipleVideoState = {
type: 'multiple-video';
container: HTMLElement;
ids: string[];
video_ids: string[];
};
export const createMultipleVideoDragPreview = (arg: DndDragPreviewMultipleVideoState) =>
createPortal(<DndDragPreviewMultipleVideo ids={arg.ids} />, arg.container);
createPortal(<DndDragPreviewMultipleVideo video_ids={arg.video_ids} />, arg.container);
type SetMultipleDragPreviewArg = {
multipleVideoDndData: MultipleVideoDndSourceData;
@@ -51,7 +51,7 @@ export const setMultipleVideoDragPreview = ({
const { nativeSetDragImage, source, location } = onGenerateDragPreviewArgs;
setCustomNativeDragPreview({
render({ container }) {
setDragPreviewState({ type: 'multiple-video', container, ids: multipleVideoDndData.payload.ids });
setDragPreviewState({ type: 'multiple-video', container, video_ids: multipleVideoDndData.payload.video_ids });
return () => setDragPreviewState(null);
},
nativeSetDragImage,

View File

@@ -3,6 +3,7 @@ import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/el
import { chakra, Flex, Text } from '@invoke-ai/ui-library';
import type { SingleVideoDndSourceData } from 'features/dnd/dnd';
import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/util';
import { GalleryVideoPlaceholder } from 'features/gallery/components/ImageGrid/GalleryVideo';
import { memo } from 'react';
import { createPortal } from 'react-dom';
import type { VideoDTO } from 'services/api/types';
@@ -12,14 +13,19 @@ const ChakraImg = chakra('img');
const DndDragPreviewSingleVideo = memo(({ videoDTO }: { videoDTO: VideoDTO }) => {
return (
<Flex w={DND_IMAGE_DRAG_PREVIEW_SIZE} h={DND_IMAGE_DRAG_PREVIEW_SIZE} bg="cyan">
<Text color="base.900">I AM A VIDEO</Text>
<Flex position="relative" w={DND_IMAGE_DRAG_PREVIEW_SIZE} h={DND_IMAGE_DRAG_PREVIEW_SIZE}>
<GalleryVideoPlaceholder />
<ChakraImg
position="absolute"
margin="auto"
maxW="full"
maxH="full"
objectFit="contain"
borderRadius="base"
borderWidth={2}
borderColor="invokeBlue.300"
borderStyle="solid"
cursor="grabbing"
src={videoDTO.thumbnail_url}
/>
</Flex>

View File

@@ -9,9 +9,11 @@ import { selectComparisonImages } from 'features/gallery/components/ImageViewer/
import type { BoardId } from 'features/gallery/store/types';
import {
addImagesToBoard,
addVideosToBoard,
createNewCanvasEntityFromImage,
newCanvasFromImage,
removeImagesFromBoard,
removeVideosFromBoard,
replaceCanvasEntityObjectsWithImage,
setComparisonImage,
setGlobalReferenceImage,
@@ -91,7 +93,7 @@ const _multipleVideo = buildTypeAndKey('multiple-video');
export type MultipleVideoDndSourceData = DndData<
typeof _multipleVideo.type,
typeof _multipleVideo.key,
{ ids: string[]; board_id: BoardId }
{ video_ids: string[]; board_id: BoardId }
>;
export const multipleVideoDndSource: DndSource<MultipleVideoDndSourceData> = {
..._multipleVideo,
@@ -473,12 +475,22 @@ export type AddImageToBoardDndTargetData = DndData<
>;
export const addImageToBoardDndTarget: DndTarget<
AddImageToBoardDndTargetData,
SingleImageDndSourceData | MultipleImageDndSourceData
SingleImageDndSourceData | MultipleImageDndSourceData | SingleVideoDndSourceData | MultipleVideoDndSourceData
> = {
..._addToBoard,
typeGuard: buildTypeGuard(_addToBoard.key),
getData: buildGetData(_addToBoard.key, _addToBoard.type),
isValid: ({ sourceData, targetData }) => {
if (singleVideoDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.videoDTO.board_id ?? 'none';
const destinationBoard = targetData.payload.boardId;
return currentBoard !== destinationBoard;
}
if (multipleVideoDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.board_id;
const destinationBoard = targetData.payload.boardId;
return currentBoard !== destinationBoard;
}
if (singleImageDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none';
const destinationBoard = targetData.payload.boardId;
@@ -492,6 +504,18 @@ export const addImageToBoardDndTarget: DndTarget<
return false;
},
handler: ({ sourceData, targetData, dispatch }) => {
if (singleVideoDndSource.typeGuard(sourceData)) {
const { videoDTO } = sourceData.payload;
const { boardId } = targetData.payload;
addVideosToBoard({ video_ids: [videoDTO.video_id], boardId, dispatch });
}
if (multipleVideoDndSource.typeGuard(sourceData)) {
const { video_ids } = sourceData.payload;
const { boardId } = targetData.payload;
addVideosToBoard({ video_ids, boardId, dispatch });
}
if (singleImageDndSource.typeGuard(sourceData)) {
const { imageDTO } = sourceData.payload;
const { boardId } = targetData.payload;
@@ -517,7 +541,7 @@ export type RemoveImageFromBoardDndTargetData = DndData<
>;
export const removeImageFromBoardDndTarget: DndTarget<
RemoveImageFromBoardDndTargetData,
SingleImageDndSourceData | MultipleImageDndSourceData
SingleImageDndSourceData | MultipleImageDndSourceData | SingleVideoDndSourceData | MultipleVideoDndSourceData
> = {
..._removeFromBoard,
typeGuard: buildTypeGuard(_removeFromBoard.key),
@@ -533,6 +557,16 @@ export const removeImageFromBoardDndTarget: DndTarget<
return currentBoard !== 'none';
}
if (singleVideoDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.videoDTO.board_id ?? 'none';
return currentBoard !== 'none';
}
if (multipleVideoDndSource.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.board_id;
return currentBoard !== 'none';
}
return false;
},
handler: ({ sourceData, dispatch }) => {
@@ -545,6 +579,16 @@ export const removeImageFromBoardDndTarget: DndTarget<
const { image_names } = sourceData.payload;
removeImagesFromBoard({ image_names, dispatch });
}
if (singleVideoDndSource.typeGuard(sourceData)) {
const { videoDTO } = sourceData.payload;
removeVideosFromBoard({ video_ids: [videoDTO.video_id], dispatch });
}
if (multipleVideoDndSource.typeGuard(sourceData)) {
const { video_ids } = sourceData.payload;
removeVideosFromBoard({ video_ids, dispatch });
}
},
};

View File

@@ -4,7 +4,13 @@ import { logger } from 'app/logging/logger';
import { getStore } from 'app/store/nanostores/store';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { parseify } from 'common/util/serialize';
import { dndTargets, multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd';
import {
dndTargets,
multipleImageDndSource,
multipleVideoDndSource,
singleImageDndSource,
singleVideoDndSource,
} from 'features/dnd/dnd';
import { useEffect } from 'react';
const log = logger('dnd');
@@ -19,7 +25,12 @@ export const useDndMonitor = () => {
const sourceData = source.data;
// Check for allowed sources
if (!singleImageDndSource.typeGuard(sourceData) && !multipleImageDndSource.typeGuard(sourceData)) {
if (
!singleImageDndSource.typeGuard(sourceData) &&
!multipleImageDndSource.typeGuard(sourceData) &&
!singleVideoDndSource.typeGuard(sourceData) &&
!multipleVideoDndSource.typeGuard(sourceData)
) {
return false;
}

View File

@@ -112,7 +112,7 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => {
// multi-image drag.
if (selection.length > 1 && selection.some((s) => s.id === videoDTO.video_id)) {
return multipleVideoDndSource.getData({
ids: selection.map((s) => s.id),
video_ids: selection.map((s) => s.id),
board_id: boardId,
});
} // Otherwise, initiate a single-image drag
@@ -149,7 +149,10 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => {
onDragStart: ({ source }) => {
// When we start dragging multiple images, set the dragging state to true if the dragged image is part of the
// selection. This is called for all drag events.
if (multipleVideoDndSource.typeGuard(source.data) && source.data.payload.ids.includes(videoDTO.video_id)) {
if (
multipleVideoDndSource.typeGuard(source.data) &&
source.data.payload.video_ids.includes(videoDTO.video_id)
) {
setIsDragging(true);
}
},

View File

@@ -37,6 +37,7 @@ import { getOptimalDimension } from 'features/parameters/util/optimalDimension';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
import { imageDTOToFile, imagesApi, uploadImage } from 'services/api/endpoints/images';
import { videosApi } from 'services/api/endpoints/videos';
import type { ImageDTO } from 'services/api/types';
import type { Equals } from 'tsafe';
import { assert } from 'tsafe';
@@ -318,3 +319,15 @@ export const removeImagesFromBoard = (arg: { image_names: string[]; dispatch: Ap
dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ image_names }, { track: false }));
dispatch(selectionChanged([]));
};
export const addVideosToBoard = (arg: { video_ids: string[]; boardId: BoardId; dispatch: AppDispatch }) => {
const { video_ids, boardId, dispatch } = arg;
dispatch(videosApi.endpoints.addVideosToBoard.initiate({ video_ids, board_id: boardId }, { track: false }));
dispatch(selectionChanged([]));
};
export const removeVideosFromBoard = (arg: { video_ids: string[]; dispatch: AppDispatch }) => {
const { video_ids, dispatch } = arg;
dispatch(videosApi.endpoints.removeVideosFromBoard.initiate({ video_ids }, { track: false }));
dispatch(selectionChanged([]));
};