diff --git a/invokeai/app/api/routers/style_presets.py b/invokeai/app/api/routers/style_presets.py index f7157c6ec3..dc5e574843 100644 --- a/invokeai/app/api/routers/style_presets.py +++ b/invokeai/app/api/routers/style_presets.py @@ -47,11 +47,11 @@ async def get_style_preset( }, ) async def update_style_preset( + image: Optional[UploadFile] = File(description="The image file to upload", default=None), style_preset_id: str = Path(description="The id of the style preset to update"), name: str = Form(description="The name of the style preset to create"), positive_prompt: str = Form(description="The positive prompt of the style preset"), negative_prompt: str = Form(description="The negative prompt of the style preset"), - image: Optional[UploadFile] = File(description="The image file to upload", default=None), ) -> StylePresetRecordWithImage: """Updates a style preset""" if image is not None: @@ -73,8 +73,8 @@ async def update_style_preset( else: try: ApiDependencies.invoker.services.style_preset_images_service.delete(style_preset_id) - except ValueError as e: - raise HTTPException(status_code=409, detail=str(e)) + except StylePresetImageFileNotFoundException: + pass preset_data = PresetData(positive_prompt=positive_prompt, negative_prompt=negative_prompt) changes = StylePresetChanges(name=name, preset_data=preset_data) diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index 61b520ce67..6f844858ae 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -153,7 +153,7 @@ class InvokeAIAppConfig(BaseSettings): db_dir: Path = Field(default=Path("databases"), description="Path to InvokeAI databases directory.") outputs_dir: Path = Field(default=Path("outputs"), description="Path to directory for outputs.") custom_nodes_dir: Path = Field(default=Path("nodes"), description="Path to directory for custom nodes.") - style_preset_images_path: Path = Field(default=Path("style_preset_images"), description="Path to directory for style preset images.") + style_preset_images_dir: Path = Field(default=Path("style_preset_images"), description="Path to directory for style preset images.") # LOGGING log_handlers: list[str] = Field(default=["console"], description='Log handler. Valid options are "console", "file=", "syslog=path|address:host:port", "http=".') @@ -301,6 +301,11 @@ class InvokeAIAppConfig(BaseSettings): """Path to the models directory, resolved to an absolute path..""" return self._resolve(self.models_dir) + @property + def style_preset_images_path(self) -> Path: + """Path to the style preset images directory, resolved to an absolute path..""" + return self._resolve(self.style_preset_images_dir) + @property def convert_cache_path(self) -> Path: """Path to the converted cache models directory, resolved to an absolute path..""" diff --git a/invokeai/frontend/web/src/common/hooks/useImageUrlToBlob.ts b/invokeai/frontend/web/src/common/hooks/useImageUrlToBlob.ts index 31faf5f22f..197782de21 100644 --- a/invokeai/frontend/web/src/common/hooks/useImageUrlToBlob.ts +++ b/invokeai/frontend/web/src/common/hooks/useImageUrlToBlob.ts @@ -13,12 +13,14 @@ export const useImageUrlToBlob = () => { new Promise((resolve) => { const img = new Image(); img.onload = () => { + console.log("on load") const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const context = canvas.getContext('2d'); if (!context) { + console.log("no context") return; } context.drawImage(img, 0, 0); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx index 6da89d1604..6c24617086 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx @@ -6,6 +6,7 @@ import { useCopyImageToClipboard } from 'common/hooks/useCopyImageToClipboard'; import { useDownloadImage } from 'common/hooks/useDownloadImage'; import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; import { imagesToChangeSelected, isModalOpenChanged } from 'features/changeBoardModal/store/slice'; +import { isModalOpenChanged as isStylePresetModalOpenChanged } from 'features/stylePresets/store/stylePresetModalSlice'; import { iiLayerAdded } from 'features/controlLayers/store/controlLayersSlice'; import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; import { useImageActions } from 'features/gallery/hooks/useImageActions'; @@ -58,8 +59,17 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => { const { downloadImage } = useDownloadImage(); const templates = useStore($templates); - const { recallAll, remix, recallSeed, recallPrompts, hasMetadata, hasSeed, hasPrompts, isLoadingMetadata } = - useImageActions(imageDTO?.image_name); + const { + recallAll, + remix, + recallSeed, + recallPrompts, + hasMetadata, + hasSeed, + hasPrompts, + isLoadingMetadata, + createAsPreset, + } = useImageActions(imageDTO?.image_name); const { getAndLoadEmbeddedWorkflow, getAndLoadEmbeddedWorkflowResult } = useGetAndLoadEmbeddedWorkflow({}); @@ -133,11 +143,6 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => { dispatch(setActiveTab('upscaling')); }, [dispatch, imageDTO]); - const handleCreatePreset = useCallback(() => { - dispatch(createPresetFromImageChanged(imageDTO)); - dispatch(isMenuOpenChanged(true)); - }, [dispatch, imageDTO]); - return ( <> }> @@ -192,7 +197,7 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => { : } - onClickCapture={handleCreatePreset} + onClickCapture={createAsPreset} isDisabled={isLoadingMetadata || !hasPrompts} > Create Preset diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts index 2978e08f4f..eeedcc454f 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts @@ -1,8 +1,12 @@ -import { useAppSelector } from 'app/store/storeHooks'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { handlers, parseAndRecallAllMetadata, parseAndRecallPrompts } from 'features/metadata/util/handlers'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { useCallback, useEffect, useState } from 'react'; import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata'; +import { useImageUrlToBlob } from '../../../common/hooks/useImageUrlToBlob'; +import { prefilledFormDataChanged, isModalOpenChanged } from '../../stylePresets/store/stylePresetModalSlice'; +import { useGetImageDTOQuery } from '../../../services/api/endpoints/images'; +import { skipToken } from '@reduxjs/toolkit/query'; export const useImageActions = (image_name?: string) => { const activeTabName = useAppSelector(activeTabNameSelector); @@ -10,6 +14,9 @@ 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) useEffect(() => { const parseMetadata = async () => { @@ -61,5 +68,17 @@ export const useImageActions = (image_name?: string) => { parseAndRecallPrompts(metadata); }, [metadata]); - return { recallAll, remix, recallSeed, recallPrompts, hasMetadata, hasSeed, hasPrompts, isLoadingMetadata }; + const createAsPreset = useCallback(async () => { + 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) + + dispatch(prefilledFormDataChanged({ name: "", positivePrompt, negativePrompt, image: imageBlob ? new File([imageBlob], "stylePreset.png", { type: 'image/png', }) : null })) + dispatch(isModalOpenChanged(true)) + } + + }, [image_name, metadata, dispatch, imageDTO]) + + return { recallAll, remix, recallSeed, recallPrompts, hasMetadata, hasSeed, hasPrompts, isLoadingMetadata, createAsPreset }; }; diff --git a/invokeai/frontend/web/src/features/metadata/util/parsers.ts b/invokeai/frontend/web/src/features/metadata/util/parsers.ts index 78d569f987..23db94f4e5 100644 --- a/invokeai/frontend/web/src/features/metadata/util/parsers.ts +++ b/invokeai/frontend/web/src/features/metadata/util/parsers.ts @@ -125,7 +125,7 @@ const parseCreatedBy: MetadataParseFunc = (metadata) => getProperty(meta const parseGenerationMode: MetadataParseFunc = (metadata) => getProperty(metadata, 'generation_mode', isString); -const parsePositivePrompt: MetadataParseFunc = (metadata) => +export const parsePositivePrompt: MetadataParseFunc = (metadata) => getProperty(metadata, 'positive_prompt', isParameterPositivePrompt); const parseNegativePrompt: MetadataParseFunc = (metadata) => diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx index d75c98c064..f06b468bf6 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx @@ -7,10 +7,15 @@ import { PromptPopover } from 'features/prompt/PromptPopover'; import { usePrompt } from 'features/prompt/usePrompt'; import { memo, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { ViewModePrompt } from '../Prompts/ViewModePrompt'; + +const DEFAULT_HEIGHT = 20; 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 textareaRef = useRef(null); const { t } = useTranslation(); const _onChange = useCallback( @@ -25,6 +30,16 @@ export const ParamNegativePrompt = memo(() => { onChange: _onChange, }); + if (viewMode) { + return ( + + ); + } + return ( @@ -39,6 +54,7 @@ export const ParamNegativePrompt = memo(() => { fontSize="sm" variant="darkFilled" paddingRight={30} + minH={DEFAULT_HEIGHT} /> diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx index ebe64ea7dd..4ac8e6c4b8 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx @@ -11,11 +11,16 @@ import { memo, useCallback, useRef } from 'react'; import type { HotkeyCallback } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; +import { ViewModePrompt } from '../Prompts/ViewModePrompt'; + +const DEFAULT_HEIGHT = 28; 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 textareaRef = useRef(null); const { t } = useTranslation(); @@ -41,6 +46,16 @@ export const ParamPositivePrompt = memo(() => { useHotkeys('alt+a', focus, []); + if (viewMode) { + return ( + + ); + } + return ( @@ -51,7 +66,7 @@ export const ParamPositivePrompt = memo(() => { value={prompt} placeholder={t('parameters.globalPositivePromptPlaceholder')} onChange={onChange} - minH={28} + minH={DEFAULT_HEIGHT} onKeyDown={onKeyDown} variant="darkFilled" paddingRight={30} diff --git a/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx b/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx index 5666989c8d..c41f929ae9 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx @@ -7,7 +7,6 @@ import { ParamPositivePrompt } from 'features/parameters/components/Core/ParamPo import { selectGenerationSlice } from 'features/parameters/store/generationSlice'; import { ParamSDXLNegativeStylePrompt } from 'features/sdxl/components/SDXLPrompts/ParamSDXLNegativeStylePrompt'; import { ParamSDXLPositiveStylePrompt } from 'features/sdxl/components/SDXLPrompts/ParamSDXLPositiveStylePrompt'; -import { usePresetModifiedPrompts } from 'features/stylePresets/hooks/usePresetModifiedPrompts'; import { memo } from 'react'; const concatPromptsSelector = createSelector( @@ -19,14 +18,11 @@ const concatPromptsSelector = createSelector( export const Prompts = memo(() => { const shouldConcatPrompts = useAppSelector(concatPromptsSelector); - const { presetModifiedPositivePrompt, presetModifiedNegativePrompt } = usePresetModifiedPrompts(); return ( - {presetModifiedPositivePrompt} {!shouldConcatPrompts && } - {presetModifiedNegativePrompt} {!shouldConcatPrompts && } ); diff --git a/invokeai/frontend/web/src/features/parameters/components/Prompts/ViewModePrompt.tsx b/invokeai/frontend/web/src/features/parameters/components/Prompts/ViewModePrompt.tsx new file mode 100644 index 0000000000..5691a01295 --- /dev/null +++ b/invokeai/frontend/web/src/features/parameters/components/Prompts/ViewModePrompt.tsx @@ -0,0 +1,67 @@ +import { Flex, Icon, Text, Tooltip, Box } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from '../../../../app/store/storeHooks'; +import { useCallback, useMemo } from 'react'; +import { PiEyeBold, PiQuestionBold } from 'react-icons/pi'; +import { viewModeChanged } from '../../../stylePresets/store/stylePresetSlice'; +import { getViewModeChunks } from '../../../stylePresets/util/getViewModeChunks'; + +export const ViewModePrompt = ({ + presetPrompt, + prompt, + height, +}: { + presetPrompt: string; + prompt: string; + height: number; +}) => { + const dispatch = useAppDispatch(); + + const presetChunks = useMemo(() => { + return getViewModeChunks(prompt, presetPrompt); + }, [presetPrompt, prompt]); + + const handleExitViewMode = useCallback(() => { + dispatch(viewModeChanged(false)); + }, [dispatch]); + + return ( + + + + {presetChunks.map((chunk, index) => { + return ( + chunk && ( + + {chunk.trim()}{' '} + + ) + ); + })} + + + + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/prompt/usePromptContentEditable.ts b/invokeai/frontend/web/src/features/prompt/usePromptContentEditable.ts new file mode 100644 index 0000000000..d077b45a24 --- /dev/null +++ b/invokeai/frontend/web/src/features/prompt/usePromptContentEditable.ts @@ -0,0 +1,98 @@ +import { useDisclosure } from '@invoke-ai/ui-library'; +import { isNil } from 'lodash-es'; +import type { ChangeEventHandler, FormEvent, FormEventHandler, KeyboardEventHandler, RefObject, SyntheticEvent } from 'react'; +import { useCallback } from 'react'; +import { flushSync } from 'react-dom'; + +type UseInsertTriggerArg = { + prompt: string; + paragraphRef: RefObject; + onChange: (v: string) => void; +}; + +export const usePromptContentEditable = ({ prompt, paragraphRef, onChange: _onChange }: UseInsertTriggerArg) => { + const { isOpen, onClose, onOpen } = useDisclosure(); + + + const onChange = useCallback( + (e: any) => { + e.preventDefault(); + _onChange(e.data) + }, + [_onChange] + ); + + + const insertTrigger = useCallback( + (v: string) => { + const element = paragraphRef.current; + if (!element) { + return; + } + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + // Insert at the end if no selection found + const newPrompt = prompt + v; + flushSync(() => { + _onChange(newPrompt); + }); + return; + } + + const range = selection.getRangeAt(0); + const cursorPosition = range.startOffset; + + + console.log({ cursorPosition }) + + + const updatedPrompt = prompt.slice(0, cursorPosition) + v + prompt.slice(cursorPosition); + + console.log({ updatedPrompt }) + + flushSync(() => { + _onChange(updatedPrompt); + }); + + }, + [paragraphRef, _onChange, prompt] + ); + + const onFocus = useCallback(() => { + paragraphRef.current?.focus(); + }, [paragraphRef]); + + const handleClosePopover = useCallback(() => { + onClose(); + onFocus(); + }, [onFocus, onClose]); + + const onSelect = useCallback( + (v: string) => { + insertTrigger(v) + handleClosePopover(); + }, + [handleClosePopover, insertTrigger] + ); + + const onKeyDown: KeyboardEventHandler = useCallback( + (e) => { + if (e.key === '<') { + onOpen(); + e.preventDefault(); + } + }, + [onOpen] + ); + + return { + onChange, + isOpen, + onClose, + onOpen, + onSelect, + onKeyDown, + onFocus, + }; +}; diff --git a/invokeai/frontend/web/src/features/stylePresets/components/ActiveStylePreset.tsx b/invokeai/frontend/web/src/features/stylePresets/components/ActiveStylePreset.tsx index 3ad7aa2f44..81a076f109 100644 --- a/invokeai/frontend/web/src/features/stylePresets/components/ActiveStylePreset.tsx +++ b/invokeai/frontend/web/src/features/stylePresets/components/ActiveStylePreset.tsx @@ -2,14 +2,15 @@ import { Flex, IconButton, Text, Box, ButtonGroup } 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 } from 'features/stylePresets/store/stylePresetSlice'; +import { activeStylePresetChanged, viewModeChanged } from 'features/stylePresets/store/stylePresetSlice'; import type { MouseEventHandler } from 'react'; import { useCallback } from 'react'; -import { PiStackSimpleBold, PiXBold } from 'react-icons/pi'; +import { PiEyeBold, PiStackSimpleBold, PiXBold } from 'react-icons/pi'; import StylePresetImage from './StylePresetImage'; export const ActiveStylePreset = () => { - const { activeStylePreset } = useAppSelector((s) => s.stylePreset); + const { activeStylePreset, viewMode } = useAppSelector((s) => s.stylePreset); + const dispatch = useAppDispatch(); const { presetModifiedPositivePrompt, presetModifiedNegativePrompt } = usePresetModifiedPrompts(); @@ -17,6 +18,7 @@ export const ActiveStylePreset = () => { const handleClearActiveStylePreset = useCallback>( (e) => { e.stopPropagation(); + dispatch(viewModeChanged(false)); dispatch(activeStylePresetChanged(null)); }, [dispatch] @@ -27,11 +29,20 @@ export const ActiveStylePreset = () => { e.stopPropagation(); dispatch(positivePromptChanged(presetModifiedPositivePrompt)); dispatch(negativePromptChanged(presetModifiedNegativePrompt)); + dispatch(viewModeChanged(false)); dispatch(activeStylePresetChanged(null)); }, [dispatch, presetModifiedPositivePrompt, presetModifiedNegativePrompt] ); + const handleToggleViewMode = useCallback>( + (e) => { + e.stopPropagation(); + dispatch(viewModeChanged(!viewMode)); + }, + [dispatch, viewMode] + ); + if (!activeStylePreset) { return ( @@ -47,12 +58,20 @@ export const ActiveStylePreset = () => { - + {activeStylePreset.name} + } + /> { +export const StylePresetForm = ({ updatingStylePresetId }: { updatingStylePresetId: string | null }) => { const [createStylePreset] = useCreateStylePresetMutation(); const [updateStylePreset] = useUpdateStylePresetMutation(); const dispatch = useAppDispatch(); - const stylePresetFieldDefaults = useStylePresetFields(updatingPreset); + const defaultValues = useAppSelector((s) => s.stylePresetModal.prefilledFormData); const { handleSubmit, control, formState, reset, register } = useForm({ - defaultValues: stylePresetFieldDefaults, + defaultValues: defaultValues || { + name: '', + positivePrompt: '', + negativePrompt: '', + image: null, + }, }); const handleClickSave = useCallback>( @@ -41,9 +44,9 @@ export const StylePresetForm = ({ updatingPreset }: { updatingPreset: StylePrese }; try { - if (updatingPreset) { + if (updatingStylePresetId) { await updateStylePreset({ - id: updatingPreset.id, + id: updatingStylePresetId, ...payload, }).unwrap(); } else { @@ -56,10 +59,10 @@ export const StylePresetForm = ({ updatingPreset }: { updatingPreset: StylePrese }); } - dispatch(updatingStylePresetChanged(null)); + dispatch(updatingStylePresetIdChanged(null)); dispatch(isModalOpenChanged(false)); }, - [dispatch, updatingPreset, updateStylePreset, createStylePreset] + [dispatch, updatingStylePresetId, updateStylePreset, createStylePreset] ); return ( diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetListItem.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetListItem.tsx index a8306ff68f..5b6c2f87d8 100644 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetListItem.tsx +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetListItem.tsx @@ -1,31 +1,52 @@ import { Badge, ConfirmationAlertDialog, Flex, IconButton, Text, useDisclosure } from '@invoke-ai/ui-library'; import type { MouseEvent } from 'react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import ModelImage from 'features/modelManagerV2/subpanels/ModelManagerPanel/ModelImage'; -import { isModalOpenChanged, updatingStylePresetChanged } from 'features/stylePresets/store/stylePresetModalSlice'; +import { + isModalOpenChanged, + prefilledFormDataChanged, + updatingStylePresetIdChanged, +} from 'features/stylePresets/store/stylePresetModalSlice'; import { activeStylePresetChanged, isMenuOpenChanged } from 'features/stylePresets/store/stylePresetSlice'; import { useCallback } from 'react'; import { PiPencilBold, PiTrashBold } from 'react-icons/pi'; import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets'; import { useDeleteStylePresetMutation } from 'services/api/endpoints/stylePresets'; import StylePresetImage from './StylePresetImage'; +import { useImageUrlToBlob } from 'common/hooks/useImageUrlToBlob'; export const StylePresetListItem = ({ preset }: { preset: StylePresetRecordWithImage }) => { const dispatch = useAppDispatch(); const [deleteStylePreset] = useDeleteStylePresetMutation(); const activeStylePreset = useAppSelector((s) => s.stylePreset.activeStylePreset); const { isOpen, onOpen, onClose } = useDisclosure(); + const imageUrlToBlob = useImageUrlToBlob(); const handleClickEdit = useCallback( - (e: MouseEvent) => { + async (e: MouseEvent) => { e.stopPropagation(); - dispatch(updatingStylePresetChanged(preset)); + const { name, preset_data } = preset; + const { positive_prompt, negative_prompt } = preset_data; + let imageBlob = null; + if (preset.image) { + imageBlob = await imageUrlToBlob(preset.image); + } + + dispatch( + prefilledFormDataChanged({ + name, + positivePrompt: positive_prompt, + negativePrompt: negative_prompt, + image: imageBlob ? new File([imageBlob], `style_preset_${preset.id}.png`, { type: 'image/png' }) : null, + }) + ); + + dispatch(updatingStylePresetIdChanged(preset.id)); dispatch(isModalOpenChanged(true)); }, [dispatch, preset] ); - const handleClickApply = useCallback(() => { + const handleClickApply = useCallback(async () => { dispatch(activeStylePresetChanged(preset)); dispatch(isMenuOpenChanged(false)); }, [dispatch, preset]); @@ -53,14 +74,16 @@ export const StylePresetListItem = ({ preset }: { preset: StylePresetRecordWithI _hover={{ backgroundColor: 'base.750' }} padding="10px" borderRadius="base" - alignItems="center" + alignItems="flex-start" w="full" > - + - {preset.name} + + {preset.name} + {activeStylePreset && activeStylePreset.id === preset.id && ( } /> diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx index 5e8b2aa6c8..21134396b9 100644 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx @@ -1,7 +1,11 @@ import { Flex, IconButton, Text } from '@invoke-ai/ui-library'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { isModalOpenChanged, updatingStylePresetChanged } from 'features/stylePresets/store/stylePresetModalSlice'; +import { + isModalOpenChanged, + prefilledFormDataChanged, + updatingStylePresetIdChanged, +} from 'features/stylePresets/store/stylePresetModalSlice'; import { useCallback } from 'react'; import { PiPlusBold } from 'react-icons/pi'; import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets'; @@ -40,12 +44,13 @@ export const StylePresetMenu = () => { const dispatch = useAppDispatch(); const handleClickAddNew = useCallback(() => { - dispatch(updatingStylePresetChanged(null)); + dispatch(prefilledFormDataChanged(null)); + dispatch(updatingStylePresetIdChanged(null)); dispatch(isModalOpenChanged(true)); }, [dispatch]); return ( - + { const isMenuOpen = useAppSelector((s) => s.stylePreset.isMenuOpen); + const activeStylePreset = useAppSelector((s) => s.stylePreset.activeStylePreset); const dispatch = useAppDispatch(); const handleToggle = useCallback(() => { @@ -16,7 +17,7 @@ export const StylePresetMenuTrigger = () => { return ( { padding="5px 10px" borderRadius="base" gap="2" + borderTop="2px solid transparent" + borderColor={activeStylePreset ? 'invokeBlue.200' : 'transparent'} > diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetModal.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetModal.tsx index 02be01e31f..dfe4777b9a 100644 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetModal.tsx +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetModal.tsx @@ -8,7 +8,7 @@ import { ModalOverlay, } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { isModalOpenChanged, updatingStylePresetChanged } from 'features/stylePresets/store/stylePresetModalSlice'; +import { isModalOpenChanged, updatingStylePresetIdChanged } from 'features/stylePresets/store/stylePresetModalSlice'; import { useCallback, useMemo } from 'react'; import { StylePresetForm } from './StylePresetForm'; @@ -16,14 +16,14 @@ import { StylePresetForm } from './StylePresetForm'; export const StylePresetModal = () => { const dispatch = useAppDispatch(); const isModalOpen = useAppSelector((s) => s.stylePresetModal.isModalOpen); - const updatingStylePreset = useAppSelector((s) => s.stylePresetModal.updatingStylePreset); + const updatingStylePresetId = useAppSelector((s) => s.stylePresetModal.updatingStylePresetId); const modalTitle = useMemo(() => { - return updatingStylePreset ? `Update Style Preset` : `Create Style Preset`; - }, [updatingStylePreset]); + return updatingStylePresetId ? `Update Style Preset` : `Create Style Preset`; + }, [updatingStylePresetId]); const handleCloseModal = useCallback(() => { - dispatch(updatingStylePresetChanged(null)); + dispatch(updatingStylePresetIdChanged(null)); dispatch(isModalOpenChanged(false)); }, [dispatch]); @@ -34,7 +34,7 @@ export const StylePresetModal = () => { {modalTitle} - + diff --git a/invokeai/frontend/web/src/features/stylePresets/hooks/useStylePresetFields.ts b/invokeai/frontend/web/src/features/stylePresets/hooks/useStylePresetFields.ts deleted file mode 100644 index ff9e114d5d..0000000000 --- a/invokeai/frontend/web/src/features/stylePresets/hooks/useStylePresetFields.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets'; -import { useAppSelector } from '../../../app/store/storeHooks'; -import { useDebouncedMetadata } from '../../../services/api/hooks/useDebouncedMetadata'; -import { handlers } from '../../metadata/util/handlers'; -import { useImageUrlToBlob } from '../../../common/hooks/useImageUrlToBlob'; - - -export const useStylePresetFields = (preset: StylePresetRecordWithImage | null) => { - const createPresetFromImage = useAppSelector(s => s.stylePresetModal.createPresetFromImage) - - const imageUrlToBlob = useImageUrlToBlob(); - - const getStylePresetFieldDefaults = useCallback(async () => { - if (preset) { - let file: File | null = null; - if (preset.image) { - const blob = await imageUrlToBlob(preset.image); - if (blob) { - file = new File([blob], "name"); - } - - } - - return { - name: preset.name, - positivePrompt: preset.preset_data.positive_prompt || "", - negativePrompt: preset.preset_data.negative_prompt || "", - image: file - }; - } - - - return { - name: "", - positivePrompt: "", - negativePrompt: "", - image: null - }; - }, [ - preset - ]); - - return getStylePresetFieldDefaults; -}; diff --git a/invokeai/frontend/web/src/features/stylePresets/store/stylePresetModalSlice.ts b/invokeai/frontend/web/src/features/stylePresets/store/stylePresetModalSlice.ts index 81582197c7..a29471096d 100644 --- a/invokeai/frontend/web/src/features/stylePresets/store/stylePresetModalSlice.ts +++ b/invokeai/frontend/web/src/features/stylePresets/store/stylePresetModalSlice.ts @@ -1,16 +1,15 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; import type { RootState } from 'app/store/store'; -import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets'; -import type { StylePresetModalState, StylePresetPrefillOptions } from './types'; -import { ImageDTO } from '../../../services/api/types'; +import type { StylePresetModalState } from './types'; +import { StylePresetFormData } from '../components/StylePresetForm'; export const initialState: StylePresetModalState = { isModalOpen: false, - updatingStylePreset: null, - createPresetFromImage: null + updatingStylePresetId: null, + prefilledFormData: null }; @@ -21,15 +20,15 @@ export const stylePresetModalSlice = createSlice({ isModalOpenChanged: (state, action: PayloadAction) => { state.isModalOpen = action.payload; }, - updatingStylePresetChanged: (state, action: PayloadAction) => { - state.updatingStylePreset = action.payload; + updatingStylePresetIdChanged: (state, action: PayloadAction) => { + state.updatingStylePresetId = action.payload; }, - createPresetFromImageChanged: (state, action: PayloadAction) => { - state.createPresetFromImage = action.payload; + prefilledFormDataChanged: (state, action: PayloadAction) => { + state.prefilledFormData = action.payload; }, }, }); -export const { isModalOpenChanged, updatingStylePresetChanged, createPresetFromImageChanged } = stylePresetModalSlice.actions; +export const { isModalOpenChanged, updatingStylePresetIdChanged, prefilledFormDataChanged } = stylePresetModalSlice.actions; export const selectStylePresetModalSlice = (state: RootState) => state.stylePresetModal; diff --git a/invokeai/frontend/web/src/features/stylePresets/store/stylePresetSlice.ts b/invokeai/frontend/web/src/features/stylePresets/store/stylePresetSlice.ts index a14d3d75ab..c7571bf94a 100644 --- a/invokeai/frontend/web/src/features/stylePresets/store/stylePresetSlice.ts +++ b/invokeai/frontend/web/src/features/stylePresets/store/stylePresetSlice.ts @@ -9,7 +9,8 @@ import type { StylePresetState } from './types'; export const initialState: StylePresetState = { isMenuOpen: false, activeStylePreset: null, - searchTerm: "" + searchTerm: "", + viewMode: false }; @@ -26,9 +27,12 @@ export const stylePresetSlice = createSlice({ searchTermChanged: (state, action: PayloadAction) => { state.searchTerm = action.payload; }, + viewModeChanged: (state, action: PayloadAction) => { + state.viewMode = action.payload; + }, }, }); -export const { isMenuOpenChanged, activeStylePresetChanged, searchTermChanged } = stylePresetSlice.actions; +export const { isMenuOpenChanged, activeStylePresetChanged, searchTermChanged, viewModeChanged } = stylePresetSlice.actions; export const selectStylePresetSlice = (state: RootState) => state.stylePreset; diff --git a/invokeai/frontend/web/src/features/stylePresets/store/types.ts b/invokeai/frontend/web/src/features/stylePresets/store/types.ts index 56358ac363..0fac9d9280 100644 --- a/invokeai/frontend/web/src/features/stylePresets/store/types.ts +++ b/invokeai/frontend/web/src/features/stylePresets/store/types.ts @@ -1,21 +1,17 @@ import type { StylePresetRecordWithImage } from "services/api/endpoints/stylePresets"; -import { ImageDTO } from "../../../services/api/types"; +import { StylePresetFormData } from "../components/StylePresetForm"; export type StylePresetModalState = { isModalOpen: boolean; - updatingStylePreset: StylePresetRecordWithImage | null; - createPresetFromImage: ImageDTO | null + updatingStylePresetId: string | null; + prefilledFormData: StylePresetFormData | null }; -export type StylePresetPrefillOptions = { - positivePrompt: string; - negativePrompt: string; - image: File; -} export type StylePresetState = { isMenuOpen: boolean; activeStylePreset: StylePresetRecordWithImage | null; - searchTerm: string + searchTerm: string; + viewMode: boolean; } diff --git a/invokeai/frontend/web/src/features/stylePresets/util/getViewModeChunks.tsx b/invokeai/frontend/web/src/features/stylePresets/util/getViewModeChunks.tsx new file mode 100644 index 0000000000..d6f79f72e4 --- /dev/null +++ b/invokeai/frontend/web/src/features/stylePresets/util/getViewModeChunks.tsx @@ -0,0 +1,15 @@ +import { PRESET_PLACEHOLDER } from '../hooks/usePresetModifiedPrompts'; + +export const getViewModeChunks = (currentPrompt: string, presetPrompt?: string) => { + if (!presetPrompt || !presetPrompt.length) { + return ['', currentPrompt, '']; + } + + const chunks = presetPrompt.split(PRESET_PLACEHOLDER); + + if (chunks.length === 1) { + return ['', currentPrompt, chunks[0]]; + } else { + return [chunks[0], currentPrompt, chunks[1]]; + } +}; diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelCanvas.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelCanvas.tsx index a2ffe8d497..d959c22471 100644 --- a/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelCanvas.tsx +++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelCanvas.tsx @@ -12,6 +12,8 @@ import { RefinerSettingsAccordion } from 'features/settingsAccordions/components import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; import type { CSSProperties } from 'react'; import { memo } from 'react'; +import { StylePresetMenu } from '../../../stylePresets/components/StylePresetMenu'; +import { StylePresetMenuTrigger } from '../../../stylePresets/components/StylePresetMenuTrigger'; const overlayScrollbarsStyles: CSSProperties = { height: '100%', @@ -20,23 +22,33 @@ const overlayScrollbarsStyles: CSSProperties = { const ParametersPanelCanvas = () => { const isSDXL = useAppSelector((s) => s.generation.model?.base === 'sdxl'); + const isMenuOpen = useAppSelector((s) => s.stylePreset.isMenuOpen); return ( + - - - - - - - - {isSDXL && } - - - + {isMenuOpen ? ( + + + + + + ) : ( + + + + + + + + {isSDXL && } + + + + )} 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 21de3fb998..5b466c6966 100644 --- a/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelTextToImage.tsx +++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelTextToImage.tsx @@ -1,5 +1,5 @@ import type { ChakraProps } from '@invoke-ai/ui-library'; -import { Box, Flex, Portal, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; +import { Box, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants'; import { ControlLayersPanelContent } from 'features/controlLayers/components/ControlLayersPanelContent'; @@ -69,22 +69,13 @@ const ParametersPanelTextToImage = () => { - - {isMenuOpen && ( - - - - - - - - )} - - {!isMenuOpen && ( + {isMenuOpen ? ( + + + + + + ) : ( diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelUpscale.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelUpscale.tsx index d31b07d5d8..fe85d7380a 100644 --- a/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelUpscale.tsx +++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelUpscale.tsx @@ -8,6 +8,9 @@ import { UpscaleSettingsAccordion } from 'features/settingsAccordions/components import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; import type { CSSProperties } from 'react'; import { memo } from 'react'; +import { useAppSelector } from '../../../../app/store/storeHooks'; +import { StylePresetMenu } from '../../../stylePresets/components/StylePresetMenu'; +import { StylePresetMenuTrigger } from '../../../stylePresets/components/StylePresetMenuTrigger'; const overlayScrollbarsStyles: CSSProperties = { height: '100%', @@ -15,19 +18,29 @@ const overlayScrollbarsStyles: CSSProperties = { }; const ParametersPanelUpscale = () => { + const isMenuOpen = useAppSelector((s) => s.stylePreset.isMenuOpen); return ( + - - - - - - - - + {isMenuOpen ? ( + + + + + + ) : ( + + + + + + + + + )}