feat(ui): double click to zoom to node

Requires a bit of fanagling to ensure the double click doesn't interfer w/ other stuff
This commit is contained in:
psychedelicious
2025-02-20 12:54:24 +10:00
parent f1bc2ea962
commit 5653352ae8
7 changed files with 91 additions and 34 deletions

View File

@@ -1,5 +1,5 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, FormControl, Spacer } from '@invoke-ai/ui-library';
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { InputFieldDescriptionPopover } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldDescriptionPopover';
import { InputFieldHandle } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldHandle';
import { InputFieldResetToDefaultValueIconButton } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldResetToDefaultValueIconButton';
@@ -57,12 +57,12 @@ type CommonProps = {
fieldTemplate: FieldInputTemplate;
};
const ConnectedOrConnectionField = memo(({ nodeId, fieldName, isInvalid, isConnected }: CommonProps) => {
const ConnectedOrConnectionField = memo(({ nodeId, fieldName, isInvalid }: CommonProps) => {
return (
<InputFieldWrapper>
<FormControl isInvalid={isInvalid} isDisabled={isConnected} px={2}>
<Flex px={2}>
<InputFieldTitle nodeId={nodeId} fieldName={fieldName} isInvalid={isInvalid} />
</FormControl>
</Flex>
<InputFieldHandle nodeId={nodeId} fieldName={fieldName} />
</InputFieldWrapper>
);
@@ -70,8 +70,10 @@ const ConnectedOrConnectionField = memo(({ nodeId, fieldName, isInvalid, isConne
ConnectedOrConnectionField.displayName = 'ConnectedOrConnectionField';
const directFieldSx: SystemStyleObject = {
orientation: 'vertical',
w: 'full',
px: 2,
flexDir: 'column',
gap: 1,
'&[data-is-dragging="true"]': {
opacity: 0.3,
},
@@ -100,31 +102,28 @@ const DirectField = memo(({ nodeId, fieldName, isInvalid, isConnected, fieldTemp
return (
<InputFieldWrapper>
<FormControl
<Flex
ref={draggableRef}
isInvalid={isInvalid}
isDisabled={isConnected}
sx={directFieldSx}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
data-is-connected={isConnected}
data-is-dragging={isDragging}
>
<Flex flexDir="column" w="full" gap={1} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<Flex gap={1}>
<Flex className="nodrag" ref={dragHandleRef}>
<InputFieldTitle nodeId={nodeId} fieldName={fieldName} isInvalid={isInvalid} isDragging={isDragging} />
</Flex>
<Spacer />
{isHovered && (
<>
<InputFieldDescriptionPopover nodeId={nodeId} fieldName={fieldName} />
<InputFieldResetToDefaultValueIconButton nodeId={nodeId} fieldName={fieldName} />
</>
)}
<Flex gap={1}>
<Flex ref={dragHandleRef}>
<InputFieldTitle nodeId={nodeId} fieldName={fieldName} isInvalid={isInvalid} isDragging={isDragging} />
</Flex>
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
<Spacer />
{isHovered && (
<>
<InputFieldDescriptionPopover nodeId={nodeId} fieldName={fieldName} />
<InputFieldResetToDefaultValueIconButton nodeId={nodeId} fieldName={fieldName} />
</>
)}
</Flex>
</FormControl>
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
</Flex>
{fieldTemplate.input !== 'direct' && <InputFieldHandle nodeId={nodeId} fieldName={fieldName} />}
</InputFieldWrapper>
);

View File

@@ -12,7 +12,8 @@ import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsCo
import { useInputFieldLabel } from 'features/nodes/hooks/useInputFieldLabel';
import { useInputFieldTemplateTitle } from 'features/nodes/hooks/useInputFieldTemplateTitle';
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import { HANDLE_TOOLTIP_OPEN_DELAY, NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
import type { MouseEvent } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -65,6 +66,19 @@ export const InputFieldTitle = memo((props: Props) => {
inputRef,
});
const isDisabled = useMemo(
() => (isConnectionInProgress && connectionError !== null && !isConnectionStartField) || isConnected,
[isConnectionInProgress, connectionError, isConnectionStartField, isConnected]
);
const onDoubleClick = useCallback(
(e: MouseEvent<HTMLParagraphElement>) => {
e.stopPropagation();
editable.startEditing();
},
[editable]
);
if (!editable.isEditing) {
return (
<Tooltip
@@ -74,13 +88,12 @@ export const InputFieldTitle = memo((props: Props) => {
isDisabled={isDragging}
>
<Text
className={`nodrag ${NO_FIT_ON_DOUBLE_CLICK_CLASS}`}
sx={labelSx}
noOfLines={1}
data-is-invalid={isInvalid}
data-is-disabled={
(isConnectionInProgress && connectionError !== null && !isConnectionStartField) || isConnected
}
onDoubleClick={editable.startEditing}
data-is-disabled={isDisabled}
onDoubleClick={onDoubleClick}
>
{editable.value}
</Text>
@@ -88,7 +101,15 @@ export const InputFieldTitle = memo((props: Props) => {
);
}
return <Input ref={inputRef} variant="outline" {...editable.inputProps} />;
return (
<Input
ref={inputRef}
className="nodrag"
variant="outline"
{...editable.inputProps}
_focusVisible={{ borderRadius: 'base', h: 'unset', px: 2 }}
/>
);
});
InputFieldTitle.displayName = 'InputFieldTitle';

View File

@@ -2,6 +2,7 @@ import type { ComboboxOnChange } from '@invoke-ai/ui-library';
import { Combobox, FormControl } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { fieldSchedulerValueChanged } from 'features/nodes/store/nodesSlice';
import { NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
import type { SchedulerFieldInputInstance, SchedulerFieldInputTemplate } from 'features/nodes/types/field';
import { SCHEDULER_OPTIONS } from 'features/parameters/types/constants';
import { isParameterScheduler } from 'features/parameters/types/parameterSchemas';
@@ -34,7 +35,7 @@ const SchedulerFieldInputComponent = (props: Props) => {
const value = useMemo(() => SCHEDULER_OPTIONS.find((o) => o.value === field?.value), [field?.value]);
return (
<FormControl className="nowheel nodrag">
<FormControl className={`nowheel nodrag ${NO_FIT_ON_DOUBLE_CLICK_CLASS}`}>
<Combobox value={value} options={SCHEDULER_OPTIONS} onChange={onChange} />
</FormControl>
);

View File

@@ -3,6 +3,7 @@ import { Icon, IconButton } from '@invoke-ai/ui-library';
import { useUpdateNodeInternals } from '@xyflow/react';
import { useAppDispatch } from 'app/store/storeHooks';
import { nodeIsOpenChanged } from 'features/nodes/store/nodesSlice';
import { NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
import { memo, useCallback } from 'react';
import { PiCaretUpBold } from 'react-icons/pi';
@@ -31,7 +32,7 @@ const NodeCollapseButton = ({ nodeId, isOpen }: Props) => {
return (
<IconButton
className="nodrag"
className={`nodrag ${NO_FIT_ON_DOUBLE_CLICK_CLASS}`}
onClick={handleClick}
aria-label="Minimize"
minW={8}

View File

@@ -6,6 +6,7 @@ import { useBatchGroupId } from 'features/nodes/hooks/useBatchGroupId';
import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel';
import { useNodeTemplateTitle } from 'features/nodes/hooks/useNodeTemplateTitle';
import { nodeLabelChanged } from 'features/nodes/store/nodesSlice';
import { NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -50,7 +51,12 @@ const NodeTitle = ({ nodeId, title }: Props) => {
return (
<Flex overflow="hidden" w="full" h="full" alignItems="center" justifyContent="center">
{!editable.isEditing && (
<Text fontWeight="semibold" color={batchGroupColorToken} onDoubleClick={editable.startEditing}>
<Text
className={NO_FIT_ON_DOUBLE_CLICK_CLASS}
fontWeight="semibold"
color={batchGroupColorToken}
onDoubleClick={editable.startEditing}
>
{titleWithBatchGroupId}
</Text>
)}

View File

@@ -1,13 +1,13 @@
import type { ChakraProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, useGlobalMenuClose } from '@invoke-ai/ui-library';
import type { NodeChange } from '@xyflow/react';
import { type NodeChange, useReactFlow } from '@xyflow/react';
import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
import { useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import { nodesChanged } from 'features/nodes/store/nodesSlice';
import { selectNodes } from 'features/nodes/store/selectors';
import { selectNodeOpacity } from 'features/nodes/store/workflowSettingsSlice';
import { DRAG_HANDLE_CLASSNAME, NODE_WIDTH } from 'features/nodes/types/constants';
import { DRAG_HANDLE_CLASSNAME, NO_FIT_ON_DOUBLE_CLICK_CLASS, NODE_WIDTH } from 'features/nodes/types/constants';
import type { AnyNode } from 'features/nodes/types/invocation';
import { zNodeStatus } from 'features/nodes/types/invocation';
import type { MouseEvent, PropsWithChildren } from 'react';
@@ -84,6 +84,7 @@ const NodeWrapper = (props: NodeWrapperProps) => {
const { nodeId, width, children, selected } = props;
const store = useAppStore();
const { isMouseOverNode, handleMouseOut, handleMouseOver } = useMouseOverNode(nodeId);
const flow = useReactFlow();
const executionState = useNodeExecutionState(nodeId);
const isInProgress = executionState?.status === zNodeStatus.enum.IN_PROGRESS;
@@ -112,9 +113,35 @@ const NodeWrapper = (props: NodeWrapperProps) => {
[onCloseGlobal, store, dispatch, nodeId]
);
const onDoubleClick = useCallback(
(e: MouseEvent) => {
if (!(e.target instanceof HTMLElement)) {
// We have to manually narrow the type here thanks to a TS quirk
return;
}
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement ||
e.target instanceof HTMLButtonElement ||
e.target instanceof HTMLAnchorElement
) {
// Don't fit the view if the user is editing a text field, select, button, or link
return;
}
if (e.target.closest(`.${NO_FIT_ON_DOUBLE_CLICK_CLASS}`) !== null) {
// This target is marked as not fitting the view on double click
return;
}
flow.fitView({ duration: 300, maxZoom: 1.5, nodes: [{ id: nodeId }] });
},
[flow, nodeId]
);
return (
<Box
onClick={handleClick}
onDoubleClick={onDoubleClick}
onMouseEnter={handleMouseOver}
onMouseLeave={handleMouseOut}
className={DRAG_HANDLE_CLASSNAME}

View File

@@ -23,6 +23,8 @@ export const SHARED_NODE_PROPERTIES: Partial<AnyNode> = {
dragHandle: `.${DRAG_HANDLE_CLASSNAME}`,
};
export const NO_FIT_ON_DOUBLE_CLICK_CLASS = 'no-fit-on-double-click';
/**
* Colors for each field type - applies to their handles and edges.
*/