diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx index af47649a20..0781f72d08 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx @@ -1,10 +1,11 @@ -import { Spacer } from '@invoke-ai/ui-library'; +import { Spacer, useDisclosure } from '@invoke-ai/ui-library'; import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer'; import { CanvasEntityDeleteButton } from 'features/controlLayers/components/common/CanvasEntityDeleteButton'; import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle'; import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; import { CanvasEntitySettingsWrapper } from 'features/controlLayers/components/common/CanvasEntitySettingsWrapper'; import { CanvasEntityTitle } from 'features/controlLayers/components/common/CanvasEntityTitle'; +import { CanvasEntityTitleEdit } from 'features/controlLayers/components/common/CanvasEntityTitleEdit'; import { ControlLayerControlAdapter } from 'features/controlLayers/components/ControlLayer/ControlLayerControlAdapter'; import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; @@ -16,13 +17,14 @@ type Props = { export const ControlLayer = memo(({ id }: Props) => { const entityIdentifier = useMemo(() => ({ id, type: 'control_layer' }), [id]); + const editing = useDisclosure({ defaultIsOpen: false }); return ( - + - + {editing.isOpen ? : } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx index 303d4191ed..11008749db 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -1,9 +1,10 @@ -import { Spacer } from '@invoke-ai/ui-library'; +import { Spacer, useDisclosure } from '@invoke-ai/ui-library'; import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer'; import { CanvasEntityDeleteButton } from 'features/controlLayers/components/common/CanvasEntityDeleteButton'; import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle'; import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; import { CanvasEntityTitle } from 'features/controlLayers/components/common/CanvasEntityTitle'; +import { CanvasEntityTitleEdit } from 'features/controlLayers/components/common/CanvasEntityTitleEdit'; import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { memo, useMemo } from 'react'; @@ -14,13 +15,14 @@ type Props = { export const RasterLayer = memo(({ id }: Props) => { const entityIdentifier = useMemo(() => ({ id, type: 'raster_layer' }), [id]); + const editing = useDisclosure({ defaultIsOpen: false }); return ( - + - + {editing.isOpen ? : } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidance.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidance.tsx index fd368e53e1..3da03db049 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidance.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidance.tsx @@ -1,9 +1,10 @@ -import { Spacer } from '@invoke-ai/ui-library'; +import { Spacer, useDisclosure } from '@invoke-ai/ui-library'; import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer'; import { CanvasEntityDeleteButton } from 'features/controlLayers/components/common/CanvasEntityDeleteButton'; import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle'; import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; import { CanvasEntityTitle } from 'features/controlLayers/components/common/CanvasEntityTitle'; +import { CanvasEntityTitleEdit } from 'features/controlLayers/components/common/CanvasEntityTitleEdit'; import { RegionalGuidanceBadges } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceBadges'; import { RegionalGuidanceSettings } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceSettings'; import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; @@ -19,12 +20,14 @@ type Props = { export const RegionalGuidance = memo(({ id }: Props) => { const entityIdentifier = useMemo(() => ({ id, type: 'regional_guidance' }), [id]); + const editing = useDisclosure({ defaultIsOpen: false }); + return ( - + - + {editing.isOpen ? : } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityTitleEdit.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityTitleEdit.tsx new file mode 100644 index 0000000000..0cc621ef63 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityTitleEdit.tsx @@ -0,0 +1,56 @@ +import { Input } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useEntityTitle } from 'features/controlLayers/hooks/useEntityTitle'; +import { entityNameChanged } from 'features/controlLayers/store/canvasV2Slice'; +import type { ChangeEvent, KeyboardEvent } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; + +type Props = { + onStopEditing: () => void; +}; + +export const CanvasEntityTitleEdit = memo(({ onStopEditing }: Props) => { + const dispatch = useAppDispatch(); + const ref = useRef(null); + const entityIdentifier = useEntityIdentifierContext(); + const title = useEntityTitle(entityIdentifier); + const [localTitle, setLocalTitle] = useState(title); + + const onChange = useCallback((e: ChangeEvent) => { + setLocalTitle(e.target.value); + }, []); + + const onBlur = useCallback(() => { + const trimmedTitle = localTitle.trim(); + if (trimmedTitle.length === 0) { + dispatch(entityNameChanged({ entityIdentifier, name: null })); + } else if (trimmedTitle !== title) { + dispatch(entityNameChanged({ entityIdentifier, name: trimmedTitle })); + } + onStopEditing(); + }, [dispatch, entityIdentifier, localTitle, onStopEditing, title]); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + onBlur(); + } else if (e.key === 'Escape') { + setLocalTitle(title); + onStopEditing(); + } + }, + [onBlur, onStopEditing, title] + ); + + useEffect(() => { + ref.current?.focus(); + ref.current?.select(); + }, []); + + return ( + + ); +}); + +CanvasEntityTitleEdit.displayName = 'CanvasEntityTitleEdit'; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTitle.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTitle.ts index a56c82d14b..e5723d8878 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTitle.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTitle.ts @@ -1,15 +1,35 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; import { useEntityObjectCount } from 'features/controlLayers/hooks/useEntityObjectCount'; +import { selectCanvasV2Slice, selectEntity } from 'features/controlLayers/store/canvasV2Slice'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { assert } from 'tsafe'; +const createSelectName = (entityIdentifier: CanvasEntityIdentifier) => + createSelector(selectCanvasV2Slice, (canvasV2) => { + const entity = selectEntity(canvasV2, entityIdentifier); + if (!entity) { + return null; + } + if (entity.type === 'inpaint_mask') { + return null; + } + return entity.name; + }); + export const useEntityTitle = (entityIdentifier: CanvasEntityIdentifier) => { const { t } = useTranslation(); - + const selectName = useMemo(() => createSelectName(entityIdentifier), [entityIdentifier]); + const name = useAppSelector(selectName); const objectCount = useEntityObjectCount(entityIdentifier); const title = useMemo(() => { + if (name) { + return name; + } + const parts: string[] = []; if (entityIdentifier.type === 'inpaint_mask') { parts.push(t('controlLayers.inpaintMask')); @@ -30,7 +50,7 @@ export const useEntityTitle = (entityIdentifier: CanvasEntityIdentifier) => { } return parts.join(' '); - }, [entityIdentifier.type, objectCount, t]); + }, [entityIdentifier.type, name, objectCount, t]); return title; }; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts index c3818f3703..450b850c4e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts @@ -198,6 +198,18 @@ export const canvasV2Slice = createSlice({ const { entityIdentifier } = action.payload; state.selectedEntityIdentifier = entityIdentifier; }, + entityNameChanged: (state, action: PayloadAction) => { + const { entityIdentifier, name } = action.payload; + const entity = selectEntity(state, entityIdentifier); + if (!entity) { + return; + } + if (entity.type === 'inpaint_mask') { + // Inpaint mask cannot be renamed + return; + } + entity.name = name; + }, entityReset: (state, action: PayloadAction) => { const { entityIdentifier } = action.payload; const entity = selectEntity(state, entityIdentifier); @@ -451,6 +463,7 @@ export const { rasterizationCachesInvalidated, // All entities entitySelected, + entityNameChanged, entityReset, entityIsEnabledToggled, entityMoved, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersReducers.ts b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersReducers.ts index 5e4ef6f251..6133f5f790 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/controlLayersReducers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/controlLayersReducers.ts @@ -33,6 +33,7 @@ export const controlLayersReducers = { const { id, overrides, isSelected } = action.payload; const layer: CanvasControlLayerState = { id, + name: null, type: 'control_layer', isEnabled: true, objects: [], diff --git a/invokeai/frontend/web/src/features/controlLayers/store/rasterLayersReducers.ts b/invokeai/frontend/web/src/features/controlLayers/store/rasterLayersReducers.ts index 728cfe24fb..df6326dc9f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/rasterLayersReducers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/rasterLayersReducers.ts @@ -30,6 +30,7 @@ export const rasterLayersReducers = { const { id, overrides, isSelected } = action.payload; const layer: CanvasRasterLayerState = { id, + name: null, type: 'raster_layer', isEnabled: true, objects: [], diff --git a/invokeai/frontend/web/src/features/controlLayers/store/regionsReducers.ts b/invokeai/frontend/web/src/features/controlLayers/store/regionsReducers.ts index fa7a84735e..cbe65507ba 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/regionsReducers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/regionsReducers.ts @@ -45,6 +45,7 @@ export const regionsReducers = { const { id } = action.payload; const rg: CanvasRegionalGuidanceState = { id, + name: null, type: 'regional_guidance', isEnabled: true, objects: [], diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 9d7bd6fa71..cc0fb37f35 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -647,6 +647,7 @@ export type ImageCache = z.infer; export const zCanvasRegionalGuidanceState = z.object({ id: zId, + name: z.string().nullable(), type: z.literal('regional_guidance'), isEnabled: z.boolean(), position: zCoordinate, @@ -730,6 +731,7 @@ export type T2IAdapterConfig = z.infer; export const zCanvasRasterLayerState = z.object({ id: zId, + name: z.string().nullable(), type: z.literal('raster_layer'), isEnabled: z.boolean(), position: zCoordinate,