From 92e51a833d72deeb753a98bccb0e176b0681ceea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Nov 2025 01:18:54 +0000 Subject: [PATCH 1/6] feat: Add canvas-workflow integration feature This commit implements a new feature that allows users to run workflows directly from the unified canvas. Users can now: - Access a "Run Workflow" option from the canvas layer context menu - Select a workflow with image parameters from a modal dialog - Customize workflow parameters (non-image fields) - Execute the workflow with the current canvas layer as input - Have the result automatically added back to the canvas Key changes: - Added canvasWorkflowIntegrationSlice for state management - Created CanvasWorkflowIntegrationModal and related UI components - Added context menu item to raster layers - Integrated workflow execution with canvas image extraction - Added modal to global modal isolator This integration enhances the canvas by allowing users to leverage custom workflows for advanced image processing directly within the canvas workspace. Implements feature request for deeper workflow-canvas integration. --- .../app/components/GlobalModalIsolator.tsx | 2 + invokeai/frontend/web/src/app/store/store.ts | 3 + .../CanvasWorkflowIntegrationModal.tsx | 96 ++++++++ ...anvasWorkflowIntegrationParameterPanel.tsx | 93 ++++++++ ...vasWorkflowIntegrationWorkflowSelector.tsx | 108 +++++++++ .../useCanvasWorkflowIntegrationExecute.ts | 208 ++++++++++++++++++ .../RasterLayer/RasterLayerMenuItems.tsx | 2 + .../CanvasEntityMenuItemsRunWorkflow.tsx | 25 +++ .../store/canvasWorkflowIntegrationSlice.ts | 109 +++++++++ 9 files changed, 646 insertions(+) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationModal.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationParameterPanel.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationWorkflowSelector.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/useCanvasWorkflowIntegrationExecute.ts create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsRunWorkflow.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/store/canvasWorkflowIntegrationSlice.ts diff --git a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx index 5c1446662ef..ef0747707ff 100644 --- a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx @@ -1,6 +1,7 @@ import { GlobalImageHotkeys } from 'app/components/GlobalImageHotkeys'; import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal'; import { CanvasPasteModal } from 'features/controlLayers/components/CanvasPasteModal'; +import { CanvasWorkflowIntegrationModal } from 'features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationModal'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { CropImageModal } from 'features/cropper/components/CropImageModal'; import { DeleteImageModal } from 'features/deleteImageModal/components/DeleteImageModal'; @@ -51,6 +52,7 @@ export const GlobalModalIsolator = memo(() => { + diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index cad6f489df7..940840c7210 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -23,6 +23,7 @@ import { changeBoardModalSliceConfig } from 'features/changeBoardModal/store/sli import { canvasSettingsSliceConfig } from 'features/controlLayers/store/canvasSettingsSlice'; import { canvasSliceConfig } from 'features/controlLayers/store/canvasSlice'; import { canvasSessionSliceConfig } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { canvasWorkflowIntegrationSliceConfig } from 'features/controlLayers/store/canvasWorkflowIntegrationSlice'; import { lorasSliceConfig } from 'features/controlLayers/store/lorasSlice'; import { paramsSliceConfig } from 'features/controlLayers/store/paramsSlice'; import { refImagesSliceConfig } from 'features/controlLayers/store/refImagesSlice'; @@ -61,6 +62,7 @@ const SLICE_CONFIGS = { [canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig, [canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig, [canvasSliceConfig.slice.reducerPath]: canvasSliceConfig, + [canvasWorkflowIntegrationSliceConfig.slice.reducerPath]: canvasWorkflowIntegrationSliceConfig, [changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig, [dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig, [gallerySliceConfig.slice.reducerPath]: gallerySliceConfig, @@ -89,6 +91,7 @@ const ALL_REDUCERS = { canvasSliceConfig.slice.reducer, canvasSliceConfig.undoableConfig?.reduxUndoOptions ), + [canvasWorkflowIntegrationSliceConfig.slice.reducerPath]: canvasWorkflowIntegrationSliceConfig.slice.reducer, [changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig.slice.reducer, [dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig.slice.reducer, [gallerySliceConfig.slice.reducerPath]: gallerySliceConfig.slice.reducer, diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationModal.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationModal.tsx new file mode 100644 index 00000000000..67b9386b441 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationModal.tsx @@ -0,0 +1,96 @@ +import { + Button, + ButtonGroup, + Flex, + Heading, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Spacer, + Spinner, + Text, +} from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { + canvasWorkflowIntegrationClosed, + selectCanvasWorkflowIntegrationIsOpen, + selectCanvasWorkflowIntegrationIsProcessing, + selectCanvasWorkflowIntegrationSelectedWorkflowId, +} from 'features/controlLayers/store/canvasWorkflowIntegrationSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { CanvasWorkflowIntegrationParameterPanel } from './CanvasWorkflowIntegrationParameterPanel'; +import { CanvasWorkflowIntegrationWorkflowSelector } from './CanvasWorkflowIntegrationWorkflowSelector'; +import { useCanvasWorkflowIntegrationExecute } from './useCanvasWorkflowIntegrationExecute'; + +export const CanvasWorkflowIntegrationModal = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const isOpen = useAppSelector(selectCanvasWorkflowIntegrationIsOpen); + const isProcessing = useAppSelector(selectCanvasWorkflowIntegrationIsProcessing); + const selectedWorkflowId = useAppSelector(selectCanvasWorkflowIntegrationSelectedWorkflowId); + + const { execute, canExecute } = useCanvasWorkflowIntegrationExecute(); + + const onClose = useCallback(() => { + if (!isProcessing) { + dispatch(canvasWorkflowIntegrationClosed()); + } + }, [dispatch, isProcessing]); + + const onExecute = useCallback(() => { + execute(); + }, [execute]); + + return ( + + + + + {t('controlLayers.workflowIntegration.title', 'Run Workflow on Canvas')} + + + + + + + {t( + 'controlLayers.workflowIntegration.description', + 'Select a workflow with an image parameter to run on the current canvas layer. The workflow result will be added back to the canvas.' + )} + + + + + {selectedWorkflowId && } + + + + + + + + + + + + + ); +}); + +CanvasWorkflowIntegrationModal.displayName = 'CanvasWorkflowIntegrationModal'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationParameterPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationParameterPanel.tsx new file mode 100644 index 00000000000..280927e6d94 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationParameterPanel.tsx @@ -0,0 +1,93 @@ +import { Box, Flex, Heading, Spinner, Text } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { canvasWorkflowIntegrationFieldValueChanged, selectCanvasWorkflowIntegrationSelectedWorkflowId } from 'features/controlLayers/store/canvasWorkflowIntegrationSlice'; +import { InputFieldRenderer } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer'; +import { useWorkflowFieldInstances } from 'features/nodes/hooks/useWorkflowFieldInstances'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useGetWorkflowQuery } from 'services/api/endpoints/workflows'; + +export const CanvasWorkflowIntegrationParameterPanel = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const selectedWorkflowId = useAppSelector(selectCanvasWorkflowIntegrationSelectedWorkflowId); + + const { data: workflow, isLoading } = useGetWorkflowQuery(selectedWorkflowId!, { + skip: !selectedWorkflowId, + }); + + const onFieldValueChanged = useCallback( + (fieldName: string, value: unknown) => { + dispatch(canvasWorkflowIntegrationFieldValueChanged({ fieldName, value })); + }, + [dispatch] + ); + + if (isLoading) { + return ( + + + {t('controlLayers.workflowIntegration.loadingParameters', 'Loading workflow parameters...')} + + ); + } + + if (!workflow) { + return null; + } + + // Get exposed fields that are NOT image fields (those will be auto-populated) + const exposedFieldsToShow = workflow.exposedFields.filter((fieldIdentifier) => { + const node = workflow.nodes.find((n) => n.data.id === fieldIdentifier.nodeId); + if (!node) { + return false; + } + + const field = node.data.inputs[fieldIdentifier.fieldName]; + // @ts-expect-error - field may not have type property + return field?.type?.name !== 'ImageField'; + }); + + if (exposedFieldsToShow.length === 0) { + return ( + + + {t( + 'controlLayers.workflowIntegration.noParametersToCustomize', + 'This workflow has no customizable parameters (all image fields will be auto-populated).' + )} + + + ); + } + + return ( + + + {t('controlLayers.workflowIntegration.parameters', 'Workflow Parameters')} + + + {exposedFieldsToShow.map((fieldIdentifier) => { + const node = workflow.nodes.find((n) => n.data.id === fieldIdentifier.nodeId); + if (!node) { + return null; + } + + return ( + + + + ); + })} + + + ); +}); + +CanvasWorkflowIntegrationParameterPanel.displayName = 'CanvasWorkflowIntegrationParameterPanel'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationWorkflowSelector.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationWorkflowSelector.tsx new file mode 100644 index 00000000000..edd40307c85 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationWorkflowSelector.tsx @@ -0,0 +1,108 @@ +import { Flex, FormControl, FormLabel, Select, Spinner, Text } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { + canvasWorkflowIntegrationWorkflowSelected, + selectCanvasWorkflowIntegrationSelectedWorkflowId, +} from 'features/controlLayers/store/canvasWorkflowIntegrationSlice'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows'; + +export const CanvasWorkflowIntegrationWorkflowSelector = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const selectedWorkflowId = useAppSelector(selectCanvasWorkflowIntegrationSelectedWorkflowId); + const { data: workflowsData, isLoading } = useListWorkflowsInfiniteInfiniteQuery( + { + queryArg: { + per_page: 100, // Get a reasonable number of workflows + page: 0, + }, + pageParam: 0, + }, + { + selectFromResult: ({ data, isLoading }) => ({ + data, + isLoading, + }), + } + ); + + const workflows = useMemo(() => { + if (!workflowsData) { + return null; + } + // Flatten all pages into a single list + return { + items: workflowsData.pages.flatMap((page) => page.items), + }; + }, [workflowsData]); + + // Filter workflows that have image input parameters + const compatibleWorkflows = useMemo(() => { + if (!workflows) { + return []; + } + + return workflows.items.filter((workflow) => { + // Check if the workflow has exposed fields + if (!workflow.exposedFields || workflow.exposedFields.length === 0) { + return false; + } + + // Check if any of the nodes have image input fields + const hasImageInput = workflow.nodes.some((node) => { + return Object.values(node.data.inputs || {}).some((input) => { + // @ts-expect-error - input may not have type property + return input.type?.name === 'ImageField'; + }); + }); + + return hasImageInput; + }); + }, [workflows]); + + const onChange = useCallback( + (e: React.ChangeEvent) => { + const workflowId = e.target.value || null; + dispatch(canvasWorkflowIntegrationWorkflowSelected({ workflowId })); + }, + [dispatch] + ); + + if (isLoading) { + return ( + + + {t('controlLayers.workflowIntegration.loadingWorkflows', 'Loading workflows...')} + + ); + } + + if (compatibleWorkflows.length === 0) { + return ( + + {t( + 'controlLayers.workflowIntegration.noCompatibleWorkflows', + 'No compatible workflows found. Workflows must have image input parameters and exposed fields configured in linear view.' + )} + + ); + } + + return ( + + {t('controlLayers.workflowIntegration.selectWorkflow', 'Select Workflow')} + + + ); +}); + +CanvasWorkflowIntegrationWorkflowSelector.displayName = 'CanvasWorkflowIntegrationWorkflowSelector'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/useCanvasWorkflowIntegrationExecute.ts b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/useCanvasWorkflowIntegrationExecute.ts new file mode 100644 index 00000000000..0fb50b6f6c2 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/useCanvasWorkflowIntegrationExecute.ts @@ -0,0 +1,208 @@ +import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { + canvasWorkflowIntegrationClosed, + canvasWorkflowIntegrationProcessingCompleted, + canvasWorkflowIntegrationProcessingStarted, + selectCanvasWorkflowIntegrationFieldValues, + selectCanvasWorkflowIntegrationSelectedWorkflowId, + selectCanvasWorkflowIntegrationSourceEntityIdentifier, +} from 'features/controlLayers/store/canvasWorkflowIntegrationSlice'; +import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice'; +import { imageDTOToImageObject } from 'features/controlLayers/store/util'; +import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph'; +import { buildWorkflowWithValidation } from 'features/nodes/util/workflow/buildWorkflow'; +import { toast } from 'features/toast/toast'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLazyGetWorkflowQuery } from 'services/api/endpoints/workflows'; +import { queueApi } from 'services/api/endpoints/queue'; +import type { ImageDTO } from 'services/api/types'; + +export const useCanvasWorkflowIntegrationExecute = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const store = useAppStore(); + const canvasManager = useCanvasManager(); + + const selectedWorkflowId = useAppSelector(selectCanvasWorkflowIntegrationSelectedWorkflowId); + const sourceEntityIdentifier = useAppSelector(selectCanvasWorkflowIntegrationSourceEntityIdentifier); + const fieldValues = useAppSelector(selectCanvasWorkflowIntegrationFieldValues); + + const [getWorkflow] = useLazyGetWorkflowQuery(); + + const canExecute = useMemo(() => { + return Boolean(selectedWorkflowId && sourceEntityIdentifier); + }, [selectedWorkflowId, sourceEntityIdentifier]); + + const execute = useCallback(async () => { + if (!selectedWorkflowId || !sourceEntityIdentifier || !canvasManager) { + return; + } + + try { + dispatch(canvasWorkflowIntegrationProcessingStarted()); + + // 1. Extract the canvas layer as an image + const adapter = canvasManager.getAdapter(sourceEntityIdentifier); + if (!adapter) { + throw new Error('Could not find canvas entity adapter'); + } + + const rect = adapter.transformer.getRelativeRect(); + const imageDTO = await adapter.renderer.rasterize({ rect, attrs: { filters: [], opacity: 1 } }); + + // 2. Fetch the workflow + const { data: workflow } = await getWorkflow(selectedWorkflowId); + if (!workflow) { + throw new Error('Failed to load workflow'); + } + + // 3. Build the workflow graph with the canvas image + // First, find the image field in the workflow + const imageFieldIdentifier = workflow.exposedFields.find((fieldIdentifier) => { + const node = workflow.nodes.find((n) => n.data.id === fieldIdentifier.nodeId); + if (!node) { + return false; + } + const field = node.data.inputs[fieldIdentifier.fieldName]; + // @ts-expect-error - field may not have type property + return field?.type?.name === 'ImageField'; + }); + + if (!imageFieldIdentifier) { + throw new Error('Workflow does not have an image input field'); + } + + // Update the workflow nodes with our values + const updatedWorkflow = { + ...workflow, + nodes: workflow.nodes.map((node) => { + if (node.data.id === imageFieldIdentifier.nodeId) { + return { + ...node, + data: { + ...node.data, + inputs: { + ...node.data.inputs, + [imageFieldIdentifier.fieldName]: { + ...node.data.inputs[imageFieldIdentifier.fieldName], + value: imageDTO, + }, + }, + }, + }; + } + + // Apply other field values + if (fieldValues) { + const fieldValue = Object.entries(fieldValues).find( + ([key]) => key === `${node.data.id}.${node.data.inputs}` + ); + if (fieldValue) { + const [, value] = fieldValue; + return { + ...node, + data: { + ...node.data, + inputs: { + ...node.data.inputs, + // @ts-expect-error - dynamic field assignment + [fieldValue[0].split('.')[1]]: value, + }, + }, + }; + } + } + + return node; + }), + }; + + // Build the graph + const state = store.getState(); + const graph = buildNodesGraph( + { + ...state, + nodes: { + ...state.nodes, + nodes: updatedWorkflow.nodes, + edges: updatedWorkflow.edges, + }, + }, + // @ts-expect-error - templates type mismatch + {} + ); + + // 4. Execute the workflow + const result = await dispatch( + queueApi.endpoints.enqueueBatch.initiate({ + batch: { + graph, + runs: 1, + origin: 'canvas_workflow_integration', + destination: 'canvas', + }, + prepend: true, + }) + ).unwrap(); + + // 5. Wait for the result and add it to canvas + // Note: In a real implementation, we would need to listen to socket events + // for the completion of this batch. For now, we'll show a toast and close. + toast({ + status: 'success', + title: t('controlLayers.workflowIntegration.executionStarted', 'Workflow execution started'), + description: t( + 'controlLayers.workflowIntegration.executionStartedDescription', + 'The result will be added to the canvas when complete.' + ), + }); + + dispatch(canvasWorkflowIntegrationClosed()); + } catch (error) { + console.error('Error executing workflow:', error); + toast({ + status: 'error', + title: t('controlLayers.workflowIntegration.executionFailed', 'Failed to execute workflow'), + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + dispatch(canvasWorkflowIntegrationProcessingCompleted()); + } + }, [selectedWorkflowId, sourceEntityIdentifier, fieldValues, canvasManager, dispatch, getWorkflow, store, t]); + + return { + execute, + canExecute, + }; +}; + +// Hook to listen for workflow completion and add result to canvas +export const useCanvasWorkflowIntegrationResultHandler = () => { + const dispatch = useAppDispatch(); + + const handleWorkflowComplete = useCallback( + (imageDTO: ImageDTO) => { + // Add the result as a new raster layer + const imageObject = imageDTOToImageObject(imageDTO); + dispatch( + rasterLayerAdded({ + overrides: { + objects: [imageObject], + position: { x: 0, y: 0 }, + }, + isSelected: true, + }) + ); + + toast({ + status: 'success', + title: 'Workflow result added to canvas', + }); + }, + [dispatch] + ); + + return { handleWorkflowComplete }; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx index 708f7f29cd6..38b25c1b470 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx @@ -6,6 +6,7 @@ import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/c import { CanvasEntityMenuItemsDuplicate } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDuplicate'; import { CanvasEntityMenuItemsFilter } from 'features/controlLayers/components/common/CanvasEntityMenuItemsFilter'; import { CanvasEntityMenuItemsMergeDown } from 'features/controlLayers/components/common/CanvasEntityMenuItemsMergeDown'; +import { CanvasEntityMenuItemsRunWorkflow } from 'features/controlLayers/components/common/CanvasEntityMenuItemsRunWorkflow'; import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSave'; import { CanvasEntityMenuItemsSelectObject } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSelectObject'; import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform'; @@ -24,6 +25,7 @@ export const RasterLayerMenuItems = memo(() => { + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsRunWorkflow.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsRunWorkflow.tsx new file mode 100644 index 00000000000..0264dd23dfb --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsRunWorkflow.tsx @@ -0,0 +1,25 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { canvasWorkflowIntegrationOpened } from 'features/controlLayers/store/canvasWorkflowIntegrationSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiFlowArrowBold } from 'react-icons/pi'; + +export const CanvasEntityMenuItemsRunWorkflow = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext(); + + const onClick = useCallback(() => { + dispatch(canvasWorkflowIntegrationOpened({ sourceEntityIdentifier: entityIdentifier })); + }, [dispatch, entityIdentifier]); + + return ( + }> + {t('controlLayers.workflowIntegration.runWorkflow', 'Run Workflow')} + + ); +}); + +CanvasEntityMenuItemsRunWorkflow.displayName = 'CanvasEntityMenuItemsRunWorkflow'; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasWorkflowIntegrationSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasWorkflowIntegrationSlice.ts new file mode 100644 index 00000000000..060e394bcdc --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasWorkflowIntegrationSlice.ts @@ -0,0 +1,109 @@ +import type { PayloadAction, Selector } from '@reduxjs/toolkit'; +import { createSelector, createSlice } from '@reduxjs/toolkit'; +import type { RootState } from 'app/store/store'; +import type { SliceConfig } from 'app/store/types'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import z from 'zod'; + +const zCanvasWorkflowIntegrationState = z.object({ + isOpen: z.boolean(), + selectedWorkflowId: z.string().nullable(), + sourceEntityIdentifier: z + .object({ + type: z.enum(['raster_layer', 'control_layer', 'regional_guidance', 'inpaint_mask']), + id: z.string(), + }) + .nullable(), + fieldValues: z.record(z.string(), z.any()).nullable(), + isProcessing: z.boolean(), +}); + +export type CanvasWorkflowIntegrationState = z.infer; + +const getInitialState = (): CanvasWorkflowIntegrationState => ({ + isOpen: false, + selectedWorkflowId: null, + sourceEntityIdentifier: null, + fieldValues: null, + isProcessing: false, +}); + +const slice = createSlice({ + name: 'canvasWorkflowIntegration', + initialState: getInitialState(), + reducers: { + canvasWorkflowIntegrationOpened: ( + state, + action: PayloadAction<{ sourceEntityIdentifier: CanvasEntityIdentifier }> + ) => { + state.isOpen = true; + state.sourceEntityIdentifier = action.payload.sourceEntityIdentifier; + state.selectedWorkflowId = null; + state.fieldValues = null; + }, + canvasWorkflowIntegrationClosed: (state) => { + state.isOpen = false; + state.selectedWorkflowId = null; + state.sourceEntityIdentifier = null; + state.fieldValues = null; + state.isProcessing = false; + }, + canvasWorkflowIntegrationWorkflowSelected: (state, action: PayloadAction<{ workflowId: string | null }>) => { + state.selectedWorkflowId = action.payload.workflowId; + // Reset field values when switching workflows + state.fieldValues = null; + }, + canvasWorkflowIntegrationFieldValueChanged: ( + state, + action: PayloadAction<{ fieldName: string; value: unknown }> + ) => { + if (!state.fieldValues) { + state.fieldValues = {}; + } + state.fieldValues[action.payload.fieldName] = action.payload.value; + }, + canvasWorkflowIntegrationFieldValuesReset: (state) => { + state.fieldValues = null; + }, + canvasWorkflowIntegrationProcessingStarted: (state) => { + state.isProcessing = true; + }, + canvasWorkflowIntegrationProcessingCompleted: (state) => { + state.isProcessing = false; + }, + }, +}); + +export const { + canvasWorkflowIntegrationOpened, + canvasWorkflowIntegrationClosed, + canvasWorkflowIntegrationWorkflowSelected, + canvasWorkflowIntegrationFieldValueChanged, + canvasWorkflowIntegrationFieldValuesReset, + canvasWorkflowIntegrationProcessingStarted, + canvasWorkflowIntegrationProcessingCompleted, +} = slice.actions; + +export const canvasWorkflowIntegrationSliceConfig: SliceConfig = { + slice, + schema: zCanvasWorkflowIntegrationState, + getInitialState, +}; + +const selectCanvasWorkflowIntegrationSlice = (state: RootState) => state.canvasWorkflowIntegration; +const createCanvasWorkflowIntegrationSelector = (selector: Selector) => + createSelector(selectCanvasWorkflowIntegrationSlice, selector); + +export const selectCanvasWorkflowIntegrationIsOpen = createCanvasWorkflowIntegrationSelector((state) => state.isOpen); +export const selectCanvasWorkflowIntegrationSelectedWorkflowId = createCanvasWorkflowIntegrationSelector( + (state) => state.selectedWorkflowId +); +export const selectCanvasWorkflowIntegrationSourceEntityIdentifier = createCanvasWorkflowIntegrationSelector( + (state) => state.sourceEntityIdentifier +); +export const selectCanvasWorkflowIntegrationFieldValues = createCanvasWorkflowIntegrationSelector( + (state) => state.fieldValues +); +export const selectCanvasWorkflowIntegrationIsProcessing = createCanvasWorkflowIntegrationSelector( + (state) => state.isProcessing +); From ccacf5df2ad1727c70a3d946c09614e764a2086a Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Mon, 17 Nov 2025 16:16:09 +0100 Subject: [PATCH 2/6] refactor(ui): simplify canvas workflow integration field rendering - Extract WorkflowFieldRenderer component for individual field rendering - Add WorkflowFormPreview component to handle workflow parameter display - Remove workflow compatibility filtering - allow all workflows - Simplify workflow selector to use flattened workflow list - Add comprehensive field type support (String, Integer, Float, Boolean, Enum, Scheduler, Board, Model, Image, Color) - Implement image field selection UI with radio --- ...anvasWorkflowIntegrationParameterPanel.tsx | 90 +-- ...vasWorkflowIntegrationWorkflowSelector.tsx | 55 +- .../WorkflowFieldRenderer.tsx | 540 ++++++++++++++++++ .../WorkflowFormPreview.tsx | 211 +++++++ .../useCanvasWorkflowIntegrationExecute.ts | 308 ++++++---- .../store/canvasWorkflowIntegrationSlice.ts | 15 +- .../nodes/util/graph/graphBuilderUtils.ts | 4 +- .../services/events/onInvocationComplete.tsx | 28 + 8 files changed, 1017 insertions(+), 234 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFieldRenderer.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFormPreview.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationParameterPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationParameterPanel.tsx index 280927e6d94..f59a6c45edb 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationParameterPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationParameterPanel.tsx @@ -1,91 +1,11 @@ -import { Box, Flex, Heading, Spinner, Text } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { canvasWorkflowIntegrationFieldValueChanged, selectCanvasWorkflowIntegrationSelectedWorkflowId } from 'features/controlLayers/store/canvasWorkflowIntegrationSlice'; -import { InputFieldRenderer } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer'; -import { useWorkflowFieldInstances } from 'features/nodes/hooks/useWorkflowFieldInstances'; -import { memo, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useGetWorkflowQuery } from 'services/api/endpoints/workflows'; +import { Box } from '@invoke-ai/ui-library'; +import { WorkflowFormPreview } from 'features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFormPreview'; +import { memo } from 'react'; export const CanvasWorkflowIntegrationParameterPanel = memo(() => { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - - const selectedWorkflowId = useAppSelector(selectCanvasWorkflowIntegrationSelectedWorkflowId); - - const { data: workflow, isLoading } = useGetWorkflowQuery(selectedWorkflowId!, { - skip: !selectedWorkflowId, - }); - - const onFieldValueChanged = useCallback( - (fieldName: string, value: unknown) => { - dispatch(canvasWorkflowIntegrationFieldValueChanged({ fieldName, value })); - }, - [dispatch] - ); - - if (isLoading) { - return ( - - - {t('controlLayers.workflowIntegration.loadingParameters', 'Loading workflow parameters...')} - - ); - } - - if (!workflow) { - return null; - } - - // Get exposed fields that are NOT image fields (those will be auto-populated) - const exposedFieldsToShow = workflow.exposedFields.filter((fieldIdentifier) => { - const node = workflow.nodes.find((n) => n.data.id === fieldIdentifier.nodeId); - if (!node) { - return false; - } - - const field = node.data.inputs[fieldIdentifier.fieldName]; - // @ts-expect-error - field may not have type property - return field?.type?.name !== 'ImageField'; - }); - - if (exposedFieldsToShow.length === 0) { - return ( - - - {t( - 'controlLayers.workflowIntegration.noParametersToCustomize', - 'This workflow has no customizable parameters (all image fields will be auto-populated).' - )} - - - ); - } - return ( - - - {t('controlLayers.workflowIntegration.parameters', 'Workflow Parameters')} - - - {exposedFieldsToShow.map((fieldIdentifier) => { - const node = workflow.nodes.find((n) => n.data.id === fieldIdentifier.nodeId); - if (!node) { - return null; - } - - return ( - - - - ); - })} - + + ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationWorkflowSelector.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationWorkflowSelector.tsx index edd40307c85..4cbd3e93cfa 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationWorkflowSelector.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationWorkflowSelector.tsx @@ -4,6 +4,7 @@ import { canvasWorkflowIntegrationWorkflowSelected, selectCanvasWorkflowIntegrationSelectedWorkflowId, } from 'features/controlLayers/store/canvasWorkflowIntegrationSlice'; +import type { ChangeEvent } from 'react'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows'; @@ -15,11 +16,8 @@ export const CanvasWorkflowIntegrationWorkflowSelector = memo(() => { const selectedWorkflowId = useAppSelector(selectCanvasWorkflowIntegrationSelectedWorkflowId); const { data: workflowsData, isLoading } = useListWorkflowsInfiniteInfiniteQuery( { - queryArg: { - per_page: 100, // Get a reasonable number of workflows - page: 0, - }, - pageParam: 0, + per_page: 100, // Get a reasonable number of workflows + page: 0, }, { selectFromResult: ({ data, isLoading }) => ({ @@ -31,40 +29,14 @@ export const CanvasWorkflowIntegrationWorkflowSelector = memo(() => { const workflows = useMemo(() => { if (!workflowsData) { - return null; + return []; } // Flatten all pages into a single list - return { - items: workflowsData.pages.flatMap((page) => page.items), - }; + return workflowsData.pages.flatMap((page) => page.items); }, [workflowsData]); - // Filter workflows that have image input parameters - const compatibleWorkflows = useMemo(() => { - if (!workflows) { - return []; - } - - return workflows.items.filter((workflow) => { - // Check if the workflow has exposed fields - if (!workflow.exposedFields || workflow.exposedFields.length === 0) { - return false; - } - - // Check if any of the nodes have image input fields - const hasImageInput = workflow.nodes.some((node) => { - return Object.values(node.data.inputs || {}).some((input) => { - // @ts-expect-error - input may not have type property - return input.type?.name === 'ImageField'; - }); - }); - - return hasImageInput; - }); - }, [workflows]); - const onChange = useCallback( - (e: React.ChangeEvent) => { + (e: ChangeEvent) => { const workflowId = e.target.value || null; dispatch(canvasWorkflowIntegrationWorkflowSelected({ workflowId })); }, @@ -80,13 +52,10 @@ export const CanvasWorkflowIntegrationWorkflowSelector = memo(() => { ); } - if (compatibleWorkflows.length === 0) { + if (workflows.length === 0) { return ( - {t( - 'controlLayers.workflowIntegration.noCompatibleWorkflows', - 'No compatible workflows found. Workflows must have image input parameters and exposed fields configured in linear view.' - )} + {t('controlLayers.workflowIntegration.noWorkflowsFound', 'No workflows found.')} ); } @@ -94,8 +63,12 @@ export const CanvasWorkflowIntegrationWorkflowSelector = memo(() => { return ( {t('controlLayers.workflowIntegration.selectWorkflow', 'Select Workflow')} - + {workflows.map((workflow) => ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFieldRenderer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFieldRenderer.tsx new file mode 100644 index 00000000000..5ff01e86e7b --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFieldRenderer.tsx @@ -0,0 +1,540 @@ +import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library'; +import { + Combobox, + Flex, + FormControl, + FormLabel, + IconButton, + Input, + Radio, + Select, + Switch, + Text, + Textarea, +} from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { logger } from 'app/logging/logger'; +import { EMPTY_ARRAY } from 'app/store/constants'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; +import { + canvasWorkflowIntegrationFieldValueChanged, + canvasWorkflowIntegrationImageFieldSelected, + selectCanvasWorkflowIntegrationFieldValues, + selectCanvasWorkflowIntegrationSelectedImageFieldKey, + selectCanvasWorkflowIntegrationSelectedWorkflowId, +} from 'features/controlLayers/store/canvasWorkflowIntegrationSlice'; +import { DndImage } from 'features/dnd/DndImage'; +import { ModelFieldCombobox } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ModelFieldCombobox'; +import { $templates } from 'features/nodes/store/nodesSlice'; +import type { NodeFieldElement } from 'features/nodes/types/workflow'; +import { SCHEDULER_OPTIONS } from 'features/parameters/types/constants'; +import { isParameterScheduler } from 'features/parameters/types/parameterSchemas'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiTrashSimpleBold } from 'react-icons/pi'; +import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { modelConfigsAdapterSelectors, useGetModelConfigsQuery } from 'services/api/endpoints/models'; +import { useGetWorkflowQuery } from 'services/api/endpoints/workflows'; +import type { AnyModelConfig, ImageDTO } from 'services/api/types'; + +const log = logger('canvas-workflow-integration'); + +interface WorkflowFieldRendererProps { + el: NodeFieldElement; +} + +export const WorkflowFieldRenderer = memo(({ el }: WorkflowFieldRendererProps) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const selectedWorkflowId = useAppSelector(selectCanvasWorkflowIntegrationSelectedWorkflowId); + const fieldValues = useAppSelector(selectCanvasWorkflowIntegrationFieldValues); + const selectedImageFieldKey = useAppSelector(selectCanvasWorkflowIntegrationSelectedImageFieldKey); + const templates = useStore($templates); + + const { data: workflow } = useGetWorkflowQuery(selectedWorkflowId!, { + skip: !selectedWorkflowId, + }); + + // Load boards and models for BoardField and ModelIdentifierField + const { data: boardsData } = useListAllBoardsQuery({ include_archived: true }); + const { data: modelsData, isLoading: isLoadingModels } = useGetModelConfigsQuery(); + + const { fieldIdentifier } = el.data; + const fieldKey = `${fieldIdentifier.nodeId}.${fieldIdentifier.fieldName}`; + + log.debug({ fieldIdentifier, fieldKey }, 'Rendering workflow field'); + + // Get the node, field instance, and field template + const { field, fieldTemplate } = useMemo(() => { + if (!workflow?.workflow.nodes) { + log.warn('No workflow nodes found'); + return { field: null, fieldTemplate: null }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const foundNode = workflow.workflow.nodes.find((n: any) => n.data.id === fieldIdentifier.nodeId); + if (!foundNode) { + log.warn({ nodeId: fieldIdentifier.nodeId }, 'Node not found'); + return { field: null, fieldTemplate: null }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const foundField = (foundNode.data as any).inputs[fieldIdentifier.fieldName]; + if (!foundField) { + log.warn({ nodeId: fieldIdentifier.nodeId, fieldName: fieldIdentifier.fieldName }, 'Field not found in node'); + return { field: null, fieldTemplate: null }; + } + + // Get the field template from the invocation templates + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nodeType = (foundNode.data as any).type; + const template = templates[nodeType]; + if (!template) { + log.warn({ nodeType }, 'No template found for node type'); + return { field: foundField, fieldTemplate: null }; + } + + const foundFieldTemplate = template.inputs[fieldIdentifier.fieldName]; + if (!foundFieldTemplate) { + log.warn({ nodeType, fieldName: fieldIdentifier.fieldName }, 'Field template not found'); + return { field: foundField, fieldTemplate: null }; + } + + return { field: foundField, fieldTemplate: foundFieldTemplate }; + }, [workflow, fieldIdentifier, templates]); + + // Get the current value from Redux or fallback to field default + const currentValue = useMemo(() => { + if (fieldValues && fieldKey in fieldValues) { + return fieldValues[fieldKey]; + } + + return field?.value ?? fieldTemplate?.default ?? ''; + }, [fieldValues, fieldKey, field, fieldTemplate]); + + // Get field type from the template + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fieldType = fieldTemplate ? (fieldTemplate as any).type?.name : null; + + const handleChange = useCallback( + (value: unknown) => { + dispatch(canvasWorkflowIntegrationFieldValueChanged({ fieldName: fieldKey, value })); + }, + [dispatch, fieldKey] + ); + + const handleStringChange = useCallback( + (e: ChangeEvent) => { + handleChange(e.target.value); + }, + [handleChange] + ); + + const handleNumberChange = useCallback( + (e: ChangeEvent) => { + const val = fieldType === 'IntegerField' ? parseInt(e.target.value, 10) : parseFloat(e.target.value); + handleChange(isNaN(val) ? 0 : val); + }, + [handleChange, fieldType] + ); + + const handleBooleanChange = useCallback( + (e: ChangeEvent) => { + handleChange(e.target.checked); + }, + [handleChange] + ); + + const handleSelectChange = useCallback( + (e: ChangeEvent) => { + handleChange(e.target.value); + }, + [handleChange] + ); + + // SchedulerField handlers + const handleSchedulerChange = useCallback( + (v) => { + if (!isParameterScheduler(v?.value)) { + return; + } + handleChange(v.value); + }, + [handleChange] + ); + + const schedulerValue = useMemo(() => SCHEDULER_OPTIONS.find((o) => o.value === currentValue), [currentValue]); + + // BoardField handlers + const handleBoardChange = useCallback( + (v) => { + if (!v) { + return; + } + const value = v.value === 'auto' || v.value === 'none' ? v.value : { board_id: v.value }; + handleChange(value); + }, + [handleChange] + ); + + const boardOptions = useMemo(() => { + const _options: ComboboxOption[] = [ + { label: t('common.auto'), value: 'auto' }, + { label: `${t('common.none')} (${t('boards.uncategorized')})`, value: 'none' }, + ]; + if (boardsData) { + for (const board of boardsData) { + _options.push({ + label: board.board_name, + value: board.board_id, + }); + } + } + return _options; + }, [boardsData, t]); + + const boardValue = useMemo(() => { + const _value = currentValue; + const autoOption = boardOptions[0]; + const noneOption = boardOptions[1]; + if (!_value || _value === 'auto') { + return autoOption; + } + if (_value === 'none') { + return noneOption; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const boardId = typeof _value === 'object' ? (_value as any).board_id : _value; + const boardOption = boardOptions.find((o) => o.value === boardId); + return boardOption ?? autoOption; + }, [currentValue, boardOptions]); + + const noOptionsMessage = useCallback(() => t('boards.noMatching'), [t]); + + // ModelIdentifierField handlers + const handleModelChange = useCallback( + (value: AnyModelConfig | null) => { + if (!value) { + return; + } + handleChange(value); + }, + [handleChange] + ); + + const modelConfigs = useMemo(() => { + if (!modelsData) { + return EMPTY_ARRAY; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ui_model_base = fieldTemplate ? (fieldTemplate as any)?.ui_model_base : null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ui_model_type = fieldTemplate ? (fieldTemplate as any)?.ui_model_type : null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ui_model_variant = fieldTemplate ? (fieldTemplate as any)?.ui_model_variant : null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ui_model_format = fieldTemplate ? (fieldTemplate as any)?.ui_model_format : null; + + if (!ui_model_base && !ui_model_type) { + return modelConfigsAdapterSelectors.selectAll(modelsData); + } + + return modelConfigsAdapterSelectors.selectAll(modelsData).filter((config) => { + if (ui_model_base && !ui_model_base.includes(config.base)) { + return false; + } + if (ui_model_type && !ui_model_type.includes(config.type)) { + return false; + } + if (ui_model_variant && 'variant' in config && config.variant && !ui_model_variant.includes(config.variant)) { + return false; + } + if (ui_model_format && !ui_model_format.includes(config.format)) { + return false; + } + return true; + }); + }, [modelsData, fieldTemplate]); + + // ImageField handler + const handleImageFieldSelect = useCallback(() => { + dispatch(canvasWorkflowIntegrationImageFieldSelected({ fieldKey })); + }, [dispatch, fieldKey]); + + if (!field || !fieldTemplate) { + log.warn({ fieldIdentifier }, 'Field or template is null - not rendering'); + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const label = (field as any)?.label || (fieldTemplate as any)?.title || fieldIdentifier.fieldName; + + // Log the entire field structure to understand its shape + log.debug( + { fieldType, label, currentValue, fieldStructure: field, fieldTemplateStructure: fieldTemplate }, + 'Field info' + ); + + // ImageField - allow user to select which one receives the canvas image + if (fieldType === 'ImageField') { + return ( + + ); + } + + // Render different input types based on field type + if (fieldType === 'StringField') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isTextarea = (fieldTemplate as any)?.ui_component === 'textarea'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isRequired = (fieldTemplate as any)?.required ?? false; + + if (isTextarea) { + return ( + + {label} +