feat(ui): iterate on builder (WIP)

This commit is contained in:
psychedelicious
2025-01-22 15:53:52 +11:00
parent 1e658cf9e7
commit bee0e8248f
10 changed files with 335 additions and 251 deletions

View File

@@ -0,0 +1,80 @@
import { Flex, Grid, GridItem } from '@invoke-ai/ui-library';
import {
ContainerElementComponent,
useContainerContext,
} from 'features/nodes/components/sidePanel/builder/ContainerElementComponent';
import { DividerElementComponent } from 'features/nodes/components/sidePanel/builder/DividerElementComponent';
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 type { ColumnChildElement, ColumnElement } from 'features/nodes/types/workflow';
import type { PropsWithChildren } from 'react';
import { createContext, memo, useContext, useMemo } from 'react';
import type { Equals } from 'tsafe';
import { assert } from 'tsafe';
const _ColumnContext = createContext<{ columnId: string; columnNumber: number } | null>(null);
const ColumnContextProvider = ({
columnId,
columnNumber,
children,
}: PropsWithChildren<{ columnId: string; columnNumber: number }>) => {
const ctx = useMemo(() => ({ columnId, columnNumber }), [columnId, columnNumber]);
return <_ColumnContext.Provider value={ctx}>{children}</_ColumnContext.Provider>;
};
export const useColumnContext = () => {
const context = useContext(_ColumnContext);
assert(context !== null);
return context;
};
const ColumnElementChildComponent = memo(({ element }: { element: ColumnChildElement }) => {
const { type, id } = element;
switch (type) {
case 'container':
return <ContainerElementComponent key={id} element={element} />;
case 'node-field':
return <NodeFieldElementComponent key={id} element={element} />;
case 'divider':
return <DividerElementComponent key={id} element={element} />;
case 'heading':
return <HeadingElementComponent key={id} element={element} />;
case 'text':
return <TextElementComponent key={id} element={element} />;
default:
assert<Equals<typeof type, never>>(false, `Unhandled type ${type}`);
}
});
ColumnElementChildComponent.displayName = 'ColumnElementChildComponent';
export const ColumnElementComponent = memo(({ element }: { element: ColumnElement }) => {
const containerCtx = useContainerContext();
const columnNumber = useMemo(
() => containerCtx.columnIds.indexOf(element.id) + 1,
[containerCtx.columnIds, element.id]
);
const withDivider = useMemo(
() => containerCtx.columnIds.indexOf(element.id) + 1 < containerCtx.columnIds.length,
[containerCtx.columnIds, element.id]
);
return (
<ColumnContextProvider columnId={element.id} columnNumber={columnNumber}>
<>
<GridItem
as={Grid}
id={`column:${element.id}_${columnNumber}`}
gap={4}
gridAutoRows="min-content"
gridAutoFlow="row"
>
{element.data.elements.map((element) => (
<ColumnElementChildComponent key={element.id} element={element} />
))}
</GridItem>
{withDivider && <Flex w="1px" bg="base.800" flexShrink={0} />}
</>
</ColumnContextProvider>
);
});
ColumnElementComponent.displayName = 'ColumnElementComponent';

View File

@@ -0,0 +1,46 @@
import { Grid } from '@invoke-ai/ui-library';
import { ColumnElementComponent } from 'features/nodes/components/sidePanel/builder/ColumnElementComponent';
import type { ContainerElement } from 'features/nodes/types/workflow';
import type { PropsWithChildren } from 'react';
import { createContext, memo, useContext, useMemo } from 'react';
import { assert } from 'tsafe';
const _ContainerContext = createContext<{ containerId: string; columnIds: string[]; depth: number } | null>(null);
const ContainerContextProvider = ({
containerId,
columnIds,
children,
}: PropsWithChildren<{ containerId: string; columnIds: string[] }>) => {
const parentCtx = useContext(_ContainerContext);
const ctx = useMemo(
() => ({ containerId, columnIds, depth: parentCtx ? parentCtx.depth + 1 : 0 }),
[columnIds, containerId, parentCtx]
);
return <_ContainerContext.Provider value={ctx}>{children}</_ContainerContext.Provider>;
};
export const useContainerContext = () => {
const context = useContext(_ContainerContext);
assert(context !== null);
return context;
};
const getGridTemplateColumns = (count: number) => {
return Array.from({ length: count }, () => '1fr').join(' auto ');
};
export const ContainerElementComponent = memo(({ element }: { element: ContainerElement }) => {
const { id, data } = element;
const { columns } = data;
const columnIds = useMemo(() => columns.map((column) => column.id), [columns]);
return (
<ContainerContextProvider containerId={id} columnIds={columnIds}>
<Grid id={id} gap={4} gridTemplateColumns={getGridTemplateColumns(columns.length)} gridAutoFlow="column">
{columns.map((element, i) => {
return <ColumnElementComponent key={`column:${id}_${i + 1}`} element={element} />;
})}
</Grid>
</ContainerContextProvider>
);
});
ContainerElementComponent.displayName = 'ContainerElementComponent';

View File

@@ -0,0 +1,11 @@
import { Flex } from '@invoke-ai/ui-library';
import type { DividerElement } from 'features/nodes/types/workflow';
import { memo } from 'react';
export const DividerElementComponent = memo(({ element }: { element: DividerElement }) => {
const { id } = element;
return <Flex id={id} h="1px" bg="base.800" flexShrink={0} />;
});
DividerElementComponent.displayName = 'DividerElementComponent';

View File

@@ -0,0 +1,24 @@
import { Heading } from '@invoke-ai/ui-library';
import type { HeadingElement } from 'features/nodes/types/workflow';
import { memo } from 'react';
const LEVEL_TO_SIZE = {
1: 'xl',
2: 'lg',
3: 'md',
4: 'sm',
5: 'xs',
} as const;
export const HeadingElementComponent = memo(({ element }: { element: HeadingElement }) => {
const { id, data } = element;
const { content, level } = data;
return (
<Heading id={id} size={LEVEL_TO_SIZE[level]}>
{content}
</Heading>
);
});
HeadingElementComponent.displayName = 'HeadingElementComponent';

View File

@@ -0,0 +1,20 @@
import { Flex } from '@invoke-ai/ui-library';
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
import { InputFieldViewMode } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldViewMode';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { memo } from 'react';
export const NodeFieldElementComponent = memo(({ element }: { element: NodeFieldElement }) => {
const { id, data } = element;
const { fieldIdentifier } = data;
return (
<Flex id={id} flexBasis="100%">
<InputFieldGate nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName}>
<InputFieldViewMode nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName} />
</InputFieldGate>
</Flex>
);
});
NodeFieldElementComponent.displayName = 'NodeFieldElementComponent';

View File

@@ -0,0 +1,16 @@
import { Text } from '@invoke-ai/ui-library';
import type { TextElement } from 'features/nodes/types/workflow';
import { memo } from 'react';
export const TextElementComponent = memo(({ element }: { element: TextElement }) => {
const { id, data } = element;
const { content, fontSize } = data;
return (
<Text id={id} fontSize={fontSize}>
{content}
</Text>
);
});
TextElementComponent.displayName = 'TextElementComponent';

View File

@@ -0,0 +1,19 @@
import { Flex } from '@invoke-ai/ui-library';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { ContainerElementComponent } from 'features/nodes/components/sidePanel/builder/ContainerElementComponent';
import { data } from 'features/nodes/types/workflow';
import { memo } from 'react';
export const WorkflowBuilder = memo(() => {
return (
<ScrollableContent>
<Flex w="full" h="full" justifyContent="center">
<Flex w="full" h="full" maxW={512}>
<ContainerElementComponent element={data} />
</Flex>
</Flex>
</ScrollableContent>
);
});
WorkflowBuilder.displayName = 'WorkflowBuilder';

View File

@@ -1,134 +0,0 @@
import { Flex, Heading, Text } from '@invoke-ai/ui-library';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
import { InputFieldViewMode } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldViewMode';
import type {
BuilderElement,
DividerElement,
FieldElement,
HeadingElement,
StackElement,
TextElement,
} from 'features/nodes/types/workflow';
import { data } from 'features/nodes/types/workflow';
import { createContext, memo, useContext, useMemo } from 'react';
import { assert } from 'tsafe';
const StackContext = createContext<{ direction: StackElement['data']['direction']; depth: number } | null>(null);
const useStackContext = () => {
const context = useContext(StackContext);
assert(context !== null);
return context;
};
export const WorkflowBuilder = memo(() => {
return (
<ScrollableContent>
<ElementComponent element={data} />
</ScrollableContent>
);
});
WorkflowBuilder.displayName = 'WorkflowBuilder';
const ElementComponent = ({ element }: { element: BuilderElement }) => {
switch (element.type) {
case 'stack':
return <StackElementComponent element={element} />;
case 'field':
return <FieldElementComponent element={element} />;
case 'heading':
return <HeadingElementComponent element={element} />;
case 'text':
return <TextElementComponent element={element} />;
case 'divider':
return <DividerElementComponent element={element} />;
default:
assert(false, `Unhandled element type: ${element}`);
}
};
const DIRECTION_TO_FLEXDIR = {
horizontal: 'row',
vertical: 'column',
} as const;
const StackElementComponent = ({ element }: { element: StackElement }) => {
const { id, data } = element;
const { children, direction } = data;
const parentCtx = useContext(StackContext);
const depth = useMemo(() => (parentCtx ? parentCtx.depth + 1 : 0), [parentCtx]);
const ctx = useMemo(() => ({ direction, depth }), [depth, direction]);
return (
<StackContext.Provider value={ctx}>
<Flex id={id} gap={2} flexDir={DIRECTION_TO_FLEXDIR[direction]}>
{children.map((child) => (
<ElementComponent key={child.id} element={child} />
))}
</Flex>
</StackContext.Provider>
);
};
const FieldElementComponent = ({ element }: { element: FieldElement }) => {
const { id, data } = element;
const { fieldIdentifier } = data;
return (
<Flex id={id} flexBasis="100%">
<InputFieldGate nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName}>
<InputFieldViewMode nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName} />
</InputFieldGate>
</Flex>
);
};
const LEVEL_TO_SIZE = {
1: 'xl',
2: 'lg',
3: 'md',
4: 'sm',
5: 'xs',
} as const;
const HeadingElementComponent = ({ element }: { element: HeadingElement }) => {
const { id, data } = element;
const { content, level } = data;
return (
<Heading id={id} size={LEVEL_TO_SIZE[level]}>
{content}
</Heading>
);
};
const TextElementComponent = ({ element }: { element: TextElement }) => {
const { id, data } = element;
const { content, fontSize } = data;
return (
<Text id={id} fontSize={fontSize}>
{content}
</Text>
);
};
const DIRECTION_TO_WIDTH = {
horizontal: '1px',
vertical: undefined,
};
const DIRECTION_TO_HEIGHT = {
horizontal: undefined,
vertical: '1px',
};
const DividerElementComponent = ({ element }: { element: DividerElement }) => {
const { id } = element;
const { direction } = useStackContext();
return (
<Flex id={id} w={DIRECTION_TO_WIDTH[direction]} h={DIRECTION_TO_HEIGHT[direction]} bg="base.700" flexShrink={0} />
);
};

View File

@@ -1,5 +1,5 @@
import { Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
import { WorkflowBuilder } from 'features/nodes/components/sidePanel/workflow/WorkflowBuilder';
import { WorkflowBuilder } from 'features/nodes/components/sidePanel/builder/WorkflowBuilder';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -78,34 +78,28 @@ export type WorkflowV3 = z.infer<typeof zWorkflowV3>;
// #endregion
// #region Workflow Builder
const buildElementBuilder =
<T extends BuilderElement['type']>(type: T) =>
(data: Extract<BuilderElement, { type: T }>['data']): Extract<BuilderElement, { type: T }> =>
({
id: nanoid(),
type,
data,
}) as Extract<BuilderElement, { type: T }>;
const zFieldElementNumberConfig = z.object({
display: z.enum(['slider', 'input', 'input-with-slider-inline', 'input-with-slider-popover']),
});
const zFieldElementStringConfig = z.object({
display: z.enum(['input', 'textarea']),
});
const zElementBase = z.object({
id: z.string().trim().min(1),
});
const zFieldElement = zElementBase.extend({
type: z.literal('field'),
const zNodeFieldElement = zElementBase.extend({
type: z.literal('node-field'),
data: z.object({
fieldIdentifier: zFieldIdentifier,
fieldConfig: z.union([zFieldElementNumberConfig, zFieldElementStringConfig]).optional(),
}),
});
export type FieldElement = z.infer<typeof zFieldElement>;
const buildFieldElement = buildElementBuilder('field');
export type NodeFieldElement = z.infer<typeof zNodeFieldElement>;
const nodeField = (
nodeId: NodeFieldElement['data']['fieldIdentifier']['nodeId'],
fieldName: NodeFieldElement['data']['fieldIdentifier']['fieldName']
): NodeFieldElement => ({
id: nanoid(),
type: 'node-field',
data: {
fieldIdentifier: { nodeId, fieldName },
},
});
const zHeadingElement = zElementBase.extend({
type: z.literal('heading'),
@@ -115,7 +109,17 @@ const zHeadingElement = zElementBase.extend({
}),
});
export type HeadingElement = z.infer<typeof zHeadingElement>;
const buildHeadingElement = buildElementBuilder('heading');
const heading = (
content: HeadingElement['data']['content'],
level: HeadingElement['data']['level']
): HeadingElement => ({
id: nanoid(),
type: 'heading',
data: {
content,
level,
},
});
const zTextElement = zElementBase.extend({
type: z.literal('text'),
@@ -125,134 +129,132 @@ const zTextElement = zElementBase.extend({
}),
});
export type TextElement = z.infer<typeof zTextElement>;
const buildTextElement = buildElementBuilder('text');
const text = (content: TextElement['data']['content'], fontSize: TextElement['data']['fontSize']): TextElement => ({
id: nanoid(),
type: 'text',
data: {
content,
fontSize,
},
});
const zDividerElement = zElementBase.extend({
type: z.literal('divider'),
data: z.void(),
});
export type DividerElement = z.infer<typeof zDividerElement>;
const buildDividerElement = buildElementBuilder('divider');
const divider = (): DividerElement => ({
id: nanoid(),
type: 'divider',
});
export type StackElement = {
export type ColumnElement = {
id: string;
type: 'stack';
type: 'column';
data: {
children: BuilderElement[];
direction: 'horizontal' | 'vertical';
elements: ColumnChildElement[];
};
};
const zStackElement: z.ZodType<StackElement> = zElementBase.extend({
type: z.literal('stack'),
const zColumnElement = zElementBase.extend({
type: z.literal('column'),
data: z.object({
children: z.lazy(() => z.array(zElement)),
direction: z.enum(['horizontal', 'vertical']),
elements: z.lazy(() => z.array(zColumnChildElement)),
}),
});
const buildStackElement = buildElementBuilder('stack');
const column = (elements: ColumnElement['data']['elements']): ColumnElement => ({
id: nanoid(),
type: 'column',
data: {
elements,
},
});
export type ContainerElement = {
id: string;
type: 'container';
data: {
columns: ColumnElement[];
};
};
const zContainerElement: z.ZodType<ContainerElement> = zElementBase.extend({
type: z.literal('container'),
data: z.object({
columns: z.lazy(() => z.array(zColumnElement)),
}),
});
const container = (columns: ContainerElement['data']['columns']): ContainerElement => ({
id: nanoid(),
type: 'container',
data: {
columns,
},
});
// export type CollapsibleElement = {
// id: string;
// type: 'collapsible';
// children: BuilderElement[];
// columns: BuilderElement[];
// title: string;
// collapsed: boolean;
// };
// const zCollapsibleElement: z.ZodType<CollapsibleElement> = z.object({
// type: z.literal('collapsible'),
// children: z.lazy(() => z.array(zElement)),
// columns: z.lazy(() => z.array(zElement)),
// title: z.string(),
// collapsed: z.boolean(),
// });
const zElement = z.union([
zStackElement,
const zColumnChildElement = z.union([
zContainerElement,
// zCollapsibleElement
zFieldElement,
zNodeFieldElement,
zHeadingElement,
zTextElement,
zDividerElement,
]);
export type BuilderElement =
| StackElement
// | CollapsibleElement
| FieldElement
| HeadingElement
| TextElement
| DividerElement;
export type ColumnChildElement = z.infer<typeof zColumnChildElement>;
export const data: StackElement = buildStackElement({
direction: 'vertical',
children: [
buildHeadingElement({ content: 'My Cool Workflow', level: 1 }),
buildTextElement({ content: 'This is a description of what my workflow does. It does things.', fontSize: 'md' }),
buildDividerElement(),
buildHeadingElement({ content: 'First Section', level: 2 }),
buildTextElement({
content: 'The first section includes fields relevant to the first section. This note describes that fact.',
fontSize: 'sm',
}),
buildStackElement({
direction: 'horizontal',
children: [
buildFieldElement({
fieldIdentifier: { nodeId: '7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', fieldName: 'image' },
}),
buildFieldElement({
fieldIdentifier: { nodeId: '7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', fieldName: 'image' },
}),
buildFieldElement({
fieldIdentifier: { nodeId: '7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', fieldName: 'image' },
}),
],
}),
buildFieldElement({ fieldIdentifier: { nodeId: '9c058600-8d73-4702-912b-0ccf37403bfd', fieldName: 'value' } }),
buildFieldElement({ fieldIdentifier: { nodeId: '7a8bbab2-6919-4cfc-bd7c-bcfda3c79ecf', fieldName: 'value' } }),
buildFieldElement({ fieldIdentifier: { nodeId: '4e16cbf6-457c-46fb-9ab7-9cb262fa1e03', fieldName: 'value' } }),
buildFieldElement({ fieldIdentifier: { nodeId: '39cb5272-a9d7-4da9-9c35-32e02b46bb34', fieldName: 'color' } }),
buildStackElement({
direction: 'horizontal',
children: [
buildFieldElement({
fieldIdentifier: { nodeId: '4f609a81-0e25-47d1-ba0d-f24fedd5273f', fieldName: 'value' },
}),
buildFieldElement({
fieldIdentifier: { nodeId: '4f609a81-0e25-47d1-ba0d-f24fedd5273f', fieldName: 'value' },
}),
buildFieldElement({
fieldIdentifier: { nodeId: '4f609a81-0e25-47d1-ba0d-f24fedd5273f', fieldName: 'value' },
}),
buildFieldElement({
fieldIdentifier: { nodeId: '4f609a81-0e25-47d1-ba0d-f24fedd5273f', fieldName: 'value' },
}),
],
}),
buildFieldElement({ fieldIdentifier: { nodeId: '14744f68-9000-4694-b4d6-cbe83ee231ee', fieldName: 'model' } }),
buildDividerElement(),
buildTextElement({ content: 'These are some text that are definitely super helpful.', fontSize: 'sm' }),
buildDividerElement(),
buildStackElement({
direction: 'horizontal',
children: [
buildStackElement({
direction: 'vertical',
children: [
buildFieldElement({
fieldIdentifier: { nodeId: '7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', fieldName: 'image' },
}),
buildFieldElement({
fieldIdentifier: { nodeId: '7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', fieldName: 'image' },
}),
],
}),
buildDividerElement(),
buildFieldElement({
fieldIdentifier: { nodeId: '7a8bbab2-6919-4cfc-bd7c-bcfda3c79ecf', fieldName: 'value' },
}),
],
}),
],
});
export const data: ContainerElement = container([
column([
heading('My Cool Workflow', 1),
text('This is a description of what my workflow does. It does things.', 'md'),
divider(),
heading('First Section', 2),
text('The first section includes fields relevant to the first section. This note describes that fact.', 'sm'),
container([
column([nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image')]),
column([nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image')]),
column([nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image')]),
]),
nodeField('9c058600-8d73-4702-912b-0ccf37403bfd', 'value'),
nodeField('7a8bbab2-6919-4cfc-bd7c-bcfda3c79ecf', 'value'),
nodeField('4e16cbf6-457c-46fb-9ab7-9cb262fa1e03', 'value'),
nodeField('39cb5272-a9d7-4da9-9c35-32e02b46bb34', 'color'),
container([
column([
nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value'),
nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value'),
]),
column([
nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value'),
nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value'),
nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value'),
nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value'),
]),
]),
nodeField('14744f68-9000-4694-b4d6-cbe83ee231ee', 'model'),
divider(),
text('These are some text that are definitely super helpful.', 'sm'),
divider(),
container([
column([
nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image'),
nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image'),
]),
column([nodeField('7a8bbab2-6919-4cfc-bd7c-bcfda3c79ecf', 'value')]),
]),
]),
]);