From 84cf8bdc08aa94e608ffa6715910c883cca991cb Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 17 Aug 2023 17:44:34 +1000 Subject: [PATCH] feat(ui): field context menu, add/remove from linear ui --- .../common/components/SelectionOverlay.tsx | 1 + .../nodes/components/Invocation/NodeTitle.tsx | 1 + .../components/fields/FieldContextMenu.tsx | 135 +++++++++++++++--- .../nodes/components/fields/FieldTitle.tsx | 93 +++++------- .../components/fields/FieldTooltipContent.tsx | 3 +- .../nodes/components/fields/InputField.tsx | 111 ++++++++------ .../components/fields/LinearViewField.tsx | 74 +++++++--- .../fields/fieldTypes/ImageInputField.tsx | 16 ++- .../components/panel/workflow/LinearTab.tsx | 11 +- .../src/features/nodes/hooks/useNodeData.ts | 105 +++++++++++++- .../src/features/nodes/store/nodesSlice.ts | 8 ++ .../web/src/features/nodes/store/types.ts | 2 + 12 files changed, 400 insertions(+), 160 deletions(-) diff --git a/invokeai/frontend/web/src/common/components/SelectionOverlay.tsx b/invokeai/frontend/web/src/common/components/SelectionOverlay.tsx index 9ff6cd341b..eb04c7c56d 100644 --- a/invokeai/frontend/web/src/common/components/SelectionOverlay.tsx +++ b/invokeai/frontend/web/src/common/components/SelectionOverlay.tsx @@ -18,6 +18,7 @@ const SelectionOverlay = ({ isSelected, isHovered }: Props) => { opacity: isSelected ? 1 : 0.7, transitionProperty: 'common', transitionDuration: '0.1s', + pointerEvents: 'none', shadow: isSelected ? isHovered ? 'hoverSelected.light' diff --git a/invokeai/frontend/web/src/features/nodes/components/Invocation/NodeTitle.tsx b/invokeai/frontend/web/src/features/nodes/components/Invocation/NodeTitle.tsx index d816f3cea1..6b14d4e952 100644 --- a/invokeai/frontend/web/src/features/nodes/components/Invocation/NodeTitle.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/Invocation/NodeTitle.tsx @@ -79,6 +79,7 @@ const NodeTitle = ({ nodeId, title }: Props) => { fontSize="sm" sx={{ p: 0, + fontWeight: 'initial', _focusVisible: { p: 0, boxShadow: 'none', diff --git a/invokeai/frontend/web/src/features/nodes/components/fields/FieldContextMenu.tsx b/invokeai/frontend/web/src/features/nodes/components/fields/FieldContextMenu.tsx index d9f8f951bc..d93f9a4241 100644 --- a/invokeai/frontend/web/src/features/nodes/components/fields/FieldContextMenu.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/fields/FieldContextMenu.tsx @@ -1,26 +1,111 @@ -import { MenuItem, MenuList } from '@chakra-ui/react'; -import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu'; +import { MenuGroup, MenuItem, MenuList } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { - InputFieldTemplate, - InputFieldValue, -} from 'features/nodes/types/types'; -import { MouseEvent, useCallback } from 'react'; + IAIContextMenu, + IAIContextMenuProps, +} from 'common/components/IAIContextMenu'; +import { + useFieldInputKind, + useFieldLabel, + useFieldTemplateTitle, +} from 'features/nodes/hooks/useNodeData'; +import { + workflowExposedFieldAdded, + workflowExposedFieldRemoved, +} from 'features/nodes/store/nodesSlice'; +import { MouseEvent, ReactNode, memo, useCallback, useMemo } from 'react'; +import { FaMinus, FaPlus } from 'react-icons/fa'; import { menuListMotionProps } from 'theme/components/menu'; type Props = { nodeId: string; - field: InputFieldValue; - fieldTemplate: InputFieldTemplate; - children: ContextMenuProps['children']; + fieldName: string; + kind: 'input' | 'output'; + children: IAIContextMenuProps['children']; }; -const FieldContextMenu = (props: Props) => { +const FieldContextMenu = ({ nodeId, fieldName, kind, children }: Props) => { + const dispatch = useAppDispatch(); + const label = useFieldLabel(nodeId, fieldName); + const fieldTemplateTitle = useFieldTemplateTitle(nodeId, fieldName, kind); + const skipEvent = useCallback((e: MouseEvent) => { e.preventDefault(); }, []); + const selector = useMemo( + () => + createSelector( + stateSelector, + ({ nodes }) => { + const isExposed = Boolean( + nodes.workflow.exposedFields.find( + (f) => f.nodeId === nodeId && f.fieldName === fieldName + ) + ); + + return { isExposed }; + }, + defaultSelectorOptions + ), + [fieldName, nodeId] + ); + + const input = useFieldInputKind(nodeId, fieldName); + const mayExpose = useMemo( + () => ['any', 'direct'].includes(input ?? '__UNKNOWN_INPUT__'), + [input] + ); + + const { isExposed } = useAppSelector(selector); + + const handleExposeField = useCallback(() => { + dispatch(workflowExposedFieldAdded({ nodeId, fieldName })); + }, [dispatch, fieldName, nodeId]); + + const handleUnexposeField = useCallback(() => { + dispatch(workflowExposedFieldRemoved({ nodeId, fieldName })); + }, [dispatch, fieldName, nodeId]); + + const menuItems = useMemo(() => { + const menuItems: ReactNode[] = []; + if (mayExpose && !isExposed) { + menuItems.push( + } + onClick={handleExposeField} + > + Add to Linear View + + ); + } + if (mayExpose && isExposed) { + menuItems.push( + } + onClick={handleUnexposeField} + > + Remove from Linear View + + ); + } + return menuItems; + }, [ + fieldName, + handleExposeField, + handleUnexposeField, + isExposed, + mayExpose, + nodeId, + ]); + return ( - + menuProps={{ size: 'sm', isLazy: true, @@ -29,19 +114,23 @@ const FieldContextMenu = (props: Props) => { bg: 'transparent', _hover: { bg: 'transparent' }, }} - renderMenu={() => ( - - Test - - )} + renderMenu={() => + !menuItems.length ? null : ( + + + {menuItems} + + + ) + } > - {props.children} - + {children} + ); }; -export default FieldContextMenu; +export default memo(FieldContextMenu); diff --git a/invokeai/frontend/web/src/features/nodes/components/fields/FieldTitle.tsx b/invokeai/frontend/web/src/features/nodes/components/fields/FieldTitle.tsx index e9a49989f6..a84358bf78 100644 --- a/invokeai/frontend/web/src/features/nodes/components/fields/FieldTitle.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/fields/FieldTitle.tsx @@ -3,63 +3,42 @@ import { EditableInput, EditablePreview, Flex, + forwardRef, useEditableControls, } from '@chakra-ui/react'; import { useAppDispatch } from 'app/store/storeHooks'; -import IAIDraggable from 'common/components/IAIDraggable'; -import { NodeFieldDraggableData } from 'features/dnd/types'; import { - useFieldData, - useFieldTemplate, + useFieldLabel, + useFieldTemplateTitle, } from 'features/nodes/hooks/useNodeData'; import { fieldLabelChanged } from 'features/nodes/store/nodesSlice'; -import { - MouseEvent, - memo, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; +import { MouseEvent, memo, useCallback, useEffect, useState } from 'react'; interface Props { nodeId: string; fieldName: string; - isDraggable?: boolean; kind: 'input' | 'output'; } -const FieldTitle = (props: Props) => { - const { nodeId, fieldName, isDraggable = false, kind } = props; - const fieldTemplate = useFieldTemplate(nodeId, fieldName, kind); - const field = useFieldData(nodeId, fieldName); +const FieldTitle = forwardRef((props: Props, ref) => { + const { nodeId, fieldName, kind } = props; + const label = useFieldLabel(nodeId, fieldName); + const fieldTemplateTitle = useFieldTemplateTitle(nodeId, fieldName, kind); const dispatch = useAppDispatch(); const [localTitle, setLocalTitle] = useState( - field?.label || fieldTemplate?.title || 'Unknown Field' - ); - - const draggableData: NodeFieldDraggableData | undefined = useMemo( - () => - field && - fieldTemplate?.fieldKind === 'input' && - fieldTemplate?.input !== 'connection' && - isDraggable - ? { - id: `${nodeId}-${fieldName}`, - payloadType: 'NODE_FIELD', - payload: { nodeId, field, fieldTemplate }, - } - : undefined, - [field, fieldName, fieldTemplate, isDraggable, nodeId] + label || fieldTemplateTitle || 'Unknown Field' ); const handleSubmit = useCallback( async (newTitle: string) => { + if (newTitle === label || newTitle === fieldTemplateTitle) { + return; + } + setLocalTitle(newTitle || fieldTemplateTitle || 'Unknown Field'); dispatch(fieldLabelChanged({ nodeId, fieldName, label: newTitle })); - setLocalTitle(newTitle || fieldTemplate?.title || 'Unknown Field'); }, - [dispatch, nodeId, fieldName, fieldTemplate?.title] + [label, fieldTemplateTitle, dispatch, nodeId, fieldName] ); const handleChange = useCallback((newTitle: string) => { @@ -68,27 +47,33 @@ const FieldTitle = (props: Props) => { useEffect(() => { // Another component may change the title; sync local title with global state - setLocalTitle(field?.label || fieldTemplate?.title || 'Unknown Field'); - }, [field?.label, fieldTemplate?.title]); + setLocalTitle(label || fieldTemplateTitle || 'Unknown Field'); + }, [label, fieldTemplateTitle]); return ( { { }, }} /> - + ); -}; +}); export default memo(FieldTitle); -type EditableControlsProps = { - draggableData?: NodeFieldDraggableData; -}; - -const EditableControls = memo((props: EditableControlsProps) => { +const EditableControls = memo(() => { const { isEditing, getEditButtonProps } = useEditableControls(); - const handleDoubleClick = useCallback( + const handleClick = useCallback( (e: MouseEvent) => { const { onClick } = getEditButtonProps(); if (!onClick) { return; } onClick(e); + e.preventDefault(); }, [getEditButtonProps] ); @@ -137,19 +124,9 @@ const EditableControls = memo((props: EditableControlsProps) => { return null; } - if (props.draggableData) { - return ( - - ); - } - return ( { const isInputTemplate = isInputFieldTemplate(fieldTemplate); const fieldTitle = useMemo(() => { if (isInputFieldValue(field)) { - if (field.label && fieldTemplate) { + console.log(field, fieldTemplate); + if (field.label && fieldTemplate?.title) { return `${field.label} (${fieldTemplate.title})`; } diff --git a/invokeai/frontend/web/src/features/nodes/components/fields/InputField.tsx b/invokeai/frontend/web/src/features/nodes/components/fields/InputField.tsx index 47033baa7b..e099180a7f 100644 --- a/invokeai/frontend/web/src/features/nodes/components/fields/InputField.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/fields/InputField.tsx @@ -3,13 +3,16 @@ import { useConnectionState } from 'features/nodes/hooks/useConnectionState'; import { useDoesInputHaveValue, useFieldTemplate, + useIsMouseOverField, } from 'features/nodes/hooks/useNodeData'; import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants'; import { PropsWithChildren, memo, useMemo } from 'react'; +import FieldContextMenu from './FieldContextMenu'; import FieldHandle from './FieldHandle'; import FieldTitle from './FieldTitle'; import FieldTooltipContent from './FieldTooltipContent'; import InputFieldRenderer from './InputFieldRenderer'; +import SelectionOverlay from 'common/components/SelectionOverlay'; interface Props { nodeId: string; @@ -48,7 +51,11 @@ const InputField = ({ nodeId, fieldName }: Props) => { if (fieldTemplate?.fieldKind !== 'input') { return ( - + @@ -59,40 +66,48 @@ const InputField = ({ nodeId, fieldName }: Props) => { } return ( - + - - } - openDelay={HANDLE_TOOLTIP_OPEN_DELAY} - placement="top" - shouldWrapChildren - hasArrow - > - - - - + + {(ref) => ( + + } + openDelay={HANDLE_TOOLTIP_OPEN_DELAY} + placement="top" + hasArrow + > + + + + + )} + @@ -113,27 +128,37 @@ export default InputField; type InputFieldWrapperProps = PropsWithChildren<{ shouldDim: boolean; + nodeId: string; + fieldName: string; }>; const InputFieldWrapper = memo( - ({ shouldDim, children }: InputFieldWrapperProps) => ( - - {children} - - ) + ({ shouldDim, nodeId, fieldName, children }: InputFieldWrapperProps) => { + const { isMouseOverField, handleMouseOver, handleMouseOut } = + useIsMouseOverField(nodeId, fieldName); + + return ( + + {children} + + + ); + } ); InputFieldWrapper.displayName = 'InputFieldWrapper'; diff --git a/invokeai/frontend/web/src/features/nodes/components/fields/LinearViewField.tsx b/invokeai/frontend/web/src/features/nodes/components/fields/LinearViewField.tsx index ea4bb76d62..2f4dc84827 100644 --- a/invokeai/frontend/web/src/features/nodes/components/fields/LinearViewField.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/fields/LinearViewField.tsx @@ -1,6 +1,12 @@ -import { Flex, FormControl, FormLabel, Tooltip } from '@chakra-ui/react'; +import { Flex, FormControl, FormLabel, Icon, Tooltip } from '@chakra-ui/react'; +import { useAppDispatch } from 'app/store/storeHooks'; +import IAIIconButton from 'common/components/IAIIconButton'; +import SelectionOverlay from 'common/components/SelectionOverlay'; +import { useIsMouseOverField } from 'features/nodes/hooks/useNodeData'; +import { workflowExposedFieldRemoved } from 'features/nodes/store/nodesSlice'; import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants'; -import { memo } from 'react'; +import { memo, useCallback } from 'react'; +import { FaInfoCircle, FaTrash } from 'react-icons/fa'; import FieldTitle from './FieldTitle'; import FieldTooltipContent from './FieldTooltipContent'; import InputFieldRenderer from './InputFieldRenderer'; @@ -11,8 +17,18 @@ type Props = { }; const LinearViewField = ({ nodeId, fieldName }: Props) => { + const dispatch = useAppDispatch(); + const { isMouseOverField, handleMouseOut, handleMouseOver } = + useIsMouseOverField(nodeId, fieldName); + + const handleRemoveField = useCallback(() => { + dispatch(workflowExposedFieldRemoved({ nodeId, fieldName })); + }, [dispatch, fieldName, nodeId]); + return ( { }} > - - } - openDelay={HANDLE_TOOLTIP_OPEN_DELAY} - placement="top" - shouldWrapChildren - hasArrow + - + + } + openDelay={HANDLE_TOOLTIP_OPEN_DELAY} + placement="top" + hasArrow > - - - + + + + + } + /> + + ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/fields/fieldTypes/ImageInputField.tsx b/invokeai/frontend/web/src/features/nodes/components/fields/fieldTypes/ImageInputField.tsx index 0391136dba..21b89c2231 100644 --- a/invokeai/frontend/web/src/features/nodes/components/fields/fieldTypes/ImageInputField.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/fields/fieldTypes/ImageInputField.tsx @@ -1,4 +1,4 @@ -import { Flex } from '@chakra-ui/react'; +import { Flex, Text } from '@chakra-ui/react'; import { skipToken } from '@reduxjs/toolkit/dist/query'; import { useAppDispatch } from 'app/store/storeHooks'; import IAIDndImage from 'common/components/IAIDndImage'; @@ -81,6 +81,9 @@ const ImageInputFieldComponent = ( draggableData={draggableData} postUploadAction={postUploadAction} useThumbailFallback + uploadElement={} + dropLabel={} + minSize={8} > ( + + Drop or Upload + +); +const DropLabel = () => ( + + Drop + +); diff --git a/invokeai/frontend/web/src/features/nodes/components/panel/workflow/LinearTab.tsx b/invokeai/frontend/web/src/features/nodes/components/panel/workflow/LinearTab.tsx index cc7428a8ec..b77453b749 100644 --- a/invokeai/frontend/web/src/features/nodes/components/panel/workflow/LinearTab.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/panel/workflow/LinearTab.tsx @@ -3,9 +3,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { stateSelector } from 'app/store/store'; import { useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import IAIDroppable from 'common/components/IAIDroppable'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; -import { AddFieldToLinearViewDropData } from 'features/dnd/types'; import { memo } from 'react'; import LinearViewField from '../../fields/LinearViewField'; import ScrollableContent from '../ScrollableContent'; @@ -20,11 +18,6 @@ const selector = createSelector( defaultSelectorOptions ); -const droppableData: AddFieldToLinearViewDropData = { - id: 'add-field-to-linear-view', - actionType: 'ADD_FIELD_TO_LINEAR', -}; - const LinearTabContent = () => { const { fields } = useAppSelector(selector); @@ -42,6 +35,7 @@ const LinearTabContent = () => { position: 'relative', flexDir: 'column', alignItems: 'flex-start', + p: 1, gap: 2, h: 'full', w: 'full', @@ -50,7 +44,7 @@ const LinearTabContent = () => { {fields.length ? ( fields.map(({ nodeId, fieldName }) => ( @@ -63,7 +57,6 @@ const LinearTabContent = () => { )} - ); }; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeData.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeData.ts index 231c7678ef..e0fddb9f8c 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeData.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeData.ts @@ -1,9 +1,10 @@ import { createSelector } from '@reduxjs/toolkit'; import { stateSelector } from 'app/store/store'; -import { useAppSelector } from 'app/store/storeHooks'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { map, some } from 'lodash-es'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; +import { mouseOverFieldChanged } from '../store/nodesSlice'; import { FOOTER_FIELDS, IMAGE_FIELDS } from '../types/constants'; import { isInvocationNode } from '../types/types'; @@ -51,6 +52,28 @@ export const useNodeData = (nodeId: string) => { return nodeData; }; +export const useFieldLabel = (nodeId: string, fieldName: string) => { + const selector = useMemo( + () => + createSelector( + stateSelector, + ({ nodes }) => { + const node = nodes.nodes.find((node) => node.id === nodeId); + if (!isInvocationNode(node)) { + return; + } + return node?.data.inputs[fieldName]?.label; + }, + defaultSelectorOptions + ), + [fieldName, nodeId] + ); + + const label = useAppSelector(selector); + + return label; +}; + export const useFieldData = (nodeId: string, fieldName: string) => { const selector = useMemo( () => @@ -73,6 +96,30 @@ export const useFieldData = (nodeId: string, fieldName: string) => { return fieldData; }; +export const useFieldInputKind = (nodeId: string, fieldName: string) => { + const selector = useMemo( + () => + createSelector( + stateSelector, + ({ nodes }) => { + const node = nodes.nodes.find((node) => node.id === nodeId); + if (!isInvocationNode(node)) { + return; + } + const nodeTemplate = nodes.nodeTemplates[node?.data.type ?? '']; + const fieldTemplate = nodeTemplate?.inputs[fieldName]; + return fieldTemplate?.input; + }, + defaultSelectorOptions + ), + [fieldName, nodeId] + ); + + const fieldType = useAppSelector(selector); + + return fieldType; +}; + export const useFieldType = ( nodeId: string, fieldName: string, @@ -236,6 +283,33 @@ export const useNodeTemplateTitle = (nodeId: string) => { return title; }; +export const useFieldTemplateTitle = ( + nodeId: string, + fieldName: string, + kind: 'input' | 'output' +) => { + const selector = useMemo( + () => + createSelector( + stateSelector, + ({ nodes }) => { + const node = nodes.nodes.find((node) => node.id === nodeId); + if (!isInvocationNode(node)) { + return; + } + const nodeTemplate = nodes.nodeTemplates[node?.data.type ?? '']; + return nodeTemplate?.[KIND_MAP[kind]][fieldName]?.title; + }, + defaultSelectorOptions + ), + [fieldName, kind, nodeId] + ); + + const fieldTemplate = useAppSelector(selector); + + return fieldTemplate; +}; + export const useFieldTemplate = ( nodeId: string, fieldName: string, @@ -284,3 +358,30 @@ export const useDoesInputHaveValue = (nodeId: string, fieldName: string) => { return doesFieldHaveValue; }; + +export const useIsMouseOverField = (nodeId: string, fieldName: string) => { + const dispatch = useAppDispatch(); + const selector = useMemo( + () => + createSelector( + stateSelector, + ({ nodes }) => + nodes.mouseOverField?.nodeId === nodeId && + nodes.mouseOverField?.fieldName === fieldName, + defaultSelectorOptions + ), + [fieldName, nodeId] + ); + + const isMouseOverField = useAppSelector(selector); + + const handleMouseOver = useCallback(() => { + dispatch(mouseOverFieldChanged({ nodeId, fieldName })); + }, [dispatch, fieldName, nodeId]); + + const handleMouseOut = useCallback(() => { + dispatch(mouseOverFieldChanged(null)); + }, [dispatch]); + + return { isMouseOverField, handleMouseOver, handleMouseOut }; +}; diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 5ee2eefeb2..048894b166 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -81,6 +81,7 @@ export const initialNodesState: NodesState = { }, nodeExecutionStates: {}, viewport: { x: 0, y: 0, zoom: 1 }, + mouseOverField: null, }; type FieldValueAction = PayloadAction<{ @@ -594,6 +595,12 @@ const nodesSlice = createSlice({ viewportChanged: (state, action: PayloadAction) => { state.viewport = action.payload; }, + mouseOverFieldChanged: ( + state, + action: PayloadAction + ) => { + state.mouseOverField = action.payload; + }, }, extraReducers: (builder) => { builder.addCase(receivedOpenAPISchema.pending, (state) => { @@ -701,6 +708,7 @@ export const { workflowExposedFieldRemoved, fieldLabelChanged, viewportChanged, + mouseOverFieldChanged, } = nodesSlice.actions; export default nodesSlice.reducer; diff --git a/invokeai/frontend/web/src/features/nodes/store/types.ts b/invokeai/frontend/web/src/features/nodes/store/types.ts index 27e25b8731..160336cef5 100644 --- a/invokeai/frontend/web/src/features/nodes/store/types.ts +++ b/invokeai/frontend/web/src/features/nodes/store/types.ts @@ -1,6 +1,7 @@ import { OpenAPIV3 } from 'openapi-types'; import { Edge, Node, OnConnectStartParams, Viewport } from 'reactflow'; import { + FieldIdentifier, FieldType, InvocationEdgeExtra, InvocationTemplate, @@ -29,4 +30,5 @@ export type NodesState = { nodeExecutionStates: Record; viewport: Viewport; isReady: boolean; + mouseOverField: FieldIdentifier | null; };