mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
feat(ui): layer opacity via caching
This commit is contained in:
@@ -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 (
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
leftIcon={<PiPlusBold />}
|
||||
variant="ghost"
|
||||
as={IconButton}
|
||||
aria-label={t('controlLayers.addLayer')}
|
||||
icon={<PiPlusBold />}
|
||||
variant="link"
|
||||
data-testid="control-layers-add-layer-menu-button"
|
||||
>
|
||||
{t('controlLayers.addLayer')}
|
||||
</MenuButton>
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem onClick={addRGLayer}>{t('controlLayers.regionalGuidanceLayer')}</MenuItem>
|
||||
<MenuItem onClick={addRasterLayer}>{t('controlLayers.rasterLayer')}</MenuItem>
|
||||
|
||||
@@ -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 (
|
||||
<ScrollableContent>
|
||||
<Flex flexDir="column" gap={2} data-testid="control-layers-layer-list">
|
||||
<Flex flexDir="column" gap={4} pt={2} data-testid="control-layers-layer-list">
|
||||
<CanvasEntityOpacity />
|
||||
<InpaintMask />
|
||||
<RegionalGuidanceEntityList />
|
||||
<IPAdapterList />
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<CanvasEntityGroupTitle
|
||||
title={t('controlLayers.controlLayers_withCount', { count: layerIds.length })}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
<CanvasEntityGroupList
|
||||
type="control_layer"
|
||||
title={t('controlLayers.controlLayers_withCount', { count: layerIds.length })}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
{layerIds.map((id) => (
|
||||
<ControlLayer key={id} id={id} />
|
||||
))}
|
||||
</>
|
||||
</CanvasEntityGroupList>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<PanelGroup direction="vertical">
|
||||
<Panel id="canvas-entity-list-panel" order={0}>
|
||||
<Flex flexDir="column" gap={2} w="full" h="full">
|
||||
<Flex justifyContent="space-around">
|
||||
<AddLayerButton />
|
||||
<ResetAllEntitiesButton />
|
||||
</Flex>
|
||||
<CanvasEntityList />
|
||||
</Flex>
|
||||
<CanvasEntityList />
|
||||
</Panel>
|
||||
{Boolean(filteringEntity) && (
|
||||
<>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<CanvasEntityGroupTitle
|
||||
title={t('controlLayers.ipAdapters_withCount', { count: ipaIds.length })}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
<CanvasEntityGroupList
|
||||
type="ip_adapter"
|
||||
title={t('controlLayers.ipAdapters_withCount', { count: ipaIds.length })}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
{ipaIds.map((id) => (
|
||||
<IPAdapter key={id} id={id} />
|
||||
))}
|
||||
</>
|
||||
</CanvasEntityGroupList>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<CanvasEntityGroupTitle title={t('controlLayers.inpaintMask')} isSelected={isSelected} />
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<CanvasEntityGroupList title={t('controlLayers.inpaintMask')} isSelected={isSelected} type="inpaint_mask" />
|
||||
<EntityIdentifierContext.Provider value={entityIdentifier}>
|
||||
<CanvasEntityContainer>
|
||||
<CanvasEntityHeader>
|
||||
@@ -30,7 +30,7 @@ export const InpaintMask = memo(() => {
|
||||
</CanvasEntityHeader>
|
||||
</CanvasEntityContainer>
|
||||
</EntityIdentifierContext.Provider>
|
||||
</>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<CanvasEntityGroupTitle
|
||||
title={t('controlLayers.rasterLayers_withCount', { count: layerIds.length })}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
<CanvasEntityGroupList
|
||||
type="raster_layer"
|
||||
title={t('controlLayers.rasterLayers_withCount', { count: layerIds.length })}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
{layerIds.map((id) => (
|
||||
<RasterLayer key={id} id={id} />
|
||||
))}
|
||||
</>
|
||||
</CanvasEntityGroupList>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<CanvasEntityGroupTitle
|
||||
title={t('controlLayers.regionalGuidance_withCount', { count: rgIds.length })}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
<CanvasEntityGroupList
|
||||
type="regional_guidance"
|
||||
title={t('controlLayers.regionalGuidance_withCount', { count: rgIds.length })}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
{rgIds.map((id) => (
|
||||
<RegionalGuidance key={id} id={id} />
|
||||
))}
|
||||
</>
|
||||
</CanvasEntityGroupList>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<Flex justifyContent="space-between" alignItems="center">
|
||||
<Text color={isSelected ? 'base.200' : 'base.500'} fontWeight="semibold" userSelect="none">
|
||||
{title}
|
||||
</Text>
|
||||
<Switch size="sm" isChecked={areAllEnabled} onChange={onChange} pe={1} />
|
||||
</Flex>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasEntityGroupList.displayName = 'CanvasEntityGroupList';
|
||||
@@ -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 (
|
||||
<Text color={isSelected ? 'base.200' : 'base.500'} fontWeight="semibold" userSelect="none">
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasEntityGroupTitle.displayName = 'CanvasEntityGroupTitle';
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
onBlur();
|
||||
}
|
||||
},
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalOpacity((opacity ?? 1) * 100);
|
||||
}, [opacity]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<FormControl w="min-content" gap={2}>
|
||||
<FormLabel m={0}>{t('controlLayers.opacity')}</FormLabel>
|
||||
<PopoverAnchor>
|
||||
<NumberInput
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={localOpacity}
|
||||
onChange={onChangeNumberInput}
|
||||
onBlur={onBlur}
|
||||
w="76px"
|
||||
format={formatPct}
|
||||
defaultValue={1}
|
||||
onKeyDown={onKeyDown}
|
||||
clampValueOnBlur={false}
|
||||
>
|
||||
<NumberInputField paddingInlineEnd={7} />
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
aria-label="open-slider"
|
||||
icon={<PiCaretDownBold />}
|
||||
size="sm"
|
||||
variant="link"
|
||||
position="absolute"
|
||||
insetInlineEnd={0}
|
||||
h="full"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
</NumberInput>
|
||||
</PopoverAnchor>
|
||||
</FormControl>
|
||||
<PopoverContent w={200} pt={0} pb={2} px={4}>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<CompositeSlider
|
||||
min={0}
|
||||
max={100}
|
||||
value={localOpacity}
|
||||
onChange={onChangeSlider}
|
||||
defaultValue={sliderDefaultValue}
|
||||
marks={marks}
|
||||
formatValue={formatSliderValue}
|
||||
alwaysShowMarks
|
||||
/>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasEntityOpacity.displayName = 'CanvasEntityOpacity';
|
||||
Reference in New Issue
Block a user