diff --git a/invokeai/frontend/web/src/features/controlLayers/components/AddLayerButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/AddLayerButton.tsx index b33f5304fa..335a6d126d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/AddLayerButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/AddLayerButton.tsx @@ -1,4 +1,4 @@ -import { Button, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { useDefaultControlAdapter, useDefaultIPAdapter } from 'features/controlLayers/hooks/useLayerControlAdapter'; import { controlLayerAdded, ipaAdded, rasterLayerAdded, rgAdded } from 'features/controlLayers/store/canvasV2Slice'; @@ -27,13 +27,12 @@ export const AddLayerButton = memo(() => { return ( } - variant="ghost" + as={IconButton} + aria-label={t('controlLayers.addLayer')} + icon={} + variant="link" data-testid="control-layers-add-layer-menu-button" - > - {t('controlLayers.addLayer')} - + /> {t('controlLayers.regionalGuidanceLayer')} {t('controlLayers.rasterLayer')} diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList.tsx index 86d76a4578..ac5878b283 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList.tsx @@ -1,5 +1,6 @@ import { Flex } from '@invoke-ai/ui-library'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; +import { CanvasEntityOpacity } from 'features/controlLayers/components/common/CanvasEntityOpacity'; import { ControlLayerEntityList } from 'features/controlLayers/components/ControlLayer/ControlLayerEntityList'; import { InpaintMask } from 'features/controlLayers/components/InpaintMask/InpaintMask'; import { IPAdapterList } from 'features/controlLayers/components/IPAdapter/IPAdapterList'; @@ -10,7 +11,8 @@ import { memo } from 'react'; export const CanvasEntityList = memo(() => { return ( - + + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx index 640c7dbf38..848d4598fc 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx @@ -1,6 +1,6 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; -import { CanvasEntityGroupTitle } from 'features/controlLayers/components/common/CanvasEntityGroupTitle'; +import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList'; import { ControlLayer } from 'features/controlLayers/components/ControlLayer/ControlLayer'; import { mapId } from 'features/controlLayers/konva/util'; import { selectCanvasV2Slice } from 'features/controlLayers/store/canvasV2Slice'; @@ -22,15 +22,15 @@ export const ControlLayerEntityList = memo(() => { if (layerIds.length > 0) { return ( - <> - + {layerIds.map((id) => ( ))} - + ); } }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx index 195d774707..e408d38bf9 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersPanelContent.tsx @@ -1,10 +1,7 @@ /* eslint-disable i18next/no-literal-string */ -import { Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { AddLayerButton } from 'features/controlLayers/components/AddLayerButton'; import { CanvasEntityList } from 'features/controlLayers/components/CanvasEntityList'; import { Filter } from 'features/controlLayers/components/Filters/Filter'; -import { ResetAllEntitiesButton } from 'features/controlLayers/components/ResetAllEntitiesButton'; import { $filteringEntity } from 'features/controlLayers/store/canvasV2Slice'; import ResizeHandle from 'features/ui/components/tabs/ResizeHandle'; import { memo } from 'react'; @@ -15,13 +12,7 @@ export const ControlLayersPanelContent = memo(() => { return ( - - - - - - - + {Boolean(filteringEntity) && ( <> diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx index 348f525ae5..fca5420ceb 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx @@ -1,7 +1,7 @@ /* eslint-disable i18next/no-literal-string */ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; -import { CanvasEntityGroupTitle } from 'features/controlLayers/components/common/CanvasEntityGroupTitle'; +import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList'; import { IPAdapter } from 'features/controlLayers/components/IPAdapter/IPAdapter'; import { mapId } from 'features/controlLayers/konva/util'; import { selectCanvasV2Slice } from 'features/controlLayers/store/canvasV2Slice'; @@ -23,15 +23,15 @@ export const IPAdapterList = memo(() => { if (ipaIds.length > 0) { return ( - <> - + {ipaIds.map((id) => ( ))} - + ); } }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMask.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMask.tsx index ba3c3dee82..186b2fad3f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMask.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMask.tsx @@ -1,8 +1,8 @@ -import { Spacer } from '@invoke-ai/ui-library'; +import { Flex, Spacer } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer'; import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle'; -import { CanvasEntityGroupTitle } from 'features/controlLayers/components/common/CanvasEntityGroupTitle'; +import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList'; import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; import { CanvasEntityTitle } from 'features/controlLayers/components/common/CanvasEntityTitle'; import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; @@ -18,8 +18,8 @@ export const InpaintMask = memo(() => { const isSelected = useAppSelector((s) => Boolean(s.canvasV2.selectedEntityIdentifier?.type === 'inpaint_mask')); return ( - <> - + + @@ -30,7 +30,7 @@ export const InpaintMask = memo(() => { - + ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx index 1c2c7448f1..c7dc342067 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx @@ -1,6 +1,6 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; -import { CanvasEntityGroupTitle } from 'features/controlLayers/components/common/CanvasEntityGroupTitle'; +import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList'; import { RasterLayer } from 'features/controlLayers/components/RasterLayer/RasterLayer'; import { mapId } from 'features/controlLayers/konva/util'; import { selectCanvasV2Slice } from 'features/controlLayers/store/canvasV2Slice'; @@ -22,15 +22,15 @@ export const RasterLayerEntityList = memo(() => { if (layerIds.length > 0) { return ( - <> - + {layerIds.map((id) => ( ))} - + ); } }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx index 6a864d99cb..b03a1df635 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx @@ -1,6 +1,6 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; -import { CanvasEntityGroupTitle } from 'features/controlLayers/components/common/CanvasEntityGroupTitle'; +import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList'; import { RegionalGuidance } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidance'; import { mapId } from 'features/controlLayers/konva/util'; import { selectCanvasV2Slice } from 'features/controlLayers/store/canvasV2Slice'; @@ -22,15 +22,15 @@ export const RegionalGuidanceEntityList = memo(() => { if (rgIds.length > 0) { return ( - <> - + {rgIds.map((id) => ( ))} - + ); } }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx new file mode 100644 index 0000000000..5bc840f9f5 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx @@ -0,0 +1,45 @@ +import { Flex, Switch, Text } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { + allEntitiesOfTypeToggled, + selectAllEntitiesOfType, + selectCanvasV2Slice, +} from 'features/controlLayers/store/canvasV2Slice'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import type { PropsWithChildren } from 'react'; +import { memo, useCallback, useMemo } from 'react'; + +type Props = PropsWithChildren<{ + title: string; + isSelected: boolean; + type: CanvasEntityIdentifier['type']; +}>; + +export const CanvasEntityGroupList = memo(({ title, isSelected, type, children }: Props) => { + const dispatch = useAppDispatch(); + const selectAreAllEnabled = useMemo( + () => + createSelector(selectCanvasV2Slice, (canvasV2) => { + return selectAllEntitiesOfType(canvasV2, type).every((entity) => entity.isEnabled); + }), + [type] + ); + const areAllEnabled = useAppSelector(selectAreAllEnabled); + const onChange = useCallback(() => { + dispatch(allEntitiesOfTypeToggled({ type })); + }, [dispatch, type]); + return ( + + + + {title} + + + + {children} + + ); +}); + +CanvasEntityGroupList.displayName = 'CanvasEntityGroupList'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupTitle.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupTitle.tsx deleted file mode 100644 index 9c88b28ed5..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupTitle.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Text } from '@invoke-ai/ui-library'; -import { memo } from 'react'; - -type Props = { - title: string; - isSelected: boolean; -}; - -export const CanvasEntityGroupTitle = memo(({ title, isSelected }: Props) => { - return ( - - {title} - - ); -}); - -CanvasEntityGroupTitle.displayName = 'CanvasEntityGroupTitle'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityOpacity.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityOpacity.tsx new file mode 100644 index 0000000000..c5be221c6d --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityOpacity.tsx @@ -0,0 +1,180 @@ +import { + $shift, + CompositeSlider, + FormControl, + FormLabel, + IconButton, + NumberInput, + NumberInputField, + Popover, + PopoverAnchor, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverTrigger, +} from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { snapToNearest } from 'features/controlLayers/konva/util'; +import { entityOpacityChanged, selectEntity } from 'features/controlLayers/store/canvasV2Slice'; +import { isDrawableEntity } from 'features/controlLayers/store/types'; +import { clamp, round } from 'lodash-es'; +import type { KeyboardEvent } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCaretDownBold } from 'react-icons/pi'; + +function formatPct(v: number | string) { + if (isNaN(Number(v))) { + return ''; + } + + return `${round(Number(v), 2).toLocaleString()}%`; +} + +function mapSliderValueToOpacity(value: number) { + return value / 100; +} + +function mapOpacityToSliderValue(opacity: number) { + return opacity * 100; +} + +function formatSliderValue(value: number) { + return String(value); +} + +const marks = [ + mapOpacityToSliderValue(0), + mapOpacityToSliderValue(0.25), + mapOpacityToSliderValue(0.5), + mapOpacityToSliderValue(0.75), + mapOpacityToSliderValue(1), +]; + +const sliderDefaultValue = mapOpacityToSliderValue(100); + +const snapCandidates = marks.slice(1, marks.length - 1); + +export const CanvasEntityOpacity = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const selectedEntityIdentifier = useAppSelector((s) => s.canvasV2.selectedEntityIdentifier); + const opacity = useAppSelector((s) => { + const selectedEntityIdentifier = s.canvasV2.selectedEntityIdentifier; + if (!selectedEntityIdentifier) { + return null; + } + const selectedEntity = selectEntity(s.canvasV2, selectedEntityIdentifier); + if (!selectedEntity) { + return null; + } + if (!isDrawableEntity(selectedEntity)) { + return null; + } + return selectedEntity.opacity; + }); + + const [localOpacity, setLocalOpacity] = useState((opacity ?? 1) * 100); + + const onChangeSlider = useCallback( + (opacity: number) => { + if (!selectedEntityIdentifier) { + return; + } + let snappedOpacity = opacity; + // Do not snap if shift key is held + if (!$shift.get()) { + snappedOpacity = snapToNearest(opacity, snapCandidates, 2); + } + const mappedOpacity = mapSliderValueToOpacity(snappedOpacity); + + dispatch(entityOpacityChanged({ entityIdentifier: selectedEntityIdentifier, opacity: mappedOpacity })); + }, + [dispatch, selectedEntityIdentifier] + ); + + const onBlur = useCallback(() => { + if (!selectedEntityIdentifier) { + return; + } + if (isNaN(Number(localOpacity))) { + setLocalOpacity(100); + return; + } + dispatch( + entityOpacityChanged({ entityIdentifier: selectedEntityIdentifier, opacity: clamp(localOpacity / 100, 0, 1) }) + ); + }, [dispatch, localOpacity, selectedEntityIdentifier]); + + const onChangeNumberInput = useCallback((valueAsString: string, valueAsNumber: number) => { + setLocalOpacity(valueAsNumber); + }, []); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + onBlur(); + } + }, + [onBlur] + ); + + useEffect(() => { + setLocalOpacity((opacity ?? 1) * 100); + }, [opacity]); + + return ( + + + {t('controlLayers.opacity')} + + + + + } + size="sm" + variant="link" + position="absolute" + insetInlineEnd={0} + h="full" + /> + + + + + + + + + + + + ); +}); + +CanvasEntityOpacity.displayName = 'CanvasEntityOpacity'; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity.ts deleted file mode 100644 index 626704d2be..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { JSONObject } from 'common/types'; -import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; -import type { Logger } from 'roarr'; - -export abstract class CanvasEntity { - id: string; - manager: CanvasManager; - log: Logger; - - constructor(id: string, manager: CanvasManager) { - this.id = id; - this.manager = manager; - this.log = this.manager.buildLogger(this.getLoggingContext); - } - /** - * Get a serializable representation of the entity. - */ - abstract repr(): JSONObject; - - getLoggingContext = (extra?: Record) => { - return { - ...this.manager.getLoggingContext(), - layerId: this.id, - ...extra, - }; - }; -} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayerAdapter.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayerAdapter.ts index 82b2317d9e..5d09fbbaa4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayerAdapter.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayerAdapter.ts @@ -4,11 +4,12 @@ import { CanvasFilter } from 'features/controlLayers/konva/CanvasFilter'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasObjectRenderer } from 'features/controlLayers/konva/CanvasObjectRenderer'; import { CanvasTransformer } from 'features/controlLayers/konva/CanvasTransformer'; -import type { - CanvasControlLayerState, - CanvasEntityIdentifier, - CanvasRasterLayerState, - CanvasV2State, +import { + type CanvasControlLayerState, + type CanvasEntityIdentifier, + type CanvasRasterLayerState, + type CanvasV2State, + getEntityIdentifier, } from 'features/controlLayers/store/types'; import Konva from 'konva'; import { get } from 'lodash-es'; @@ -61,7 +62,7 @@ export class CanvasLayerAdapter { * Get this entity's entity identifier */ getEntityIdentifier = (): CanvasEntityIdentifier => { - return { id: this.id, type: this.state.type }; + return getEntityIdentifier(this.state); }; destroy = (): void => { @@ -97,7 +98,7 @@ export class CanvasLayerAdapter { this.transformer.updatePosition({ position }); } if (this.isFirstRender || opacity !== this.state.opacity) { - this.updateOpacity({ opacity }); + this.renderer.updateOpacity(opacity); } // this.transformer.syncInteractionState(); @@ -113,6 +114,7 @@ export class CanvasLayerAdapter { this.log.trace('Updating visibility'); const isEnabled = get(arg, 'isEnabled', this.state.isEnabled); this.konva.layer.visible(isEnabled); + this.renderer.syncCache(isEnabled); }; updateObjects = async (arg?: { objects: CanvasRasterLayerState['objects'] }) => { diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts index c52624217c..60d80d956a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts @@ -316,10 +316,10 @@ export class CanvasManager { if (this._isFirstRender || state.rasterLayers.entities !== this._prevState.rasterLayers.entities) { this.log.debug('Rendering raster layers'); - for (const canvasLayer of this.rasterLayerAdapters.values()) { - if (!state.rasterLayers.entities.find((l) => l.id === canvasLayer.id)) { - await canvasLayer.destroy(); - this.rasterLayerAdapters.delete(canvasLayer.id); + for (const entityAdapter of this.rasterLayerAdapters.values()) { + if (!state.rasterLayers.entities.find((l) => l.id === entityAdapter.id)) { + await entityAdapter.destroy(); + this.rasterLayerAdapters.delete(entityAdapter.id); } } @@ -341,10 +341,10 @@ export class CanvasManager { if (this._isFirstRender || state.controlLayers.entities !== this._prevState.controlLayers.entities) { this.log.debug('Rendering control layers'); - for (const canvasLayer of this.controlLayerAdapters.values()) { - if (!state.controlLayers.entities.find((l) => l.id === canvasLayer.id)) { - await canvasLayer.destroy(); - this.controlLayerAdapters.delete(canvasLayer.id); + for (const entityAdapter of this.controlLayerAdapters.values()) { + if (!state.controlLayers.entities.find((l) => l.id === entityAdapter.id)) { + await entityAdapter.destroy(); + this.controlLayerAdapters.delete(entityAdapter.id); } } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasMaskAdapter.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasMaskAdapter.ts index b472b9717b..f028058066 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasMaskAdapter.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasMaskAdapter.ts @@ -3,11 +3,12 @@ import { deepClone } from 'common/util/deepClone'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasObjectRenderer } from 'features/controlLayers/konva/CanvasObjectRenderer'; import { CanvasTransformer } from 'features/controlLayers/konva/CanvasTransformer'; -import type { - CanvasEntityIdentifier, - CanvasInpaintMaskState, - CanvasRegionalGuidanceState, - CanvasV2State, +import { + type CanvasEntityIdentifier, + type CanvasInpaintMaskState, + type CanvasRegionalGuidanceState, + type CanvasV2State, + getEntityIdentifier, } from 'features/controlLayers/store/types'; import Konva from 'konva'; import { get } from 'lodash-es'; @@ -59,7 +60,7 @@ export class CanvasMaskAdapter { * Get this entity's entity identifier */ getEntityIdentifier = (): CanvasEntityIdentifier => { - return { id: this.id, type: this.state.type }; + return getEntityIdentifier(this.state) }; destroy = (): void => { @@ -99,7 +100,11 @@ export class CanvasMaskAdapter { } if (this.isFirstRender || state.fill !== this.state.fill) { - this.renderer.updateCompositingRect(state.fill); + this.renderer.updateCompositingRectFill(state.fill); + } + + if (this.isFirstRender) { + this.renderer.updateCompositingRectSize(); } // this.transformer.syncInteractionState(); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectRenderer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectRenderer.ts index 37ce33cca9..4ee639315c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectRenderer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectRenderer.ts @@ -1,5 +1,5 @@ import type { JSONObject } from 'common/types'; -import { rgbaColorToString } from 'common/util/colorCodeTransformers'; +import { rgbColorToString } from 'common/util/colorCodeTransformers'; import { deepClone } from 'common/util/deepClone'; import { CanvasBrushLineRenderer } from 'features/controlLayers/konva/CanvasBrushLine'; import { CanvasEraserLineRenderer } from 'features/controlLayers/konva/CanvasEraserLine'; @@ -21,7 +21,6 @@ import type { } from 'features/controlLayers/store/types'; import { imageDTOToImageObject } from 'features/controlLayers/store/types'; import Konva from 'konva'; -import type { RectConfig } from 'konva/lib/shapes/Rect'; import { isEqual } from 'lodash-es'; import type { Logger } from 'roarr'; import { getImageDTO, uploadImage } from 'services/api/endpoints/images'; @@ -149,7 +148,8 @@ export class CanvasObjectRenderer { this.subscriptions.add( this.manager.stateApi.$stageAttrs.listen(() => { if (this.konva.compositing && this.parent.type === 'mask_adapter') { - this.updateCompositingRect(this.parent.state.fill); + this.updateCompositingRectFill(this.parent.state.fill); + this.updateCompositingRectSize(); } }) ); @@ -162,6 +162,7 @@ export class CanvasObjectRenderer { */ render = async (objectStates: AnyObjectState[]): Promise => { let didRender = false; + const objectIds = objectStates.map((objectState) => objectState.id); for (const renderer of this.renderers.values()) { @@ -180,32 +181,61 @@ export class CanvasObjectRenderer { didRender = (await this.renderObject(this.buffer)) || didRender; } + this.syncCache(didRender); + return didRender; }; - updateCompositingRect = (fill: Fill) => { - this.log.trace('Updating compositing rect'); + syncCache = (force: boolean = false) => { + if (this.renderers.size === 0) { + this.log.trace('Clearing object group cache'); + this.konva.objectGroup.clearCache(); + } else if (force || !this.konva.objectGroup.isCached()) { + this.log.trace('Caching object group'); + this.konva.objectGroup.clearCache(); + this.konva.objectGroup.cache(); + } + }; + + updateCompositingRectFill = (fill: Fill) => { + this.log.trace('Updating compositing rect fill'); + assert(this.konva.compositing, 'Missing compositing rect'); + + if (fill.style === 'solid') { + this.konva.compositing.rect.setAttrs({ + fill: rgbColorToString(fill.color), + fillPriority: 'color', + }); + } else { + this.konva.compositing.rect.setAttrs({ + fillPriority: 'pattern', + }); + setFillPatternImage(this.konva.compositing.rect, fill.style, fill.color); + } + }; + + updateCompositingRectSize = () => { + this.log.trace('Updating compositing rect size'); assert(this.konva.compositing, 'Missing compositing rect'); const { x, y, width, height, scale } = this.manager.stateApi.$stageAttrs.get(); - const attrs: RectConfig = { + this.konva.compositing.rect.setAttrs({ x: -x / scale, y: -y / scale, width: width / scale, height: height / scale, - }; + fillPatternScaleX: 1 / scale, + fillPatternScaleY: 1 / scale, + }); + }; - if (fill.style === 'solid') { - attrs.fill = rgbaColorToString(fill.color); - attrs.fillPriority = 'color'; - this.konva.compositing.rect.setAttrs(attrs); + updateOpacity = (opacity: number) => { + this.log.trace('Updating opacity'); + if (this.konva.compositing) { + this.konva.compositing.group.opacity(opacity); } else { - attrs.fillPatternScaleX = 1 / scale; - attrs.fillPatternScaleY = 1 / scale; - attrs.fillPriority = 'pattern'; - this.konva.compositing.rect.setAttrs(attrs); - setFillPatternImage(this.konva.compositing.rect, fill.style, fill.color); + this.konva.objectGroup.opacity(opacity); } }; @@ -266,6 +296,10 @@ export class CanvasObjectRenderer { didRender = await renderer.update(objectState, force || isFirstRender); } + if (didRender && this.konva.objectGroup.isCached()) { + this.konva.objectGroup.clearCache(); + } + return didRender; }; @@ -421,7 +455,7 @@ export class CanvasObjectRenderer { imageDTO = await uploadImage(blob, `${this.id}_rasterized.png`, 'other', true); const imageObject = imageDTOToImageObject(imageDTO); if (replaceObjects) { - await this.renderObject(imageObject, true); + await this.renderObject(imageObject, true); } this.manager.stateApi.rasterizeEntity({ entityIdentifier: this.parent.getEntityIdentifier(), diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/patterns/getPatternSVG.ts b/invokeai/frontend/web/src/features/controlLayers/konva/patterns/getPatternSVG.ts index aaf4a2c577..2836b13979 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/patterns/getPatternSVG.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/patterns/getPatternSVG.ts @@ -1,5 +1,5 @@ -import { rgbaColorToString } from 'common/util/colorCodeTransformers'; -import type { FillStyle, RgbaColor } from 'features/controlLayers/store/types'; +import { rgbColorToString } from 'common/util/colorCodeTransformers'; +import type { FillStyle, RgbColor } from 'features/controlLayers/store/types'; import crosshatch from './pattern-crosshatch.svg?raw'; import diagonal from './pattern-diagonal.svg?raw'; @@ -7,7 +7,7 @@ import grid from './pattern-grid.svg?raw'; import horizontal from './pattern-horizontal.svg?raw'; import vertical from './pattern-vertical.svg?raw'; -export function getPatternSVG(pattern: Exclude, color: RgbaColor) { +export function getPatternSVG(pattern: Exclude, color: RgbColor) { let content: string = 'data:image/svg+xml;utf8,'; if (pattern === 'crosshatch') { content += crosshatch; @@ -21,7 +21,7 @@ export function getPatternSVG(pattern: Exclude, color: RgbaC content += grid; } - content = content.replaceAll('stroke:black', `stroke:${rgbaColorToString(color)}`); + content = content.replaceAll('stroke:black', `stroke:${rgbColorToString(color)}`); return content; } diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts index d0ae06c28a..2c19024d68 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts @@ -25,6 +25,7 @@ import { assert } from 'tsafe'; import type { CanvasControlLayerState, CanvasEntityIdentifier, + CanvasEntityState, CanvasInpaintMaskState, CanvasRasterLayerState, CanvasRegionalGuidanceState, @@ -163,6 +164,22 @@ export function selectEntity(state: CanvasV2State, { id, type }: CanvasEntityIde } } +export function selectAllEntitiesOfType(state: CanvasV2State, type: CanvasEntityState['type']): CanvasEntityState[] { + if (type === 'raster_layer') { + return state.rasterLayers.entities; + } else if (type === 'control_layer') { + return state.controlLayers.entities; + } else if (type === 'inpaint_mask') { + return [state.inpaintMask]; + } else if (type === 'regional_guidance') { + return state.regions.entities; + } else if (type === 'ip_adapter') { + return state.ipAdapters.entities; + } else { + assert(false, 'Not implemented'); + } +} + const invalidateRasterizationCaches = ( entity: CanvasRasterLayerState | CanvasControlLayerState | CanvasInpaintMaskState | CanvasRegionalGuidanceState, state: CanvasV2State @@ -410,6 +427,48 @@ export const canvasV2Slice = createSlice({ moveToStart(state.regions.entities, entity); } }, + entityOpacityChanged: (state, action: PayloadAction>) => { + const { entityIdentifier, opacity } = action.payload; + const entity = selectEntity(state, entityIdentifier); + if (!entity) { + return; + } + if (entity.type === 'ip_adapter') { + return; + } + entity.opacity = opacity; + }, + allEntitiesOfTypeToggled: (state, action: PayloadAction<{ type: CanvasEntityIdentifier['type'] }>) => { + const { type } = action.payload; + let entities: ( + | CanvasRasterLayerState + | CanvasControlLayerState + | CanvasInpaintMaskState + | CanvasRegionalGuidanceState + )[]; + + switch (type) { + case 'raster_layer': + entities = state.rasterLayers.entities; + break; + case 'control_layer': + entities = state.controlLayers.entities; + break; + case 'inpaint_mask': + entities = [state.inpaintMask]; + break; + case 'regional_guidance': + entities = state.regions.entities; + break; + default: + assert(false, 'Not implemented'); + } + + const allEnabled = entities.every((entity) => entity.isEnabled); + for (const entity of entities) { + entity.isEnabled = !allEnabled; + } + }, allEntitiesDeleted: (state) => { state.ipAdapters = deepClone(initialState.ipAdapters); state.rasterLayers = deepClone(initialState.rasterLayers); @@ -490,6 +549,8 @@ export const { entityArrangedToFront, entityArrangedBackwardOne, entityArrangedToBack, + entityOpacityChanged, + allEntitiesOfTypeToggled, // bbox bboxChanged, bboxScaledSizeChanged, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index ea010ff06b..5adba63711 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -644,7 +644,7 @@ const zMaskObject = z const zFillStyle = z.enum(['solid', 'grid', 'crosshatch', 'diagonal', 'horizontal', 'vertical']); export type FillStyle = z.infer; export const isFillStyle = (v: unknown): v is FillStyle => zFillStyle.safeParse(v).success; -const zFill = z.object({ style: zFillStyle, color: zRgbaColor }); +const zFill = z.object({ style: zFillStyle, color: zRgbColor }); export type Fill = z.infer; const zImageCache = z.object({ @@ -670,6 +670,7 @@ export const zCanvasRegionalGuidanceState = z.object({ type: z.literal('regional_guidance'), isEnabled: z.boolean(), position: zCoordinate, + opacity: zOpacity, objects: z.array(zCanvasObjectState), fill: zFill, positivePrompt: zParameterPositivePrompt.nullable(), @@ -686,6 +687,7 @@ const zCanvasInpaintMaskState = z.object({ isEnabled: z.boolean(), position: zCoordinate, fill: zFill, + opacity: zOpacity, objects: z.array(zCanvasObjectState), rasterizationCache: z.array(zImageCache), }); @@ -946,20 +948,16 @@ export type PositionChangedArg = { id: string; position: Coordinate }; export type ScaleChangedArg = { id: string; scale: Coordinate; position: Coordinate }; export type BboxChangedArg = { id: string; bbox: Rect | null }; -export type EntityIdentifierPayload = { entityIdentifier: CanvasEntityIdentifier }; -export type EntityMovedPayload = { entityIdentifier: CanvasEntityIdentifier; position: Coordinate }; -export type EntityBrushLineAddedPayload = { entityIdentifier: CanvasEntityIdentifier; brushLine: CanvasBrushLineState }; -export type EntityEraserLineAddedPayload = { - entityIdentifier: CanvasEntityIdentifier; - eraserLine: CanvasEraserLineState; -}; -export type EntityRectAddedPayload = { entityIdentifier: CanvasEntityIdentifier; rect: CanvasRectState }; -export type EntityRasterizedPayload = { - entityIdentifier: CanvasEntityIdentifier; +export type EntityIdentifierPayload = { entityIdentifier: CanvasEntityIdentifier } & T; +export type EntityMovedPayload = EntityIdentifierPayload<{ position: Coordinate }>; +export type EntityBrushLineAddedPayload = EntityIdentifierPayload<{ brushLine: CanvasBrushLineState }>; +export type EntityEraserLineAddedPayload = EntityIdentifierPayload<{ eraserLine: CanvasEraserLineState }>; +export type EntityRectAddedPayload = EntityIdentifierPayload<{ rect: CanvasRectState }>; +export type EntityRasterizedPayload = EntityIdentifierPayload<{ imageObject: CanvasImageState; rect: Rect; replaceObjects: boolean; -}; +}>; export type ImageObjectAddedArg = { id: string; imageDTO: ImageDTO; position?: Coordinate }; //#region Type guards @@ -1000,3 +998,7 @@ export function isDrawableEntityAdapter( ): adapter is CanvasLayerAdapter | CanvasMaskAdapter { return adapter instanceof CanvasLayerAdapter || adapter instanceof CanvasMaskAdapter; } + +export const getEntityIdentifier = (entity: CanvasEntityState): CanvasEntityIdentifier => { + return { id: entity.id, type: entity.type }; +}; diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelTextToImage.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelTextToImage.tsx index 57f81cd722..9b73c16368 100644 --- a/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelTextToImage.tsx +++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelTextToImage.tsx @@ -1,8 +1,9 @@ import type { ChakraProps } from '@invoke-ai/ui-library'; -import { Box, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; +import { Box, Flex, Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants'; +import { AddLayerButton } from 'features/controlLayers/components/AddLayerButton'; import { ControlLayersPanelContent } from 'features/controlLayers/components/ControlLayersPanelContent'; import { $isPreviewVisible } from 'features/controlLayers/store/canvasV2Slice'; import { selectEntityCount } from 'features/controlLayers/store/selectors'; @@ -89,7 +90,7 @@ const ParametersPanelTextToImage = () => { gap={2} onChange={onChangeTabs} > - + {t('common.settingsLabel')} @@ -100,6 +101,8 @@ const ParametersPanelTextToImage = () => { > {controlLayersTitle} + +