feat(ui): iterate on builder (WIP)

This commit is contained in:
psychedelicious
2025-01-23 16:35:57 +11:00
parent 1f995d0257
commit 9f07e83a23
13 changed files with 499 additions and 124 deletions

View File

@@ -1,9 +0,0 @@
import type { ContainerElement } from 'features/nodes/types/workflow';
import { createContext, useContext } from 'react';
export const ContainerContext = createContext<ContainerElement['data'] | null>(null);
export const useContainerContext = () => {
const containerDirection = useContext(ContainerContext);
return containerDirection;
};

View File

@@ -1,63 +1,120 @@
import { Flex, type SystemStyleObject } from '@invoke-ai/ui-library';
import { ContainerContext } from 'features/nodes/components/sidePanel/builder/ContainerContext';
import { Flex, IconButton, type SystemStyleObject } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { ContainerContext, DepthContext } from 'features/nodes/components/sidePanel/builder/contexts';
import { DividerElementComponent } from 'features/nodes/components/sidePanel/builder/DividerElementComponent';
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
import { HeadingElementComponent } from 'features/nodes/components/sidePanel/builder/HeadingElementComponent';
import { NodeFieldElementComponent } from 'features/nodes/components/sidePanel/builder/NodeFieldElementComponent';
import { TextElementComponent } from 'features/nodes/components/sidePanel/builder/TextElementComponent';
import { useElement } from 'features/nodes/store/workflowSlice';
import { formElementAdded, selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice';
import type { ContainerElement } from 'features/nodes/types/workflow';
import {
container,
CONTAINER_CLASS_NAME,
DIVIDER_CLASS_NAME,
isContainerElement,
isDividerElement,
isHeadingElement,
isNodeFieldElement,
isTextElement,
} from 'features/nodes/types/workflow';
import { memo } from 'react';
import { memo, useCallback, useContext } from 'react';
import { PiPlusBold } from 'react-icons/pi';
import type { Equals } from 'tsafe';
import { assert } from 'tsafe';
const sx: SystemStyleObject = {
gap: 4,
flex: '1 1 0',
'&[data-container-direction="column"]': {
flexDir: 'column',
'> :last-child': {
flex: '1 0 0',
alignItems: 'flex-start',
},
},
'&[data-container-direction="row"]': {
'> *': {
flex: '1 1 0',
},
},
[`& > .${DIVIDER_CLASS_NAME}`]: {
flex: '0 0 1px',
flexDir: 'row',
},
};
export const ContainerElementComponent = memo(({ id }: { id: string }) => {
const el = useElement(id);
const mode = useAppSelector(selectWorkflowFormMode);
if (!el || !isContainerElement(el)) {
return null;
}
const { children, direction } = el.data;
if (mode === 'view') {
return <ContainerElementComponentViewMode el={el} />;
}
return (
<ContainerContext.Provider value={el.data}>
<Flex id={id} className={CONTAINER_CLASS_NAME} sx={sx} data-container-direction={direction}>
{children.map((childId) => (
<FormElementComponent key={childId} id={childId} />
))}
</Flex>
</ContainerContext.Provider>
);
// mode === 'edit'
return <ContainerElementComponentEditMode el={el} />;
});
ContainerElementComponent.displayName = 'ContainerElementComponent';
export const ContainerElementComponentViewMode = memo(({ el }: { el: ContainerElement }) => {
const depth = useContext(DepthContext);
const { id, data } = el;
const { children, direction } = data;
return (
<DepthContext.Provider value={depth + 1}>
<ContainerContext.Provider value={data}>
<Flex id={id} className={CONTAINER_CLASS_NAME} sx={sx} data-container-direction={direction}>
{children.map((childId) => (
<FormElementComponent key={childId} id={childId} />
))}
</Flex>
</ContainerContext.Provider>{' '}
</DepthContext.Provider>
);
});
ContainerElementComponentViewMode.displayName = 'ContainerElementComponentViewMode';
export const ContainerElementComponentEditMode = memo(({ el }: { el: ContainerElement }) => {
const depth = useContext(DepthContext);
const { id, data } = el;
const { children, direction } = data;
return (
<FormElementEditModeWrapper element={el}>
<DepthContext.Provider value={depth + 1}>
<ContainerContext.Provider value={data}>
<Flex id={id} className={CONTAINER_CLASS_NAME} sx={sx} data-container-direction={direction}>
{children.map((childId) => (
<FormElementComponent key={childId} id={childId} />
))}
{direction === 'row' && children.length < 3 && depth < 2 && <AddColumnButton containerId={id} />}
{direction === 'column' && depth < 1 && <AddRowButton containerId={id} />}
</Flex>
</ContainerContext.Provider>
</DepthContext.Provider>
</FormElementEditModeWrapper>
);
});
ContainerElementComponentEditMode.displayName = 'ContainerElementComponentEditMode';
const AddColumnButton = ({ containerId }: { containerId: string }) => {
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
const element = container('column', []);
dispatch(formElementAdded({ element, containerId }));
}, [containerId, dispatch]);
return (
<IconButton onClick={onClick} aria-label="add column" icon={<PiPlusBold />} h="unset" variant="ghost" size="sm" />
);
};
const AddRowButton = ({ containerId }: { containerId: string }) => {
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
const element = container('row', []);
dispatch(formElementAdded({ element, containerId }));
}, [containerId, dispatch]);
return (
<IconButton onClick={onClick} aria-label="add row" icon={<PiPlusBold />} w="unset" variant="ghost" size="sm" />
);
};
// TODO(psyche): Can we move this into a separate file and avoid circular dependencies between it and ContainerElementComponent?
export const FormElementComponent = memo(({ id }: { id: string }) => {
const el = useElement(id);

View File

@@ -1,22 +1,74 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex } from '@invoke-ai/ui-library';
import { useElement } from 'features/nodes/store/workflowSlice';
import { useAppSelector } from 'app/store/storeHooks';
import { ContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
import { selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice';
import type { DividerElement } from 'features/nodes/types/workflow';
import { DIVIDER_CLASS_NAME, isDividerElement } from 'features/nodes/types/workflow';
import { memo } from 'react';
import { memo, useContext } from 'react';
const sx: SystemStyleObject = {
bg: 'base.700',
flexShrink: 0,
'&[data-orientation="horizontal"]': {
width: '100%',
height: '1px',
},
'&[data-orientation="vertical"]': {
height: '100%',
width: '1px',
},
};
export const DividerElementComponent = memo(({ id }: { id: string }) => {
const el = useElement(id);
const mode = useAppSelector(selectWorkflowFormMode);
if (!el || !isDividerElement(el)) {
return;
}
return <Flex id={id} className={DIVIDER_CLASS_NAME} sx={sx} />;
if (mode === 'view') {
return <DividerElementComponentViewMode el={el} />;
}
// mode === 'edit'
return <DividerElementComponentEditMode el={el} />;
});
DividerElementComponent.displayName = 'DividerElementComponent';
export const DividerElementComponentViewMode = memo(({ el }: { el: DividerElement }) => {
const container = useContext(ContainerContext);
const { id } = el;
return (
<Flex
id={id}
className={DIVIDER_CLASS_NAME}
sx={sx}
data-orientation={container?.direction === 'column' ? 'horizontal' : 'vertical'}
/>
);
});
DividerElementComponentViewMode.displayName = 'DividerElementComponentViewMode';
export const DividerElementComponentEditMode = memo(({ el }: { el: DividerElement }) => {
const container = useContext(ContainerContext);
const { id } = el;
return (
<FormElementEditModeWrapper element={el}>
<Flex
id={id}
className={DIVIDER_CLASS_NAME}
sx={sx}
data-orientation={container?.direction === 'column' ? 'horizontal' : 'vertical'}
/>
</FormElementEditModeWrapper>
);
});
DividerElementComponentEditMode.displayName = 'DividerElementComponentEditMode';

View File

@@ -0,0 +1,87 @@
import { Flex, type FlexProps, IconButton, Spacer, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { DepthContext } from 'features/nodes/components/sidePanel/builder/contexts';
import { formElementRemoved } from 'features/nodes/store/workflowSlice';
import { type FormElement, isContainerElement } from 'features/nodes/types/workflow';
import { startCase } from 'lodash-es';
import { memo, useCallback, useContext } from 'react';
import { PiXBold } from 'react-icons/pi';
export const EDIT_MODE_WRAPPER_CLASS_NAME = getPrefixedId('edit-mode-wrapper', '-');
const getHeaderBgColor = (depth: number) => {
if (depth <= 1) {
return 'base.800';
}
if (depth === 2) {
return 'base.750';
}
return 'base.700';
};
const getHeaderLabel = (el: FormElement) => {
if (isContainerElement(el)) {
if (el.data.direction === 'column') {
return 'Column';
}
return 'Row';
}
return startCase(el.type);
};
export const FormElementEditModeWrapper = memo(
({ element, children, ...rest }: { element: FormElement } & FlexProps) => {
const depth = useContext(DepthContext);
const dispatch = useAppDispatch();
const removeElement = useCallback(() => {
dispatch(formElementRemoved({ id: element.id }));
}, [dispatch, element.id]);
return (
<Flex
className={EDIT_MODE_WRAPPER_CLASS_NAME}
flexDir="column"
borderWidth={1}
borderRadius="base"
borderColor="base.750"
alignItems="center"
justifyContent="flex-start"
w="full"
h="full"
{...rest}
>
<Flex
w="full"
ps={2}
h={8}
bg={getHeaderBgColor(depth)}
borderTopRadius="inherit"
borderBottomWidth={1}
borderColor="inherit"
alignItems="center"
cursor="grab"
>
<Text fontWeight="semibold" noOfLines={1} wordBreak="break-all">
{getHeaderLabel(element)}
</Text>
<Spacer />
<IconButton
aria-label="delete"
onClick={removeElement}
icon={<PiXBold />}
variant="link"
size="sm"
alignSelf="stretch"
colorScheme="error"
/>
</Flex>
<Flex w="full" p={4} alignItems="center" gap={4}>
{children}
</Flex>
</Flex>
);
}
);
FormElementEditModeWrapper.displayName = 'FormElementEditModeWrapper';

View File

@@ -1,5 +1,8 @@
import { Flex, Heading } from '@invoke-ai/ui-library';
import { useElement } from 'features/nodes/store/workflowSlice';
import { useAppSelector } from 'app/store/storeHooks';
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
import { selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice';
import type { HeadingElement } from 'features/nodes/types/workflow';
import { HEADING_CLASS_NAME, isHeadingElement } from 'features/nodes/types/workflow';
import { memo } from 'react';
@@ -13,12 +16,25 @@ const LEVEL_TO_SIZE = {
export const HeadingElementComponent = memo(({ id }: { id: string }) => {
const el = useElement(id);
const mode = useAppSelector(selectWorkflowFormMode);
if (!el || !isHeadingElement(el)) {
return null;
}
const { content, level } = el.data;
if (mode === 'view') {
return <HeadingElementComponentViewMode el={el} />;
}
// mode === 'edit'
return <HeadingElementComponentEditMode el={el} />;
});
HeadingElementComponent.displayName = 'HeadingElementComponent';
export const HeadingElementComponentViewMode = memo(({ el }: { el: HeadingElement }) => {
const { id, data } = el;
const { content, level } = data;
return (
<Flex id={id} className={HEADING_CLASS_NAME}>
@@ -27,4 +43,19 @@ export const HeadingElementComponent = memo(({ id }: { id: string }) => {
);
});
HeadingElementComponent.displayName = 'HeadingElementComponent';
HeadingElementComponentViewMode.displayName = 'HeadingElementComponentViewMode';
export const HeadingElementComponentEditMode = memo(({ el }: { el: HeadingElement }) => {
const { id, data } = el;
const { content, level } = data;
return (
<FormElementEditModeWrapper element={el}>
<Flex id={id} className={HEADING_CLASS_NAME}>
<Heading size={LEVEL_TO_SIZE[level]}>{content}</Heading>
</Flex>
</FormElementEditModeWrapper>
);
});
HeadingElementComponentEditMode.displayName = 'HeadingElementComponentEditMode';

View File

@@ -1,18 +1,34 @@
import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
import { InputFieldViewMode } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldViewMode';
import { useElement } from 'features/nodes/store/workflowSlice';
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
import { selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { isNodeFieldElement, NODE_FIELD_CLASS_NAME } from 'features/nodes/types/workflow';
import { memo } from 'react';
export const NodeFieldElementComponent = memo(({ id }: { id: string }) => {
const el = useElement(id);
const mode = useAppSelector(selectWorkflowFormMode);
if (!el || !isNodeFieldElement(el)) {
return null;
}
const { fieldIdentifier } = el.data;
if (mode === 'view') {
return <NodeFieldElementComponentViewMode el={el} />;
}
// mode === 'edit'
return <NodeFieldElementComponentEditMode el={el} />;
});
NodeFieldElementComponent.displayName = 'NodeFieldElementComponent';
export const NodeFieldElementComponentViewMode = memo(({ el }: { el: NodeFieldElement }) => {
const { id, data } = el;
const { fieldIdentifier } = data;
return (
<Flex id={id} className={NODE_FIELD_CLASS_NAME}>
@@ -23,4 +39,21 @@ export const NodeFieldElementComponent = memo(({ id }: { id: string }) => {
);
});
NodeFieldElementComponent.displayName = 'NodeFieldElementComponent';
NodeFieldElementComponentViewMode.displayName = 'NodeFieldElementComponentViewMode';
export const NodeFieldElementComponentEditMode = memo(({ el }: { el: NodeFieldElement }) => {
const { id, data } = el;
const { fieldIdentifier } = data;
return (
<FormElementEditModeWrapper element={el}>
<Flex id={id} className={NODE_FIELD_CLASS_NAME} w='full'>
<InputFieldGate nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName}>
<InputFieldViewMode nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName} />
</InputFieldGate>
</Flex>
</FormElementEditModeWrapper>
);
});
NodeFieldElementComponentEditMode.displayName = 'NodeFieldElementComponentEditMode';

View File

@@ -1,16 +1,31 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import { useElement } from 'features/nodes/store/workflowSlice';
import { useAppSelector } from 'app/store/storeHooks';
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
import { selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice';
import type { TextElement } from 'features/nodes/types/workflow';
import { isTextElement, TEXT_CLASS_NAME } from 'features/nodes/types/workflow';
import { memo } from 'react';
export const TextElementComponent = memo(({ id }: { id: string }) => {
const el = useElement(id);
const mode = useAppSelector(selectWorkflowFormMode);
if (!el || !isTextElement(el)) {
return null;
}
const { content, fontSize } = el.data;
if (mode === 'view') {
return <TextElementComponentViewMode el={el} />;
}
// mode === 'edit'
return <TextElementComponentEditMode el={el} />;
});
TextElementComponent.displayName = 'TextElementComponent';
export const TextElementComponentViewMode = memo(({ el }: { el: TextElement }) => {
const { id, data } = el;
const { content, fontSize } = data;
return (
<Flex id={id} className={TEXT_CLASS_NAME}>
@@ -18,5 +33,18 @@ export const TextElementComponent = memo(({ id }: { id: string }) => {
</Flex>
);
});
TextElementComponentViewMode.displayName = 'TextElementComponentViewMode';
TextElementComponent.displayName = 'TextElementComponent';
export const TextElementComponentEditMode = memo(({ el }: { el: TextElement }) => {
const { id, data } = el;
const { content, fontSize } = data;
return (
<Flex id={id} className={TEXT_CLASS_NAME}>
<FormElementEditModeWrapper element={el}>
<Text fontSize={fontSize}>{content}</Text>
</FormElementEditModeWrapper>
</Flex>
);
});
TextElementComponentEditMode.displayName = 'TextElementComponentEditMode';

View File

@@ -1,20 +1,23 @@
import { Flex } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { Button, Flex } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { FormElementComponent } from 'features/nodes/components/sidePanel/builder/ContainerElementComponent';
import { formLoaded } from 'features/nodes/store/workflowSlice';
import { formLoaded, formModeToggled, selectWorkflowFormMode } from 'features/nodes/store/workflowSlice';
import { elements, rootElementId } from 'features/nodes/types/workflow';
import { memo, useEffect } from 'react';
import { memo, useCallback, useEffect } from 'react';
export const WorkflowBuilder = memo(() => {
const dispatch = useAppDispatch();
const mode = useAppSelector(selectWorkflowFormMode);
useEffect(() => {
dispatch(formLoaded({ elements, rootElementId }));
}, [dispatch]);
return (
<ScrollableContent>
<Flex w="full" justifyContent="center">
<Flex w="full" maxW={512}>
<Flex flexDir="column" w={mode === 'view' ? '768px' : 'min-content'} minW='768px'>
<ToggleModeButton />
{rootElementId && <FormElementComponent id={rootElementId} />}
</Flex>
</Flex>
@@ -23,3 +26,15 @@ export const WorkflowBuilder = memo(() => {
});
WorkflowBuilder.displayName = 'WorkflowBuilder';
const ToggleModeButton = memo(() => {
const dispatch = useAppDispatch();
const mode = useAppSelector(selectWorkflowFormMode);
const onClick = useCallback(() => {
dispatch(formModeToggled());
}, [dispatch]);
return <Button onClick={onClick}>{mode === 'view' ? 'Edit' : 'View'}</Button>;
});
ToggleModeButton.displayName = 'ToggleModeButton';

View File

@@ -0,0 +1,5 @@
import type { ContainerElement } from 'features/nodes/types/workflow';
import { createContext } from 'react';
export const ContainerContext = createContext<ContainerElement['data'] | null>(null);
export const DepthContext = createContext<number>(0);

View File

@@ -13,7 +13,7 @@ const WorkflowFieldsLinearViewPanel = () => {
<Flex layerStyle="first" flexDir="column" w="full" h="full" borderRadius="base" p={2} gap={2}>
<Tabs variant="line" display="flex" w="full" h="full" flexDir="column">
<TabList>
<Tab>Builder</Tab>
<Tab>{t('common.builder')}</Tab>
<Tab>{t('common.linear')}</Tab>
<Tab>{t('common.details')}</Tab>
<Tab>JSON</Tab>