feat(ui): restore dnd to workflow fields

This commit is contained in:
psychedelicious
2024-10-30 22:04:20 +10:00
parent 57122c6aa3
commit 6d7a486e5b
4 changed files with 277 additions and 102 deletions

View File

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

View File

@@ -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' };

View File

@@ -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>
);
};

View File

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