feat(ui): layer opacity via caching

This commit is contained in:
psychedelicious
2024-08-19 22:51:40 +10:00
parent 37dc7ee595
commit d6fec0a0df
20 changed files with 432 additions and 152 deletions

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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>
);
}
});

View File

@@ -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) && (
<>

View File

@@ -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>
);
}
});

View File

@@ -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>
);
});

View File

@@ -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>
);
}
});

View File

@@ -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>
);
}
});

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';