mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
feat(ui): restore dnd to workflow fields
This commit is contained in:
@@ -1,8 +1,5 @@
|
||||
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
// import { pointerOutsideOfPreview } from '@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview';
|
||||
// import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
|
||||
import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
||||
import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
||||
import { Box, Flex } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
@@ -10,29 +7,12 @@ import { useEntityIdentifierContext } from 'features/controlLayers/contexts/Enti
|
||||
import { useEntityIsSelected } from 'features/controlLayers/hooks/useEntityIsSelected';
|
||||
import { useEntitySelectionColor } from 'features/controlLayers/hooks/useEntitySelectionColor';
|
||||
import { entitySelected } from 'features/controlLayers/store/canvasSlice';
|
||||
import { Dnd } from 'features/dnd/dnd';
|
||||
import type { DndState } from 'features/dnd/dnd';
|
||||
import { Dnd, idle } from 'features/dnd/dnd';
|
||||
import { DndDropIndicator } from 'features/dnd/DndDropIndicator';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
type DndState =
|
||||
| {
|
||||
type: 'idle';
|
||||
}
|
||||
| {
|
||||
type: 'preview';
|
||||
container: HTMLElement;
|
||||
}
|
||||
| {
|
||||
type: 'is-dragging';
|
||||
}
|
||||
| {
|
||||
type: 'is-dragging-over';
|
||||
closestEdge: Edge | null;
|
||||
};
|
||||
|
||||
const idle: DndState = { type: 'idle' };
|
||||
|
||||
export const CanvasEntityContainer = memo((props: PropsWithChildren) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const entityIdentifier = useEntityIdentifierContext();
|
||||
@@ -58,18 +38,6 @@ export const CanvasEntityContainer = memo((props: PropsWithChildren) => {
|
||||
getInitialData() {
|
||||
return Dnd.Source.singleCanvasEntity.getData({ entityIdentifier });
|
||||
},
|
||||
// onGenerateDragPreview({ nativeSetDragImage }) {
|
||||
// setCustomNativeDragPreview({
|
||||
// nativeSetDragImage,
|
||||
// getOffset: pointerOutsideOfPreview({
|
||||
// x: '16px',
|
||||
// y: '8px',
|
||||
// }),
|
||||
// render({ container }) {
|
||||
// setState({ type: 'preview', container });
|
||||
// },
|
||||
// });
|
||||
// },
|
||||
onDragStart() {
|
||||
setDndState({ type: 'is-dragging' });
|
||||
},
|
||||
@@ -128,6 +96,7 @@ export const CanvasEntityContainer = memo((props: PropsWithChildren) => {
|
||||
return (
|
||||
<Box position="relative">
|
||||
<Flex
|
||||
// This is used to trigger the post-move flash animation
|
||||
data-entity-id={entityIdentifier.id}
|
||||
ref={ref}
|
||||
position="relative"
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import type { Input } from '@atlaskit/pragmatic-drag-and-drop/dist/types/entry-point/types';
|
||||
import type { GetOffsetFn } from '@atlaskit/pragmatic-drag-and-drop/dist/types/public-utils/element/custom-native-drag-preview/types';
|
||||
import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/closest-edge';
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
|
||||
import type { BoardId } from 'features/gallery/store/types';
|
||||
import type { FieldIdentifier } from 'features/nodes/types/field';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
import type { ValueOf } from 'type-fest';
|
||||
@@ -171,11 +173,16 @@ const multipleImage = buildDndSourceApi<{ imageDTOs: ImageDTO[]; boardId: BoardI
|
||||
* Dnd source API for a single canvas entity.
|
||||
*/
|
||||
const singleCanvasEntity = buildDndSourceApi<{ entityIdentifier: CanvasEntityIdentifier }>('SingleCanvasEntity');
|
||||
/**
|
||||
* Dnd source API for a single workflow field.
|
||||
*/
|
||||
const singleWorkflowField = buildDndSourceApi<{ fieldIdentifier: FieldIdentifier }>('SingleWorkflowField');
|
||||
|
||||
const DndSource = {
|
||||
singleImage,
|
||||
multipleImage,
|
||||
singleCanvasEntity,
|
||||
singleWorkflowField,
|
||||
} as const;
|
||||
|
||||
type SourceDataTypeMap = {
|
||||
@@ -528,3 +535,20 @@ export function triggerPostMoveFlash(element: HTMLElement, backgroundColor: CSSP
|
||||
iterations: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export type DndState =
|
||||
| {
|
||||
type: 'idle';
|
||||
}
|
||||
| {
|
||||
type: 'preview';
|
||||
container: HTMLElement;
|
||||
}
|
||||
| {
|
||||
type: 'is-dragging';
|
||||
}
|
||||
| {
|
||||
type: 'is-dragging-over';
|
||||
closestEdge: Edge | null;
|
||||
};
|
||||
export const idle: DndState = { type: 'idle' };
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Flex, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
||||
import { Box, Circle, Flex, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import NodeSelectionOverlay from 'common/components/NodeSelectionOverlay';
|
||||
import type { DndState } from 'features/dnd/dnd';
|
||||
import { Dnd, idle } from 'features/dnd/dnd';
|
||||
import { DndDropIndicator } from 'features/dnd/DndDropIndicator';
|
||||
import { InvocationInputFieldCheck } from 'features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck';
|
||||
import { useFieldOriginalValue } from 'features/nodes/hooks/useFieldOriginalValue';
|
||||
import { useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
|
||||
import { workflowExposedFieldRemoved } from 'features/nodes/store/workflowSlice';
|
||||
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowCounterClockwiseBold, PiDotsSixVerticalBold, PiInfoBold, PiTrashSimpleBold } from 'react-icons/pi';
|
||||
import { PiArrowCounterClockwiseBold, PiInfoBold, PiTrashSimpleBold } from 'react-icons/pi';
|
||||
|
||||
import EditableFieldTitle from './EditableFieldTitle';
|
||||
import FieldTooltipContent from './FieldTooltipContent';
|
||||
@@ -29,61 +34,131 @@ const LinearViewFieldInternal = ({ nodeId, fieldName }: Props) => {
|
||||
dispatch(workflowExposedFieldRemoved({ nodeId, fieldName }));
|
||||
}, [dispatch, fieldName, nodeId]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [dndState, setDndState] = useState<DndState>(idle);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
return combine(
|
||||
draggable({
|
||||
element,
|
||||
getInitialData() {
|
||||
return Dnd.Source.singleWorkflowField.getData({ fieldIdentifier: { nodeId, fieldName } });
|
||||
},
|
||||
onDragStart() {
|
||||
setDndState({ type: 'is-dragging' });
|
||||
},
|
||||
onDrop() {
|
||||
setDndState(idle);
|
||||
},
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element,
|
||||
canDrop({ source }) {
|
||||
if (!Dnd.Source.singleWorkflowField.typeGuard(source.data)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
getData({ input }) {
|
||||
const data = Dnd.Source.singleWorkflowField.getData({ fieldIdentifier: { nodeId, fieldName } });
|
||||
return attachClosestEdge(data, {
|
||||
element,
|
||||
input,
|
||||
allowedEdges: ['top', 'bottom'],
|
||||
});
|
||||
},
|
||||
getIsSticky() {
|
||||
return true;
|
||||
},
|
||||
onDragEnter({ self }) {
|
||||
const closestEdge = extractClosestEdge(self.data);
|
||||
setDndState({ type: 'is-dragging-over', closestEdge });
|
||||
},
|
||||
onDrag({ self }) {
|
||||
const closestEdge = extractClosestEdge(self.data);
|
||||
|
||||
// Only need to update react state if nothing has changed.
|
||||
// Prevents re-rendering.
|
||||
setDndState((current) => {
|
||||
if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) {
|
||||
return current;
|
||||
}
|
||||
return { type: 'is-dragging-over', closestEdge };
|
||||
});
|
||||
},
|
||||
onDragLeave() {
|
||||
setDndState(idle);
|
||||
},
|
||||
onDrop() {
|
||||
setDndState(idle);
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [fieldName, nodeId]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
onMouseEnter={handleMouseOver}
|
||||
onMouseLeave={handleMouseOut}
|
||||
layerStyle="second"
|
||||
alignItems="center"
|
||||
position="relative"
|
||||
borderRadius="base"
|
||||
w="full"
|
||||
p={4}
|
||||
paddingLeft={0}
|
||||
>
|
||||
<IconButton
|
||||
aria-label={t('nodes.reorderLinearView')}
|
||||
variant="ghost"
|
||||
icon={<PiDotsSixVerticalBold />}
|
||||
mx={2}
|
||||
height="full"
|
||||
/>
|
||||
<Flex flexDir="column" w="full">
|
||||
<Flex alignItems="center">
|
||||
<EditableFieldTitle nodeId={nodeId} fieldName={fieldName} kind="inputs" />
|
||||
<Spacer />
|
||||
{isValueChanged && (
|
||||
<Box position="relative" w="full">
|
||||
<Flex
|
||||
ref={ref}
|
||||
// This is used to trigger the post-move flash animation
|
||||
data-field-name={fieldName}
|
||||
onMouseEnter={handleMouseOver}
|
||||
onMouseLeave={handleMouseOut}
|
||||
layerStyle="second"
|
||||
alignItems="center"
|
||||
position="relative"
|
||||
borderRadius="base"
|
||||
w="full"
|
||||
p={2}
|
||||
>
|
||||
<Flex flexDir="column" w="full">
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<EditableFieldTitle nodeId={nodeId} fieldName={fieldName} kind="inputs" />
|
||||
<Spacer />
|
||||
{isMouseOverNode && <Circle size={2} borderRadius="full" bg="invokeBlue.500" />}
|
||||
{isValueChanged && (
|
||||
<IconButton
|
||||
aria-label={t('nodes.resetToDefaultValue')}
|
||||
tooltip={t('nodes.resetToDefaultValue')}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
icon={<PiArrowCounterClockwiseBold />}
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
label={<FieldTooltipContent nodeId={nodeId} fieldName={fieldName} kind="inputs" />}
|
||||
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
|
||||
placement="top"
|
||||
>
|
||||
<Flex h="full" alignItems="center">
|
||||
<Icon fontSize="sm" color="base.300" as={PiInfoBold} />
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
aria-label={t('nodes.resetToDefaultValue')}
|
||||
tooltip={t('nodes.resetToDefaultValue')}
|
||||
aria-label={t('nodes.removeLinearView')}
|
||||
tooltip={t('nodes.removeLinearView')}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
icon={<PiArrowCounterClockwiseBold />}
|
||||
onClick={handleRemoveField}
|
||||
icon={<PiTrashSimpleBold />}
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
label={<FieldTooltipContent nodeId={nodeId} fieldName={fieldName} kind="inputs" />}
|
||||
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
|
||||
placement="top"
|
||||
>
|
||||
<Flex h="full" alignItems="center">
|
||||
<Icon fontSize="sm" color="base.300" as={PiInfoBold} />
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
aria-label={t('nodes.removeLinearView')}
|
||||
tooltip={t('nodes.removeLinearView')}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemoveField}
|
||||
icon={<PiTrashSimpleBold />}
|
||||
/>
|
||||
</Flex>
|
||||
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
|
||||
</Flex>
|
||||
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
|
||||
<NodeSelectionOverlay isSelected={false} isHovered={isMouseOverNode} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
{dndState.type === 'is-dragging-over' && dndState.closestEdge ? (
|
||||
<DndDropIndicator
|
||||
edge={dndState.closestEdge}
|
||||
// This is the gap between items in the list
|
||||
gap="var(--invoke-space-2)"
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
||||
import { reorderWithEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge';
|
||||
import { Box, Flex } from '@invoke-ai/ui-library';
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
|
||||
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
|
||||
import { Dnd, triggerPostMoveFlash } from 'features/dnd/dnd';
|
||||
import LinearViewFieldInternal from 'features/nodes/components/flow/nodes/Invocation/fields/LinearViewField';
|
||||
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
|
||||
import { memo } from 'react';
|
||||
import { selectWorkflowSlice, workflowExposedFieldsReordered } from 'features/nodes/store/workflowSlice';
|
||||
import type { FieldIdentifier } from 'features/nodes/types/field';
|
||||
import { memo, useEffect } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
|
||||
|
||||
const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => workflow.exposedFields);
|
||||
|
||||
const WorkflowLinearTab = () => {
|
||||
const fields = useAppSelector(selector);
|
||||
const { isLoading } = useGetOpenAPISchemaQuery();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box position="relative" w="full" h="full">
|
||||
<ScrollableContent>
|
||||
<Flex position="relative" flexDir="column" alignItems="flex-start" p={1} gap={2} h="full" w="full">
|
||||
{isLoading ? (
|
||||
<IAINoContentFallback label={t('nodes.loadingNodes')} icon={null} />
|
||||
) : fields.length ? (
|
||||
fields.map(({ nodeId, fieldName }) => (
|
||||
<LinearViewFieldInternal key={`${nodeId}.${fieldName}`} nodeId={nodeId} fieldName={fieldName} />
|
||||
))
|
||||
) : (
|
||||
<IAINoContentFallback label={t('nodes.noFieldsLinearview')} icon={null} />
|
||||
)}
|
||||
<FieldListContent />
|
||||
</Flex>
|
||||
</ScrollableContent>
|
||||
</Box>
|
||||
@@ -36,3 +31,115 @@ const WorkflowLinearTab = () => {
|
||||
};
|
||||
|
||||
export default memo(WorkflowLinearTab);
|
||||
|
||||
const FieldListContent = memo(() => {
|
||||
const fields = useAppSelector(selector);
|
||||
const { isLoading } = useGetOpenAPISchemaQuery();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isLoading) {
|
||||
return <IAINoContentFallback label={t('nodes.loadingNodes')} icon={null} />;
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
<IAINoContentFallback label={t('nodes.noFieldsLinearview')} icon={null} />;
|
||||
}
|
||||
|
||||
return <FieldListInnerContent fields={fields} />;
|
||||
});
|
||||
|
||||
FieldListContent.displayName = 'FieldListContent';
|
||||
|
||||
const FieldListInnerContent = memo(({ fields }: { fields: FieldIdentifier[] }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
return monitorForElements({
|
||||
canMonitor({ source }) {
|
||||
if (!Dnd.Source.singleWorkflowField.typeGuard(source.data)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
onDrop({ location, source }) {
|
||||
const target = location.current.dropTargets[0];
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceData = source.data;
|
||||
const targetData = target.data;
|
||||
|
||||
if (
|
||||
!Dnd.Source.singleWorkflowField.typeGuard(sourceData) ||
|
||||
!Dnd.Source.singleWorkflowField.typeGuard(targetData)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const indexOfSource = fields.findIndex(
|
||||
(fieldIdentifier) => fieldIdentifier.fieldName === sourceData.payload.fieldIdentifier.fieldName
|
||||
);
|
||||
const indexOfTarget = fields.findIndex(
|
||||
(fieldIdentifier) => fieldIdentifier.fieldName === targetData.payload.fieldIdentifier.fieldName
|
||||
);
|
||||
|
||||
if (indexOfTarget < 0 || indexOfSource < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't move if the source and target are the same index, meaning same position in the list
|
||||
if (indexOfSource === indexOfTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const closestEdgeOfTarget = extractClosestEdge(targetData);
|
||||
|
||||
// It's possible that the indices are different, but refer to the same position. For example, if the source is
|
||||
// at 2 and the target is at 3, but the target edge is 'top', then the entity is already in the correct position.
|
||||
// We should bail if this is the case.
|
||||
let edgeIndexDelta = 0;
|
||||
|
||||
if (closestEdgeOfTarget === 'bottom') {
|
||||
edgeIndexDelta = 1;
|
||||
} else if (closestEdgeOfTarget === 'top') {
|
||||
edgeIndexDelta = -1;
|
||||
}
|
||||
|
||||
// If the source is already in the correct position, we don't need to move it.
|
||||
if (indexOfSource === indexOfTarget + edgeIndexDelta) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reorderedFields = reorderWithEdge({
|
||||
list: fields,
|
||||
startIndex: indexOfSource,
|
||||
indexOfTarget,
|
||||
closestEdgeOfTarget,
|
||||
axis: 'vertical',
|
||||
});
|
||||
|
||||
// Using `flushSync` so we can query the DOM straight after this line
|
||||
flushSync(() => {
|
||||
dispatch(workflowExposedFieldsReordered(reorderedFields));
|
||||
});
|
||||
|
||||
// Flash the element that was moved
|
||||
const element = document.querySelector(`[data-field-name="${sourceData.payload.fieldIdentifier.fieldName}"]`);
|
||||
if (element instanceof HTMLElement) {
|
||||
triggerPostMoveFlash(element, colorTokenToCssVar('base.700'));
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [dispatch, fields]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{fields.map(({ nodeId, fieldName }) => (
|
||||
<LinearViewFieldInternal key={`${nodeId}.${fieldName}`} nodeId={nodeId} fieldName={fieldName} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
FieldListInnerContent.displayName = 'FieldListInnerContent';
|
||||
|
||||
Reference in New Issue
Block a user