diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 1d256e068e..22e6a089e2 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -49,10 +49,11 @@ "langSimplifiedChinese": "简体中文", "langUkranian": "Украї́нська", "langSpanish": "Español", - "text2img": "Text To Image", + "txt2img": "Text To Image", "img2img": "Image To Image", "unifiedCanvas": "Unified Canvas", "nodes": "Nodes", + "postprocessing": "Post Processing", "nodesDesc": "A node based system for the generation of images is under development currently. Stay tuned for updates about this amazing feature.", "postProcessing": "Post Processing", "postProcessDesc1": "Invoke AI offers a wide variety of post processing features. Image Upscaling and Face Restoration are already available in the WebUI. You can access them from the Advanced Options menu of the Text To Image and Image To Image tabs. You can also process images directly, using the image action buttons above the current image display or in the viewer.", diff --git a/invokeai/frontend/web/src/common/components/ImageUploader.tsx b/invokeai/frontend/web/src/common/components/ImageUploader.tsx index dac6bba5c0..c4f4dca9df 100644 --- a/invokeai/frontend/web/src/common/components/ImageUploader.tsx +++ b/invokeai/frontend/web/src/common/components/ImageUploader.tsx @@ -3,8 +3,8 @@ import { ImageUploaderTriggerContext } from 'app/contexts/ImageUploaderTriggerCo import { useAppDispatch, useAppSelector } from 'app/storeHooks'; import useImageUploader from 'common/hooks/useImageUploader'; import { uploadImage } from 'features/gallery/store/thunks/uploadImage'; -import { tabDict } from 'features/ui/components/InvokeTabs'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; +import { ResourceKey } from 'i18next'; import { KeyboardEvent, memo, @@ -135,7 +135,7 @@ const ImageUploader = (props: ImageUploaderProps) => { const overlaySecondaryText = ['img2img', 'unifiedCanvas'].includes( activeTabName ) - ? ` to ${tabDict[activeTabName as keyof typeof tabDict].tooltip}` + ? ` to ${String(t(`common.${activeTabName}` as ResourceKey))}` : ``; return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryPanel.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryPanel.tsx index f3555ef003..8b06ed4ebe 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryPanel.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryPanel.tsx @@ -34,7 +34,7 @@ const GALLERY_TAB_WIDTHS: Record< img2img: { galleryMinWidth: 200, galleryMaxWidth: 500 }, unifiedCanvas: { galleryMinWidth: 200, galleryMaxWidth: 200 }, nodes: { galleryMinWidth: 200, galleryMaxWidth: 500 }, - postprocess: { galleryMinWidth: 200, galleryMaxWidth: 500 }, + postprocessing: { galleryMinWidth: 200, galleryMaxWidth: 500 }, training: { galleryMinWidth: 200, galleryMaxWidth: 500 }, }; diff --git a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx index be220e6c3e..74a64ba405 100644 --- a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx +++ b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx @@ -1,4 +1,5 @@ import { + ChakraProps, Icon, Tab, TabList, @@ -36,90 +37,48 @@ import { ResourceKey } from 'i18next'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; export interface InvokeTabInfo { - id: string; + id: InvokeTabName; icon: ReactNode; workarea: ReactNode; - tooltip: string; } +const tabIconStyles: ChakraProps['sx'] = { + boxSize: 6, +}; + const tabInfo: InvokeTabInfo[] = [ { - id: 'text2img', - icon: , + id: 'txt2img', + icon: , workarea: , - tooltip: 'Text To Image', }, { id: 'img2img', - icon: , + icon: , workarea: , - tooltip: 'Image To Image', }, { id: 'unifiedCanvas', - icon: , + icon: , workarea: , - tooltip: 'Unified Canvas', }, { id: 'nodes', - icon: , + icon: , workarea: , - tooltip: 'Nodes', }, { - id: 'postProcessing', - icon: , + id: 'postprocessing', + icon: , workarea: , - tooltip: 'Post Processing', }, { id: 'training', - icon: , + icon: , workarea: , - tooltip: 'Training', }, ]; -export interface InvokeTabInfo2 { - icon: ReactNode; - workarea: ReactNode; - tooltip: string; -} - -export const tabDict: Record = { - txt2img: { - icon: , - workarea: , - tooltip: 'Text To Image', - }, - img2img: { - icon: , - workarea: , - tooltip: 'Image To Image', - }, - unifiedCanvas: { - icon: , - workarea: , - tooltip: 'Unified Canvas', - }, - nodes: { - icon: , - workarea: , - tooltip: 'Nodes', - }, - postprocess: { - icon: , - workarea: , - tooltip: 'Post Processing', - }, - training: { - icon: , - workarea: , - tooltip: 'Training', - }, -}; - export default function InvokeTabs() { const activeTab = useAppSelector(activeTabIndexSelector); @@ -192,7 +151,9 @@ export default function InvokeTabs() { placement="end" > - {tab.tooltip} + + {String(t(`common.${tab.id}` as ResourceKey))} + {tab.icon} @@ -206,6 +167,17 @@ export default function InvokeTabs() { [] ); + /** + * isLazy means the tabs are mounted and unmounted when changing them. There is a tradeoff here, + * as mounting is expensive, but so is retaining all tabs in the DOM at all times. + * + * Removing isLazy messes with the outside click watcher, which is used by ResizableDrawer. + * Because you have multiple handlers listening for an outside click, any click anywhere triggers + * the watcher for the hidden drawers, closing the open drawer. + * + * TODO: Add logic to the `useOutsideClick` in ResizableDrawer to enable it only for the active + * tab's drawer. + */ return ( { + const { shouldPinParametersPanel, shouldShowParametersPanel } = ui; + + return { + shouldPinParametersPanel, + shouldShowParametersPanel, + isResizable: activeTabName !== 'unifiedCanvas', + }; + }, + { + memoizeOptions: { + resultEqualityCheck: isEqual, + }, + } +); type ParametersPanelProps = { children: ReactNode; @@ -23,12 +44,8 @@ type ParametersPanelProps = { const ParametersPanel = ({ children }: ParametersPanelProps) => { const dispatch = useAppDispatch(); - const shouldPinParametersPanel = useAppSelector( - (state) => state.ui.shouldPinParametersPanel - ); - const shouldShowParametersPanel = useAppSelector( - (state) => state.ui.shouldShowParametersPanel - ); + const { shouldPinParametersPanel, shouldShowParametersPanel, isResizable } = + useAppSelector(parametersPanelSelector); const closeParametersPanel = () => { dispatch(setShouldShowParametersPanel(false)); @@ -66,8 +83,8 @@ const ParametersPanel = ({ children }: ParametersPanelProps) => { return ( { const langDirection = useTheme().direction as LangDirection; const outsideClickRef = useRef(null); - const [width, setWidth] = useState( - initialWidth ?? + const defaultWidth = useMemo( + () => + initialWidth ?? minWidth ?? - (['left', 'right'].includes(direction) ? 500 : '100vw') + (['left', 'right'].includes(direction) ? 500 : '100vw'), + [initialWidth, minWidth, direction] ); - const [height, setHeight] = useState( - initialHeight ?? + const defaultHeight = useMemo( + () => + initialHeight ?? minHeight ?? - (['top', 'bottom'].includes(direction) ? 500 : '100vh') + (['top', 'bottom'].includes(direction) ? 500 : '100vh'), + [initialHeight, minHeight, direction] ); + const [width, setWidth] = useState(defaultWidth); + + const [height, setHeight] = useState(defaultHeight); + useOutsideClick({ ref: outsideClickRef, handler: () => { @@ -101,40 +106,34 @@ const ResizableDrawer = ({ [isResizable, langDirection, direction] ); - const handleStyles = useMemo( - () => - getHandleStyles({ - handleEnables, - handleStyle: { - width: handleInteractWidth, - }, - }), - [handleEnables, handleInteractWidth] - ); - const minMaxDimensions = useMemo( () => getMinMaxDimensions({ direction, - minWidth: isPinned - ? parseAndPadSize(minWidth) - : parseAndPadSize(minWidth, 36), - maxWidth: isPinned - ? parseAndPadSize(maxWidth) - : parseAndPadSize(maxWidth, 36), - minHeight: isPinned - ? parseAndPadSize(minHeight) - : parseAndPadSize(minHeight, 36), - maxHeight: isPinned - ? parseAndPadSize(maxHeight) - : parseAndPadSize(maxHeight, 36), + minWidth: isResizable + ? parseAndPadSize(minWidth, 18) + : parseAndPadSize(minWidth), + maxWidth: isResizable + ? parseAndPadSize(maxWidth, 18) + : parseAndPadSize(maxWidth), + minHeight: isResizable + ? parseAndPadSize(minHeight, 18) + : parseAndPadSize(minHeight), + maxHeight: isResizable + ? parseAndPadSize(maxHeight, 18) + : parseAndPadSize(maxHeight), }), - [minWidth, maxWidth, minHeight, maxHeight, direction, isPinned] + [minWidth, maxWidth, minHeight, maxHeight, direction, isResizable] ); - const resizableStyles = useMemo( - () => getResizableStyles({ isPinned, direction, handleWidth, isResizable }), - [handleWidth, direction, isPinned, isResizable] + const { containerStyles, handleStyles } = useMemo( + () => + getStyles({ + isPinned, + isResizable, + direction, + }), + [isPinned, isResizable, direction] ); const slideDirection = useMemo( @@ -180,19 +179,19 @@ const ResizableDrawer = ({ > { diff --git a/invokeai/frontend/web/src/features/ui/components/common/ResizableDrawer/util.ts b/invokeai/frontend/web/src/features/ui/components/common/ResizableDrawer/util.ts index faa59034ca..b69da8cf1c 100644 --- a/invokeai/frontend/web/src/features/ui/components/common/ResizableDrawer/util.ts +++ b/invokeai/frontend/web/src/features/ui/components/common/ResizableDrawer/util.ts @@ -1,7 +1,7 @@ -import { ChakraProps, SlideDirection } from '@chakra-ui/react'; +import { SlideDirection } from '@chakra-ui/react'; import { AnimationProps } from 'framer-motion'; -import { Enable } from 're-resizable'; -import React from 'react'; +import { HandleStyles } from 're-resizable'; +import { CSSProperties } from 'react'; import { LangDirection } from './types'; export type GetHandleEnablesOptions = { @@ -9,7 +9,9 @@ export type GetHandleEnablesOptions = { langDirection: LangDirection; }; -// Determine handles to enable, taking into account language direction +/** + * Determine handles to enable. `re-resizable` doesn't handle RTL, so we have to do that here. + */ export const getHandleEnables = ({ direction, langDirection, @@ -26,7 +28,12 @@ export const getHandleEnables = ({ (langDirection !== 'rtl' && direction === 'right') || (langDirection === 'rtl' && direction === 'left'); - return { top, right, bottom, left }; + return { + top, + right, + bottom, + left, + }; }; export type GetDefaultSizeOptions = { @@ -86,34 +93,6 @@ export const getMinMaxDimensions = ({ }; }; -export type GetHandleStylesOptions = { - handleEnables: Enable; - handleStyle?: React.CSSProperties; -}; - -// Get handle styles, the enables already have language direction factored in so -// that does not need to be handled here -export const getHandleStyles = ({ - handleEnables, - handleStyle, -}: GetHandleStylesOptions) => { - if (!handleStyle) { - return {}; - } - - const top = handleEnables.top ? handleStyle : {}; - const right = handleEnables.right ? handleStyle : {}; - const bottom = handleEnables.bottom ? handleStyle : {}; - const left = handleEnables.left ? handleStyle : {}; - - return { - top, - right, - bottom, - left, - }; -}; - export type GetAnimationsOptions = { direction: SlideDirection; langDirection: LangDirection; @@ -176,43 +155,106 @@ export const getAnimations = ({ }; export type GetResizableStylesProps = { - direction: SlideDirection; - handleWidth: string | number; isPinned: boolean; isResizable: boolean; + direction: SlideDirection; }; -export const getResizableStyles = ({ - isPinned, // TODO add borderRadius for pinned? - direction, - handleWidth, +// Expand the handle hitbox +const HANDLE_INTERACT_PADDING = '0.75rem'; + +// Visible padding around handle +const HANDLE_PADDING = '1rem'; + +const HANDLE_WIDTH_PINNED = '2px'; +const HANDLE_WIDTH_UNPINNED = '5px'; + +// Get the styles for the container and handle. Do not need to handle langDirection here bc we use direction-agnostic CSS +export const getStyles = ({ + isPinned, isResizable, -}: GetResizableStylesProps): ChakraProps['sx'] => { - if (isPinned && !isResizable) { - return {}; + direction, +}: GetResizableStylesProps): { + containerStyles: CSSProperties; // technically this could be ChakraProps['sx'], but we cannot use this for HandleStyles so leave it as CSSProperties to be consistent + handleStyles: HandleStyles; +} => { + if (!isResizable) { + return { containerStyles: {}, handleStyles: {} }; } + const handleWidth = isPinned ? HANDLE_WIDTH_PINNED : HANDLE_WIDTH_UNPINNED; + + // Calculate the positioning offset of the handle hitbox so it is centered over the handle + const handleOffset = `calc((2 * ${HANDLE_INTERACT_PADDING} + ${handleWidth}) / -2)`; + if (direction === 'top') { return { - borderBottomWidth: handleWidth, - }; - } - - if (direction === 'right') { - return { borderInlineStartWidth: handleWidth }; - } - - if (direction === 'bottom') { - return { - borderTopWidth: handleWidth, + containerStyles: { + borderBottomWidth: handleWidth, + paddingBottom: HANDLE_PADDING, + }, + handleStyles: { + top: { + paddingTop: HANDLE_INTERACT_PADDING, + paddingBottom: HANDLE_INTERACT_PADDING, + bottom: handleOffset, + }, + }, }; } if (direction === 'left') { - return { borderInlineEndWidth: handleWidth }; + return { + containerStyles: { + borderInlineEndWidth: handleWidth, + paddingInlineEnd: HANDLE_PADDING, + }, + handleStyles: { + right: { + paddingInlineStart: HANDLE_INTERACT_PADDING, + paddingInlineEnd: HANDLE_INTERACT_PADDING, + insetInlineEnd: handleOffset, + }, + }, + }; } + + if (direction === 'bottom') { + return { + containerStyles: { + borderTopWidth: handleWidth, + paddingTop: HANDLE_PADDING, + }, + handleStyles: { + bottom: { + paddingTop: HANDLE_INTERACT_PADDING, + paddingBottom: HANDLE_INTERACT_PADDING, + top: handleOffset, + }, + }, + }; + } + + if (direction === 'right') { + return { + containerStyles: { + borderInlineStartWidth: handleWidth, + paddingInlineStart: HANDLE_PADDING, + }, + handleStyles: { + left: { + paddingInlineStart: HANDLE_INTERACT_PADDING, + paddingInlineEnd: HANDLE_INTERACT_PADDING, + insetInlineStart: handleOffset, + }, + }, + }; + } + + return { containerStyles: {}, handleStyles: {} }; }; +// Chakra's Slide does not handle langDirection, so we need to do it here export const getSlideDirection = ( direction: SlideDirection, langDirection: LangDirection @@ -238,6 +280,7 @@ export const getSlideDirection = ( return 'left'; }; +// Hack to correct the width of panels while pinned and unpinned, due to different padding in pinned vs unpinned export const parseAndPadSize = (size?: number, padding?: number) => { if (!size) { return undefined; diff --git a/invokeai/frontend/web/src/features/ui/components/common/Scrollable.tsx b/invokeai/frontend/web/src/features/ui/components/common/Scrollable.tsx index 5f9322eefe..50901fdf86 100644 --- a/invokeai/frontend/web/src/features/ui/components/common/Scrollable.tsx +++ b/invokeai/frontend/web/src/features/ui/components/common/Scrollable.tsx @@ -8,7 +8,7 @@ const scrollShadowBaseStyles: ChakraProps['sx'] = { height: 24, left: 0, pointerEvents: 'none', - transition: 'opacity 0.2s', + transition: 'opacity 0.2s ease-in-out', }; type ScrollableProps = { @@ -68,8 +68,7 @@ const Scrollable = ({ children }: ScrollableProps) => { sx={{ ...scrollShadowBaseStyles, bottom: 0, - boxShadow: - 'inset 0 -3.5rem 2rem -2rem var(--invokeai-colors-base-900)', + boxShadow: 'inset 0 -5rem 2rem -2rem var(--invokeai-colors-base-900)', }} > { sx={{ ...scrollShadowBaseStyles, top: 0, - boxShadow: - 'inset 0 3.5rem 2rem -2rem var(--invokeai-colors-base-900)', + boxShadow: 'inset 0 5rem 2rem -2rem var(--invokeai-colors-base-900)', }} > diff --git a/invokeai/frontend/web/src/features/ui/store/tabMap.ts b/invokeai/frontend/web/src/features/ui/store/tabMap.ts index 13a93f7f70..d30799a80f 100644 --- a/invokeai/frontend/web/src/features/ui/store/tabMap.ts +++ b/invokeai/frontend/web/src/features/ui/store/tabMap.ts @@ -3,7 +3,7 @@ export const tabMap = [ 'img2img', 'unifiedCanvas', 'nodes', - 'postprocess', + 'postprocessing', 'training', ] as const;