UI updates per PR feedback

This commit is contained in:
Mary Hipp 2024-08-09 16:00:13 -04:00
parent 8eb5d08499
commit 12ba15bfa9
27 changed files with 340 additions and 309 deletions

View File

@ -1705,7 +1705,7 @@
"name": "Name",
"negativePrompt": "Negative Prompt",
"noMatchingTemplates": "No matching templates",
"placeholderDirections": "Use the { } button to specify where your manual prompt should be included in the template. If you do not provide one, the template will be appended to your prompt.",
"placeholderDirections": "Use the button to specify where your manual prompt should be included in the template. If you do not provide one, the template will be appended to your prompt.",
"positivePrompt": "Positive Prompt",
"searchByName": "Search by name",
"templateDeleted": "Prompt template deleted",
@ -1714,6 +1714,7 @@
"updatePromptTemplate": "Update Prompt Template",
"uploadImage": "Upload Image",
"useForTemplate": "Use For Prompt Template",
"viewList": "View Template List",
"viewModeTooltip": "This is how your prompt will look with your currently selected template. To edit your prompt, click anywhere in the text box."
},
"upsell": {

View File

@ -12,7 +12,7 @@ import {
} from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt';
import { getPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils';
import { activeStylePresetChanged } from 'features/stylePresets/store/stylePresetSlice';
import { activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice';
import { utilitiesApi } from 'services/api/endpoints/utilities';
import { socketConnected } from 'services/events/actions';
@ -22,7 +22,7 @@ const matcher = isAnyOf(
maxPromptsChanged,
maxPromptsReset,
socketConnected,
activeStylePresetChanged
activeStylePresetIdChanged
);
export const addDynamicPromptsListener = (startAppListening: AppStartListening) => {

View File

@ -29,7 +29,7 @@ import { upscalePersistConfig, upscaleSlice } from 'features/parameters/store/up
import { queueSlice } from 'features/queue/store/queueSlice';
import { sdxlPersistConfig, sdxlSlice } from 'features/sdxl/store/sdxlSlice';
import { stylePresetModalSlice } from 'features/stylePresets/store/stylePresetModalSlice';
import { stylePresetSlice } from 'features/stylePresets/store/stylePresetSlice';
import { stylePresetPersistConfig, stylePresetSlice } from 'features/stylePresets/store/stylePresetSlice';
import { configSlice } from 'features/system/store/configSlice';
import { systemPersistConfig, systemSlice } from 'features/system/store/systemSlice';
import { uiPersistConfig, uiSlice } from 'features/ui/store/uiSlice';
@ -118,6 +118,7 @@ const persistConfigs: { [key in keyof typeof allReducers]?: PersistConfig } = {
[controlLayersPersistConfig.name]: controlLayersPersistConfig,
[workflowSettingsPersistConfig.name]: workflowSettingsPersistConfig,
[upscalePersistConfig.name]: upscalePersistConfig,
[stylePresetPersistConfig.name]: stylePresetPersistConfig,
};
const unserialize: UnserializeFunction = (data, key) => {
@ -168,8 +169,8 @@ export const createStore = (uniqueStoreKey?: string, persist = true) =>
reducer: rememberedRootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
immutableCheck: false,
serializableCheck: import.meta.env.MODE === 'development',
immutableCheck: import.meta.env.MODE === 'development',
})
.concat(api.middleware)
.concat(dynamicMiddlewares)

View File

@ -1,4 +1,4 @@
import { useImageUrlToBlob } from 'common/hooks/useImageUrlToBlob';
import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob';
import { copyBlobToClipboard } from 'features/system/util/copyBlobToClipboard';
import { toast } from 'features/toast/toast';
import { useCallback, useMemo } from 'react';
@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next';
export const useCopyImageToClipboard = () => {
const { t } = useTranslation();
const imageUrlToBlob = useImageUrlToBlob();
const isClipboardAPIAvailable = useMemo(() => {
return Boolean(navigator.clipboard) && Boolean(window.ClipboardItem);
@ -23,7 +22,7 @@ export const useCopyImageToClipboard = () => {
});
}
try {
const blob = await imageUrlToBlob(image_url);
const blob = await convertImageUrlToBlob(image_url);
if (!blob) {
throw new Error('Unable to create Blob');
@ -45,7 +44,7 @@ export const useCopyImageToClipboard = () => {
});
}
},
[imageUrlToBlob, isClipboardAPIAvailable, t]
[isClipboardAPIAvailable, t]
);
return { isClipboardAPIAvailable, copyImageToClipboard };

View File

@ -1,54 +0,0 @@
import { $authToken } from 'app/store/nanostores/authToken';
import { useCallback } from 'react';
/**
* Converts an image URL to a Blob by creating an <img /> element, drawing it to canvas
* and then converting the canvas to a Blob.
*
* @returns A function that takes a URL and returns a Promise that resolves with a Blob
*/
export const useImageUrlToBlob = () => {
const imageUrlToBlob = useCallback(
async (url: string, dimension?: number) =>
new Promise<Blob | null>((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
if (dimension) {
const aspectRatio = img.width / img.height;
if (img.width > img.height) {
width = dimension;
height = dimension / aspectRatio;
} else {
height = dimension;
width = dimension * aspectRatio;
}
}
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
return;
}
context.drawImage(img, 0, 0, width, height);
resolve(
new Promise<Blob | null>((resolve) => {
canvas.toBlob(function (blob) {
resolve(blob);
}, 'image/png');
})
);
};
img.crossOrigin = $authToken.get() ? 'use-credentials' : 'anonymous';
img.src = url;
}),
[]
);
return imageUrlToBlob;
};

View File

@ -0,0 +1,33 @@
import { $authToken } from 'app/store/nanostores/authToken';
/**
* Converts an image URL to a Blob by creating an <img /> element, drawing it to canvas
* and then converting the canvas to a Blob.
*
* @returns A function that takes a URL and returns a Promise that resolves with a Blob
*/
export const convertImageUrlToBlob = async (url: string) =>
new Promise<Blob | null>((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const context = canvas.getContext('2d');
if (!context) {
return;
}
context.drawImage(img, 0, 0);
resolve(
new Promise<Blob | null>((resolve) => {
canvas.toBlob(function (blob) {
resolve(blob);
}, 'image/png');
})
);
};
img.crossOrigin = $authToken.get() ? 'use-credentials' : 'anonymous';
img.src = url;
});

View File

@ -1,6 +1,5 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useImageUrlToBlob } from 'common/hooks/useImageUrlToBlob';
import { handlers, parseAndRecallAllMetadata, parseAndRecallPrompts } from 'features/metadata/util/handlers';
import { isModalOpenChanged, prefilledFormDataChanged } from 'features/stylePresets/store/stylePresetModalSlice';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
@ -14,7 +13,6 @@ export const useImageActions = (image_name?: string) => {
const [hasMetadata, setHasMetadata] = useState(false);
const [hasSeed, setHasSeed] = useState(false);
const [hasPrompts, setHasPrompts] = useState(false);
const imageUrlToBlob = useImageUrlToBlob();
const dispatch = useAppDispatch();
const { data: imageDTO } = useGetImageDTOQuery(image_name ?? skipToken);
@ -72,19 +70,18 @@ export const useImageActions = (image_name?: string) => {
if (image_name && metadata && imageDTO) {
const positivePrompt = await handlers.positivePrompt.parse(metadata);
const negativePrompt = await handlers.negativePrompt.parse(metadata);
const imageBlob = await imageUrlToBlob(imageDTO.image_url, 100);
dispatch(
prefilledFormDataChanged({
name: '',
positivePrompt,
negativePrompt,
image: imageBlob ? new File([imageBlob], 'stylePreset.png', { type: 'image/png' }) : null,
imageUrl: imageDTO.image_url,
})
);
dispatch(isModalOpenChanged(true));
}
}, [image_name, metadata, dispatch, imageDTO, imageUrlToBlob]);
}, [image_name, metadata, dispatch, imageDTO]);
return {
recallAll,

View File

@ -2,6 +2,7 @@ import type { RootState } from 'app/store/store';
import type { BoardField } from 'features/nodes/types/common';
import { buildPresetModifiedPrompt } from 'features/stylePresets/hooks/usePresetModifiedPrompts';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { stylePresetsApi } from 'services/api/endpoints/stylePresets';
/**
* Gets the board field, based on the autoAddBoardId setting.
@ -22,25 +23,31 @@ export const getPresetModifiedPrompts = (
): { positivePrompt: string; negativePrompt: string; positiveStylePrompt?: string; negativeStylePrompt?: string } => {
const { positivePrompt, negativePrompt, positivePrompt2, negativePrompt2, shouldConcatPrompts } =
state.controlLayers.present;
const { activeStylePreset } = state.stylePreset;
const { activeStylePresetId } = state.stylePreset;
if (activeStylePreset) {
const presetModifiedPositivePrompt = buildPresetModifiedPrompt(
activeStylePreset.preset_data.positive_prompt,
positivePrompt
);
if (activeStylePresetId) {
const { data } = stylePresetsApi.endpoints.listStylePresets.select()(state);
const presetModifiedNegativePrompt = buildPresetModifiedPrompt(
activeStylePreset.preset_data.negative_prompt,
negativePrompt
);
const activeStylePreset = data?.find((item) => item.id === activeStylePresetId);
return {
positivePrompt: presetModifiedPositivePrompt,
negativePrompt: presetModifiedNegativePrompt,
positiveStylePrompt: shouldConcatPrompts ? presetModifiedPositivePrompt : positivePrompt2,
negativeStylePrompt: shouldConcatPrompts ? presetModifiedNegativePrompt : negativePrompt2,
};
if (activeStylePreset) {
const presetModifiedPositivePrompt = buildPresetModifiedPrompt(
activeStylePreset.preset_data.positive_prompt,
positivePrompt
);
const presetModifiedNegativePrompt = buildPresetModifiedPrompt(
activeStylePreset.preset_data.negative_prompt,
negativePrompt
);
return {
positivePrompt: presetModifiedPositivePrompt,
negativePrompt: presetModifiedNegativePrompt,
positiveStylePrompt: shouldConcatPrompts ? presetModifiedPositivePrompt : positivePrompt2,
negativeStylePrompt: shouldConcatPrompts ? presetModifiedNegativePrompt : negativePrompt2,
};
}
}
return {

View File

@ -8,14 +8,24 @@ import { PromptPopover } from 'features/prompt/PromptPopover';
import { usePrompt } from 'features/prompt/usePrompt';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
const DEFAULT_HEIGHT = 20;
import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets';
export const ParamNegativePrompt = memo(() => {
const dispatch = useAppDispatch();
const prompt = useAppSelector((s) => s.controlLayers.present.negativePrompt);
const viewMode = useAppSelector((s) => s.stylePreset.viewMode);
const activeStylePreset = useAppSelector((s) => s.stylePreset.activeStylePreset);
const activeStylePresetId = useAppSelector((s) => s.stylePreset.activeStylePresetId);
const { activeStylePreset } = useListStylePresetsQuery(undefined, {
selectFromResult: ({ data }) => {
let activeStylePreset = null;
if (data) {
activeStylePreset = data.find((sp) => sp.id === activeStylePresetId);
}
return { activeStylePreset };
},
});
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { t } = useTranslation();
const _onChange = useCallback(
@ -24,32 +34,18 @@ export const ParamNegativePrompt = memo(() => {
},
[dispatch]
);
const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocusCursorAtEnd } = usePrompt({
const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown } = usePrompt({
prompt,
textareaRef,
onChange: _onChange,
});
const handleFocus = useCallback(() => {
setTimeout(() => {
onFocusCursorAtEnd();
}, 500);
}, [onFocusCursorAtEnd]);
if (viewMode) {
return (
<ViewModePrompt
prompt={prompt}
presetPrompt={activeStylePreset?.preset_data.negative_prompt || ''}
height={DEFAULT_HEIGHT}
onExit={handleFocus}
/>
);
}
return (
<PromptPopover isOpen={isOpen} onClose={onClose} onSelect={onSelect} width={textareaRef.current?.clientWidth}>
<Box pos="relative" w="full">
{viewMode && (
<ViewModePrompt prompt={prompt} presetPrompt={activeStylePreset?.preset_data.negative_prompt || ''} />
)}
<Textarea
id="negativePrompt"
name="negativePrompt"
@ -61,7 +57,6 @@ export const ParamNegativePrompt = memo(() => {
fontSize="sm"
variant="darkFilled"
paddingRight={30}
minH={DEFAULT_HEIGHT}
/>
<PromptOverlayButtonWrapper>
<AddPromptTriggerButton isOpen={isOpen} onOpen={onOpen} />

View File

@ -12,15 +12,24 @@ import { memo, useCallback, useRef } from 'react';
import type { HotkeyCallback } from 'react-hotkeys-hook';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
const DEFAULT_HEIGHT = 28;
import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets';
export const ParamPositivePrompt = memo(() => {
const dispatch = useAppDispatch();
const prompt = useAppSelector((s) => s.controlLayers.present.positivePrompt);
const baseModel = useAppSelector((s) => s.generation.model)?.base;
const viewMode = useAppSelector((s) => s.stylePreset.viewMode);
const activeStylePreset = useAppSelector((s) => s.stylePreset.activeStylePreset);
const activeStylePresetId = useAppSelector((s) => s.stylePreset.activeStylePresetId);
const { activeStylePreset } = useListStylePresetsQuery(undefined, {
selectFromResult: ({ data }) => {
let activeStylePreset = null;
if (data) {
activeStylePreset = data.find((sp) => sp.id === activeStylePresetId);
}
return { activeStylePreset };
},
});
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { t } = useTranslation();
@ -30,18 +39,12 @@ export const ParamPositivePrompt = memo(() => {
},
[dispatch]
);
const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocus, onFocusCursorAtEnd } = usePrompt({
const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocus } = usePrompt({
prompt,
textareaRef: textareaRef,
onChange: handleChange,
});
const handleFocus = useCallback(() => {
setTimeout(() => {
onFocusCursorAtEnd();
}, 500);
}, [onFocusCursorAtEnd]);
const focus: HotkeyCallback = useCallback(
(e) => {
onFocus();
@ -52,20 +55,12 @@ export const ParamPositivePrompt = memo(() => {
useHotkeys('alt+a', focus, []);
if (viewMode) {
return (
<ViewModePrompt
prompt={prompt}
presetPrompt={activeStylePreset?.preset_data.positive_prompt || ''}
height={DEFAULT_HEIGHT}
onExit={handleFocus}
/>
);
}
return (
<PromptPopover isOpen={isOpen} onClose={onClose} onSelect={onSelect} width={textareaRef.current?.clientWidth}>
<Box pos="relative">
{viewMode && (
<ViewModePrompt prompt={prompt} presetPrompt={activeStylePreset?.preset_data.positive_prompt || ''} />
)}
<Textarea
id="prompt"
name="prompt"
@ -73,7 +68,7 @@ export const ParamPositivePrompt = memo(() => {
value={prompt}
placeholder={t('parameters.globalPositivePromptPlaceholder')}
onChange={onChange}
minH={DEFAULT_HEIGHT}
minH={28}
onKeyDown={onKeyDown}
variant="darkFilled"
paddingRight={30}

View File

@ -6,17 +6,7 @@ import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiEyeBold } from 'react-icons/pi';
export const ViewModePrompt = ({
presetPrompt,
prompt,
height,
onExit,
}: {
presetPrompt: string;
prompt: string;
height: number;
onExit: () => void;
}) => {
export const ViewModePrompt = ({ presetPrompt, prompt }: { presetPrompt: string; prompt: string }) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
@ -26,43 +16,43 @@ export const ViewModePrompt = ({
const handleExitViewMode = useCallback(() => {
dispatch(viewModeChanged(false));
onExit();
}, [dispatch, onExit]);
}, [dispatch]);
return (
<Flex
flexDir="column"
layerStyle="second"
padding="8px 10px"
borderRadius="base"
height={height}
onClick={handleExitViewMode}
justifyContent="space-between"
position="relative"
>
<Flex overflow="scroll">
<Text fontSize="sm" lineHeight="1rem">
{presetChunks.map((chunk, index) => {
return (
chunk && (
<Text as="span" color={index === 1 ? 'white' : 'base.300'} key={index}>
{chunk.trim()}{' '}
</Text>
)
);
})}
</Text>
</Flex>
<Box position="absolute" top={0} right={0} backgroundColor="rgba(0,0,0,0.75)" padding="2px 5px">
<Flex alignItems="center" gap="1">
<Tooltip label={t('stylePresets.viewModeTooltip')}>
<Flex>
<Icon as={PiEyeBold} color="base.500" boxSize="12px" />
</Flex>
</Tooltip>
<Box position="absolute" top={0} bottom={0} left={0} right={0} zIndex={1} layerStyle="second" borderRadius="base">
<Flex flexDir="column" onClick={handleExitViewMode} justifyContent="space-between" h="full" padding="8px 10px">
<Flex overflow="scroll">
<Text fontSize="sm" lineHeight="1rem" w="full">
{presetChunks.map((chunk, index) => (
<Text
as="span"
color={index === 1 ? 'white' : 'base.300'}
fontWeight={index === 1 ? 'semibold' : 'normal'}
key={index}
>
{chunk}
</Text>
))}
</Text>
</Flex>
</Box>
</Flex>
<Tooltip label={t('stylePresets.viewModeTooltip')}>
<Flex
position="absolute"
insetInlineEnd={0}
insetBlockStart={0}
alignItems="center"
justifyContent="center"
p={2}
bg="base.900"
opacity={0.8}
backgroundClip="border-box"
borderBottomStartRadius="base"
>
<Icon as={PiEyeBold} color="base.500" boxSize={4} />
</Flex>
</Tooltip>
</Flex>
</Box>
);
};

View File

@ -58,13 +58,6 @@ export const usePrompt = ({ prompt, textareaRef, onChange: _onChange }: UseInser
textareaRef.current?.focus();
}, [textareaRef]);
const onFocusCursorAtEnd = useCallback(() => {
onFocus();
if (textareaRef.current) {
textareaRef.current.setSelectionRange(prompt.length, prompt.length);
}
}, [onFocus, textareaRef, prompt]);
const handleClosePopover = useCallback(() => {
onClose();
onFocus();
@ -96,6 +89,5 @@ export const usePrompt = ({ prompt, textareaRef, onChange: _onChange }: UseInser
onSelect,
onKeyDown,
onFocus,
onFocusCursorAtEnd,
};
};

View File

@ -1,17 +1,30 @@
import { Flex, IconButton, Text, Tooltip } from '@invoke-ai/ui-library';
import { Badge, Flex, IconButton, Text, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { negativePromptChanged, positivePromptChanged } from 'features/controlLayers/store/controlLayersSlice';
import { usePresetModifiedPrompts } from 'features/stylePresets/hooks/usePresetModifiedPrompts';
import { activeStylePresetChanged, viewModeChanged } from 'features/stylePresets/store/stylePresetSlice';
import { activeStylePresetIdChanged, viewModeChanged } from 'features/stylePresets/store/stylePresetSlice';
import type { MouseEventHandler } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiEyeBold, PiStackSimpleBold, PiXBold } from 'react-icons/pi';
import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets';
import StylePresetImage from './StylePresetImage';
export const ActiveStylePreset = () => {
const { activeStylePreset, viewMode } = useAppSelector((s) => s.stylePreset);
const viewMode = useAppSelector((s) => s.stylePreset.viewMode);
const activeStylePresetId = useAppSelector((s) => s.stylePreset.activeStylePresetId);
const { activeStylePreset } = useListStylePresetsQuery(undefined, {
selectFromResult: ({ data }) => {
let activeStylePreset = null;
if (data) {
activeStylePreset = data.find((sp) => sp.id === activeStylePresetId);
}
return { activeStylePreset };
},
});
const dispatch = useAppDispatch();
const { t } = useTranslation();
@ -22,7 +35,7 @@ export const ActiveStylePreset = () => {
(e) => {
e.stopPropagation();
dispatch(viewModeChanged(false));
dispatch(activeStylePresetChanged(null));
dispatch(activeStylePresetIdChanged(null));
},
[dispatch]
);
@ -33,7 +46,7 @@ export const ActiveStylePreset = () => {
dispatch(positivePromptChanged(presetModifiedPositivePrompt));
dispatch(negativePromptChanged(presetModifiedNegativePrompt));
dispatch(viewModeChanged(false));
dispatch(activeStylePresetChanged(null));
dispatch(activeStylePresetIdChanged(null));
},
[dispatch, presetModifiedPositivePrompt, presetModifiedNegativePrompt]
);
@ -48,7 +61,7 @@ export const ActiveStylePreset = () => {
if (!activeStylePreset) {
return (
<Flex h="25px" alignItems="center">
<Flex h={25} alignItems="center">
<Text fontSize="sm" fontWeight="semibold" color="base.300">
{t('stylePresets.choosePromptTemplate')}
</Text>
@ -56,47 +69,45 @@ export const ActiveStylePreset = () => {
);
}
return (
<>
<Flex justifyContent="space-between" w="full" alignItems="center">
<Flex gap="2" alignItems="center">
<StylePresetImage imageWidth={25} presetImageUrl={activeStylePreset.image} />
<Flex flexDir="column">
<Text fontSize="sm" fontWeight="semibold" color="base.300" noOfLines={1}>
{activeStylePreset.name}
</Text>
</Flex>
</Flex>
<Flex gap="1">
<Tooltip label={t('stylePresets.toggleViewMode')}>
<IconButton
onClick={handleToggleViewMode}
variant="outline"
size="sm"
aria-label={t('stylePresets.toggleViewMode')}
colorScheme={viewMode ? 'invokeBlue' : 'base'}
icon={<PiEyeBold />}
/>
</Tooltip>
<Tooltip label={t('stylePresets.flatten')}>
<IconButton
onClick={handleFlattenPrompts}
variant="outline"
size="sm"
aria-label={t('stylePresets.flatten')}
icon={<PiStackSimpleBold />}
/>
</Tooltip>
<Tooltip label={t('stylePresets.clearTemplateSelection')}>
<IconButton
onClick={handleClearActiveStylePreset}
variant="outline"
size="sm"
aria-label={t('stylePresets.clearTemplateSelection')}
icon={<PiXBold />}
/>
</Tooltip>
<Flex justifyContent="space-between" w="full" alignItems="center">
<Flex gap={2} alignItems="center">
<StylePresetImage imageWidth={25} presetImageUrl={activeStylePreset.image} />
<Flex flexDir="column">
<Badge colorScheme="invokeBlue" variant="subtle">
{activeStylePreset.name}
</Badge>
</Flex>
</Flex>
</>
<Flex gap={1}>
<Tooltip label={t('stylePresets.toggleViewMode')}>
<IconButton
onClick={handleToggleViewMode}
variant="outline"
size="sm"
aria-label={t('stylePresets.toggleViewMode')}
colorScheme={viewMode ? 'invokeBlue' : 'base'}
icon={<PiEyeBold />}
/>
</Tooltip>
<Tooltip label={t('stylePresets.flatten')}>
<IconButton
onClick={handleFlattenPrompts}
variant="outline"
size="sm"
aria-label={t('stylePresets.flatten')}
icon={<PiStackSimpleBold />}
/>
</Tooltip>
<Tooltip label={t('stylePresets.clearTemplateSelection')}>
<IconButton
onClick={handleClearActiveStylePreset}
variant="outline"
size="sm"
aria-label={t('stylePresets.clearTemplateSelection')}
icon={<PiXBold />}
/>
</Tooltip>
</Flex>
</Flex>
);
};

View File

@ -1,5 +1,5 @@
import { Button, Flex, FormControl, FormLabel, Input, Text } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppDispatch } from 'app/store/storeHooks';
import { isModalOpenChanged, updatingStylePresetIdChanged } from 'features/stylePresets/store/stylePresetModalSlice';
import { toast } from 'features/toast/toast';
import { useCallback } from 'react';
@ -18,16 +18,20 @@ export type StylePresetFormData = {
image: File | null;
};
export const StylePresetForm = ({ updatingStylePresetId }: { updatingStylePresetId: string | null }) => {
export const StylePresetForm = ({
updatingStylePresetId,
formData,
}: {
updatingStylePresetId: string | null;
formData: StylePresetFormData | null;
}) => {
const [createStylePreset] = useCreateStylePresetMutation();
const [updateStylePreset] = useUpdateStylePresetMutation();
const dispatch = useAppDispatch();
const { t } = useTranslation();
const defaultValues = useAppSelector((s) => s.stylePresetModal.prefilledFormData);
const { handleSubmit, control, register, formState } = useForm<StylePresetFormData>({
defaultValues: defaultValues || {
defaultValues: formData || {
name: '',
positivePrompt: '',
negativePrompt: '',
@ -68,12 +72,12 @@ export const StylePresetForm = ({ updatingStylePresetId }: { updatingStylePreset
);
return (
<Flex flexDir="column" gap="4">
<Flex alignItems="center" gap="4">
<Flex flexDir="column" gap={4}>
<Flex alignItems="center" gap={4}>
<StylePresetImageField control={control} name="image" />
<FormControl orientation="vertical">
<FormLabel>{t('stylePresets.name')}</FormLabel>
<Input size="md" {...register('name', { required: true, minLength: 1 })} required={true} />
<Input size="md" {...register('name', { required: true, minLength: 1 })} />
</FormControl>
</Flex>

View File

@ -8,7 +8,7 @@ import { PiArrowCounterClockwiseBold, PiUploadSimpleBold } from 'react-icons/pi'
import type { StylePresetFormData } from './StylePresetForm';
export const StylePresetImageField = (props: UseControllerProps<StylePresetFormData>) => {
export const StylePresetImageField = (props: UseControllerProps<StylePresetFormData, 'image'>) => {
const { field } = useController(props);
const { t } = useTranslation();
const onDropAccepted = useCallback(
@ -36,7 +36,7 @@ export const StylePresetImageField = (props: UseControllerProps<StylePresetFormD
return (
<Box position="relative" flexShrink={0}>
<Image
src={URL.createObjectURL(field.value as File)}
src={URL.createObjectURL(field.value)}
objectFit="cover"
objectPosition="50% 50%"
w={65}

View File

@ -14,7 +14,7 @@ export const StylePresetList = ({ title, data }: { title: string; data: StylePre
return (
<Flex flexDir="column">
<Button variant="unstyled" onClick={onToggle}>
<Flex gap="2" alignItems="center">
<Flex gap={2} alignItems="center">
<Icon boxSize={4} as={PiCaretDownBold} transform={isOpen ? undefined : 'rotate(-90deg)'} fill="base.500" />
<Text fontSize="sm" fontWeight="semibold" userSelect="none" color="base.500">
{title}

View File

@ -1,12 +1,11 @@
import { Badge, ConfirmationAlertDialog, Flex, IconButton, Text, useDisclosure } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useImageUrlToBlob } from 'common/hooks/useImageUrlToBlob';
import {
isModalOpenChanged,
prefilledFormDataChanged,
updatingStylePresetIdChanged,
} from 'features/stylePresets/store/stylePresetModalSlice';
import { activeStylePresetChanged, isMenuOpenChanged } from 'features/stylePresets/store/stylePresetSlice';
import { activeStylePresetIdChanged, isMenuOpenChanged } from 'features/stylePresets/store/stylePresetSlice';
import { toast } from 'features/toast/toast';
import type { MouseEvent } from 'react';
import { useCallback } from 'react';
@ -20,40 +19,35 @@ import StylePresetImage from './StylePresetImage';
export const StylePresetListItem = ({ preset }: { preset: StylePresetRecordWithImage }) => {
const dispatch = useAppDispatch();
const [deleteStylePreset] = useDeleteStylePresetMutation();
const activeStylePreset = useAppSelector((s) => s.stylePreset.activeStylePreset);
const activeStylePresetId = useAppSelector((s) => s.stylePreset.activeStylePresetId);
const { isOpen, onOpen, onClose } = useDisclosure();
const imageUrlToBlob = useImageUrlToBlob();
const { t } = useTranslation();
const handleClickEdit = useCallback(
async (e: MouseEvent<HTMLButtonElement>) => {
(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const { name, preset_data } = preset;
const { positive_prompt, negative_prompt } = preset_data;
let imageBlob = null;
if (preset.image) {
imageBlob = await imageUrlToBlob(preset.image, 100);
}
dispatch(
prefilledFormDataChanged({
name,
positivePrompt: positive_prompt,
negativePrompt: negative_prompt,
image: imageBlob ? new File([imageBlob], `style_preset_${preset.id}.png`, { type: 'image/png' }) : null,
imageUrl: preset.image,
})
);
dispatch(updatingStylePresetIdChanged(preset.id));
dispatch(isModalOpenChanged(true));
},
[dispatch, preset, imageUrlToBlob]
[dispatch, preset]
);
const handleClickApply = useCallback(async () => {
dispatch(activeStylePresetChanged(preset));
dispatch(activeStylePresetIdChanged(preset.id));
dispatch(isMenuOpenChanged(false));
}, [dispatch, preset]);
}, [dispatch, preset.id]);
const handleClickDelete = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
@ -81,11 +75,12 @@ export const StylePresetListItem = ({ preset }: { preset: StylePresetRecordWithI
return (
<>
<Flex
gap="4"
gap={4}
onClick={handleClickApply}
cursor="pointer"
_hover={{ backgroundColor: 'base.750' }}
padding="10px"
py={3}
px={2}
borderRadius="base"
alignItems="flex-start"
w="full"
@ -93,11 +88,11 @@ export const StylePresetListItem = ({ preset }: { preset: StylePresetRecordWithI
<StylePresetImage presetImageUrl={preset.image} />
<Flex flexDir="column" w="full">
<Flex w="full" justifyContent="space-between" alignItems="flex-start">
<Flex alignItems="center" gap="2">
<Flex alignItems="center" gap={2}>
<Text fontSize="md" noOfLines={2}>
{preset.name}
</Text>
{activeStylePreset && activeStylePreset.id === preset.id && (
{activeStylePresetId === preset.id && (
<Badge
color="invokeBlue.400"
borderColor="invokeBlue.700"
@ -111,7 +106,7 @@ export const StylePresetListItem = ({ preset }: { preset: StylePresetRecordWithI
</Flex>
{!preset.is_default && (
<Flex alignItems="center" gap="1">
<Flex alignItems="center" gap={1}>
<IconButton
size="sm"
variant="ghost"
@ -131,7 +126,7 @@ export const StylePresetListItem = ({ preset }: { preset: StylePresetRecordWithI
)}
</Flex>
<Flex flexDir="column" gap="1">
<Flex flexDir="column" gap={1}>
<Text fontSize="xs">
<Text as="span" fontWeight="semibold">
{t('stylePresets.positivePrompt')}:

View File

@ -18,7 +18,7 @@ import StylePresetSearch from './StylePresetSearch';
export const StylePresetMenu = () => {
const searchTerm = useAppSelector((s) => s.stylePreset.searchTerm);
const { data } = useListStylePresetsQuery(undefined, {
selectFromResult: ({ data, error, isLoading }) => {
selectFromResult: ({ data }) => {
const filteredData =
data?.filter((preset) => preset.name.toLowerCase().includes(searchTerm.toLowerCase())) || EMPTY_ARRAY;
@ -36,8 +36,6 @@ export const StylePresetMenu = () => {
return {
data: groupedData,
error,
isLoading,
};
},
});
@ -52,8 +50,8 @@ export const StylePresetMenu = () => {
}, [dispatch]);
return (
<Flex flexDir="column" gap="2" padding="10px" layerStyle="second">
<Flex alignItems="center" gap="10" w="full" justifyContent="space-between">
<Flex flexDir="column" gap={2} padding={3} layerStyle="second" borderRadius="base">
<Flex alignItems="center" gap={2} w="full" justifyContent="space-between">
<StylePresetSearch />
<IconButton
icon={<PiPlusBold />}
@ -68,7 +66,7 @@ export const StylePresetMenu = () => {
</Flex>
{data.presets.length === 0 && data.defaultPresets.length === 0 && (
<Text m="20px" textAlign="center">
<Text p={10} textAlign="center">
{t('stylePresets.noMatchingTemplates')}
</Text>
)}

View File

@ -1,15 +1,16 @@
import { Flex, Icon } from '@invoke-ai/ui-library';
import { Flex, IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { isMenuOpenChanged } from 'features/stylePresets/store/stylePresetSlice';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';
import { ActiveStylePreset } from './ActiveStylePreset';
export const StylePresetMenuTrigger = () => {
const isMenuOpen = useAppSelector((s) => s.stylePreset.isMenuOpen);
const activeStylePreset = useAppSelector((s) => s.stylePreset.activeStylePreset);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const handleToggle = useCallback(() => {
dispatch(isMenuOpenChanged(!isMenuOpen));
@ -17,20 +18,19 @@ export const StylePresetMenuTrigger = () => {
return (
<Flex
as="button"
onClick={handleToggle}
backgroundColor="base.800"
justifyContent="space-between"
alignItems="center"
padding="5px 10px"
py={2}
px={3}
borderRadius="base"
gap="2"
borderTop="2px solid transparent"
borderColor={activeStylePreset ? 'invokeBlue.200' : 'transparent'}
gap={1}
role="button"
>
<ActiveStylePreset />
<Icon boxSize="15px" as={PiCaretDownBold} color="base.300" />
<IconButton aria-label={t('stylePresets.viewList')} variant="ghost" icon={<PiCaretDownBold />} size="sm" />
</Flex>
);
};

View File

@ -6,29 +6,65 @@ import {
ModalFooter,
ModalHeader,
ModalOverlay,
Spinner,
} from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { isModalOpenChanged, updatingStylePresetIdChanged } from 'features/stylePresets/store/stylePresetModalSlice';
import { useCallback, useMemo } from 'react';
import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob';
import {
isModalOpenChanged,
prefilledFormDataChanged,
updatingStylePresetIdChanged,
} from 'features/stylePresets/store/stylePresetModalSlice';
import type { PrefilledFormData } from 'features/stylePresets/store/types';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { StylePresetFormData } from './StylePresetForm';
import { StylePresetForm } from './StylePresetForm';
export const StylePresetModal = () => {
const [formData, setFormData] = useState<StylePresetFormData | null>(null);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const isModalOpen = useAppSelector((s) => s.stylePresetModal.isModalOpen);
const updatingStylePresetId = useAppSelector((s) => s.stylePresetModal.updatingStylePresetId);
const prefilledFormData = useAppSelector((s) => s.stylePresetModal.prefilledFormData);
const modalTitle = useMemo(() => {
return updatingStylePresetId ? t('stylePresets.updatePromptTemplate') : t('stylePresets.createPromptTemplate');
}, [updatingStylePresetId, t]);
const handleCloseModal = useCallback(() => {
dispatch(prefilledFormDataChanged(null));
dispatch(updatingStylePresetIdChanged(null));
dispatch(isModalOpenChanged(false));
}, [dispatch]);
useEffect(() => {
setFormData(null);
}, []);
useEffect(() => {
const convertImageToBlob = async (data: PrefilledFormData | null) => {
if (!data) {
setFormData(null);
} else {
let file = null;
if (data.imageUrl) {
const blob = await convertImageUrlToBlob(data.imageUrl);
if (blob) {
file = new File([blob], 'style_preset.png', { type: 'image/png' });
}
}
setFormData({
...data,
image: file,
});
}
};
convertImageToBlob(prefilledFormData);
}, [prefilledFormData]);
return (
<Modal isOpen={isModalOpen} onClose={handleCloseModal} isCentered size="2xl">
<ModalOverlay />
@ -36,9 +72,13 @@ export const StylePresetModal = () => {
<ModalHeader>{modalTitle}</ModalHeader>
<ModalCloseButton />
<ModalBody display="flex" flexDir="column" gap={4}>
<StylePresetForm updatingStylePresetId={updatingStylePresetId} />
{!prefilledFormData || formData ? (
<StylePresetForm updatingStylePresetId={updatingStylePresetId} formData={formData} />
) : (
<Spinner />
)}
</ModalBody>
<ModalFooter />
<ModalFooter p={2} />
</ModalContent>
</Modal>
);

View File

@ -1,15 +1,14 @@
import { Flex, FormControl, FormLabel, IconButton, Textarea } from '@invoke-ai/ui-library';
import { Button, Flex, FormControl, FormLabel, Textarea } from '@invoke-ai/ui-library';
import { PRESET_PLACEHOLDER } from 'features/stylePresets/hooks/usePresetModifiedPrompts';
import type { ChangeEventHandler } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import type { UseControllerProps } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { PiBracketsCurlyBold } from 'react-icons/pi';
import type { StylePresetFormData } from './StylePresetForm';
interface Props extends UseControllerProps<StylePresetFormData> {
interface Props extends UseControllerProps<StylePresetFormData, 'negativePrompt' | 'positivePrompt'> {
label: string;
}
@ -26,7 +25,7 @@ export const StylePresetPromptField = (props: Props) => {
);
const value = useMemo(() => {
return field.value as string;
return field.value;
}, [field.value]);
const insertPromptPlaceholder = useCallback(() => {
@ -45,16 +44,17 @@ export const StylePresetPromptField = (props: Props) => {
const isPromptPresent = useMemo(() => value?.includes(PRESET_PLACEHOLDER), [value]);
return (
<FormControl orientation="vertical">
<Flex alignItems="center" gap="1">
<FormControl orientation="vertical" gap={3}>
<Flex alignItems="center" gap={2}>
<FormLabel>{props.label}</FormLabel>
<IconButton
<Button
onClick={insertPromptPlaceholder}
size="sm"
icon={<PiBracketsCurlyBold />}
size="xs"
aria-label={t('stylePresets.insertPlaceholder')}
isDisabled={isPromptPresent}
/>
>
{t('stylePresets.insertPlaceholder')}
</Button>
</Flex>
<Textarea size="sm" ref={textareaRef} value={value} onChange={onChange} />

View File

@ -1,6 +1,7 @@
import { useAppSelector } from 'app/store/storeHooks';
import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets';
export const PRESET_PLACEHOLDER = `{prompt}`;
export const PRESET_PLACEHOLDER = `[prompt]`;
export const buildPresetModifiedPrompt = (presetPrompt: string, currentPrompt: string) => {
return presetPrompt.includes(PRESET_PLACEHOLDER)
@ -9,9 +10,20 @@ export const buildPresetModifiedPrompt = (presetPrompt: string, currentPrompt: s
};
export const usePresetModifiedPrompts = () => {
const activeStylePreset = useAppSelector((s) => s.stylePreset.activeStylePreset);
const { positivePrompt, negativePrompt } = useAppSelector((s) => s.controlLayers.present);
const activeStylePresetId = useAppSelector((s) => s.stylePreset.activeStylePresetId);
const { activeStylePreset } = useListStylePresetsQuery(undefined, {
selectFromResult: ({ data }) => {
let activeStylePreset = null;
if (data) {
activeStylePreset = data.find((sp) => sp.id === activeStylePresetId);
}
return { activeStylePreset };
},
});
if (!activeStylePreset) {
return { presetModifiedPositivePrompt: positivePrompt, presetModifiedNegativePrompt: negativePrompt };
}

View File

@ -1,8 +1,7 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { StylePresetFormData } from 'features/stylePresets/components/StylePresetForm';
import type { StylePresetModalState } from './types';
import type { PrefilledFormData, StylePresetModalState } from './types';
const initialState: StylePresetModalState = {
isModalOpen: false,
@ -20,7 +19,7 @@ export const stylePresetModalSlice = createSlice({
updatingStylePresetIdChanged: (state, action: PayloadAction<string | null>) => {
state.updatingStylePresetId = action.payload;
},
prefilledFormDataChanged: (state, action: PayloadAction<StylePresetFormData | null>) => {
prefilledFormDataChanged: (state, action: PayloadAction<PrefilledFormData | null>) => {
state.prefilledFormData = action.payload;
},
},

View File

@ -1,12 +1,12 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets';
import type { PersistConfig } from 'app/store/store';
import type { StylePresetState } from './types';
const initialState: StylePresetState = {
isMenuOpen: false,
activeStylePreset: null,
activeStylePresetId: null,
searchTerm: '',
viewMode: false,
};
@ -18,8 +18,8 @@ export const stylePresetSlice = createSlice({
isMenuOpenChanged: (state, action: PayloadAction<boolean>) => {
state.isMenuOpen = action.payload;
},
activeStylePresetChanged: (state, action: PayloadAction<StylePresetRecordWithImage | null>) => {
state.activeStylePreset = action.payload;
activeStylePresetIdChanged: (state, action: PayloadAction<string | null>) => {
state.activeStylePresetId = action.payload;
},
searchTermChanged: (state, action: PayloadAction<string>) => {
state.searchTerm = action.payload;
@ -30,5 +30,20 @@ export const stylePresetSlice = createSlice({
},
});
export const { isMenuOpenChanged, activeStylePresetChanged, searchTermChanged, viewModeChanged } =
export const { isMenuOpenChanged, activeStylePresetIdChanged, searchTermChanged, viewModeChanged } =
stylePresetSlice.actions;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const migrateStylePresetState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
return state;
};
export const stylePresetPersistConfig: PersistConfig<StylePresetState> = {
name: stylePresetSlice.name,
initialState,
migrate: migrateStylePresetState,
persistDenylist: [],
};

View File

@ -1,15 +1,19 @@
import type { StylePresetFormData } from 'features/stylePresets/components/StylePresetForm';
import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets';
export type StylePresetModalState = {
isModalOpen: boolean;
updatingStylePresetId: string | null;
prefilledFormData: StylePresetFormData | null;
prefilledFormData: PrefilledFormData | null;
};
export type PrefilledFormData = {
name: string;
positivePrompt: string;
negativePrompt: string;
imageUrl: string | null;
};
export type StylePresetState = {
isMenuOpen: boolean;
activeStylePreset: StylePresetRecordWithImage | null;
activeStylePresetId: string | null;
searchTerm: string;
viewMode: boolean;
};

View File

@ -1,15 +1,12 @@
import { PRESET_PLACEHOLDER } from 'features/stylePresets/hooks/usePresetModifiedPrompts';
export const getViewModeChunks = (currentPrompt: string, presetPrompt?: string) => {
export const getViewModeChunks = (currentPrompt: string, presetPrompt?: string): [string, string, string] => {
if (!presetPrompt || !presetPrompt.length) {
return ['', currentPrompt, ''];
}
const chunks = presetPrompt.split(PRESET_PLACEHOLDER);
if (chunks.length === 1) {
return ['', currentPrompt, chunks[0]];
return ['', currentPrompt, chunks[0] ?? ''];
} else {
return [chunks[0], currentPrompt, chunks[1]];
return [chunks[0] ?? '', currentPrompt, chunks[1] ?? ''];
}
};

View File

@ -13,7 +13,7 @@ export type StylePresetRecordWithImage =
*/
const buildStylePresetsUrl = (path: string = '') => buildV1Url(`style_presets/${path}`);
const stylePresetsApi = api.injectEndpoints({
export const stylePresetsApi = api.injectEndpoints({
endpoints: (build) => ({
getStylePreset: build.query<
paths['/api/v1/style_presets/i/{style_preset_id}']['get']['responses']['200']['content']['application/json'],