feat(ui): drawer almost done

TODO:
- hide while pinned
- lightbox interaction with gallery
This commit is contained in:
psychedelicious 2023-03-12 12:37:38 +11:00
parent b4d976f2db
commit 76cf2c61db
9 changed files with 199 additions and 169 deletions

View File

@ -49,10 +49,11 @@
"langSimplifiedChinese": "简体中文", "langSimplifiedChinese": "简体中文",
"langUkranian": "Украї́нська", "langUkranian": "Украї́нська",
"langSpanish": "Español", "langSpanish": "Español",
"text2img": "Text To Image", "txt2img": "Text To Image",
"img2img": "Image To Image", "img2img": "Image To Image",
"unifiedCanvas": "Unified Canvas", "unifiedCanvas": "Unified Canvas",
"nodes": "Nodes", "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.", "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", "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.", "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.",

View File

@ -3,8 +3,8 @@ import { ImageUploaderTriggerContext } from 'app/contexts/ImageUploaderTriggerCo
import { useAppDispatch, useAppSelector } from 'app/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/storeHooks';
import useImageUploader from 'common/hooks/useImageUploader'; import useImageUploader from 'common/hooks/useImageUploader';
import { uploadImage } from 'features/gallery/store/thunks/uploadImage'; import { uploadImage } from 'features/gallery/store/thunks/uploadImage';
import { tabDict } from 'features/ui/components/InvokeTabs';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { ResourceKey } from 'i18next';
import { import {
KeyboardEvent, KeyboardEvent,
memo, memo,
@ -135,7 +135,7 @@ const ImageUploader = (props: ImageUploaderProps) => {
const overlaySecondaryText = ['img2img', 'unifiedCanvas'].includes( const overlaySecondaryText = ['img2img', 'unifiedCanvas'].includes(
activeTabName activeTabName
) )
? ` to ${tabDict[activeTabName as keyof typeof tabDict].tooltip}` ? ` to ${String(t(`common.${activeTabName}` as ResourceKey))}`
: ``; : ``;
return ( return (

View File

@ -34,7 +34,7 @@ const GALLERY_TAB_WIDTHS: Record<
img2img: { galleryMinWidth: 200, galleryMaxWidth: 500 }, img2img: { galleryMinWidth: 200, galleryMaxWidth: 500 },
unifiedCanvas: { galleryMinWidth: 200, galleryMaxWidth: 200 }, unifiedCanvas: { galleryMinWidth: 200, galleryMaxWidth: 200 },
nodes: { galleryMinWidth: 200, galleryMaxWidth: 500 }, nodes: { galleryMinWidth: 200, galleryMaxWidth: 500 },
postprocess: { galleryMinWidth: 200, galleryMaxWidth: 500 }, postprocessing: { galleryMinWidth: 200, galleryMaxWidth: 500 },
training: { galleryMinWidth: 200, galleryMaxWidth: 500 }, training: { galleryMinWidth: 200, galleryMaxWidth: 500 },
}; };

View File

@ -1,4 +1,5 @@
import { import {
ChakraProps,
Icon, Icon,
Tab, Tab,
TabList, TabList,
@ -36,90 +37,48 @@ import { ResourceKey } from 'i18next';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
export interface InvokeTabInfo { export interface InvokeTabInfo {
id: string; id: InvokeTabName;
icon: ReactNode; icon: ReactNode;
workarea: ReactNode; workarea: ReactNode;
tooltip: string;
} }
const tabIconStyles: ChakraProps['sx'] = {
boxSize: 6,
};
const tabInfo: InvokeTabInfo[] = [ const tabInfo: InvokeTabInfo[] = [
{ {
id: 'text2img', id: 'txt2img',
icon: <Icon as={MdTextFields} boxSize={6} />, icon: <Icon as={MdTextFields} sx={tabIconStyles} />,
workarea: <TextToImageWorkarea />, workarea: <TextToImageWorkarea />,
tooltip: 'Text To Image',
}, },
{ {
id: 'img2img', id: 'img2img',
icon: <Icon as={MdPhotoLibrary} boxSize={6} />, icon: <Icon as={MdPhotoLibrary} sx={tabIconStyles} />,
workarea: <ImageToImageWorkarea />, workarea: <ImageToImageWorkarea />,
tooltip: 'Image To Image',
}, },
{ {
id: 'unifiedCanvas', id: 'unifiedCanvas',
icon: <Icon as={MdGridOn} boxSize={6} />, icon: <Icon as={MdGridOn} sx={tabIconStyles} />,
workarea: <UnifiedCanvasWorkarea />, workarea: <UnifiedCanvasWorkarea />,
tooltip: 'Unified Canvas',
}, },
{ {
id: 'nodes', id: 'nodes',
icon: <Icon as={MdDeviceHub} boxSize={6} />, icon: <Icon as={MdDeviceHub} sx={tabIconStyles} />,
workarea: <NodesWIP />, workarea: <NodesWIP />,
tooltip: 'Nodes',
}, },
{ {
id: 'postProcessing', id: 'postprocessing',
icon: <Icon as={MdPhotoFilter} boxSize={6} />, icon: <Icon as={MdPhotoFilter} sx={tabIconStyles} />,
workarea: <PostProcessingWIP />, workarea: <PostProcessingWIP />,
tooltip: 'Post Processing',
}, },
{ {
id: 'training', id: 'training',
icon: <Icon as={MdFlashOn} boxSize={6} />, icon: <Icon as={MdFlashOn} sx={tabIconStyles} />,
workarea: <TrainingWIP />, workarea: <TrainingWIP />,
tooltip: 'Training',
}, },
]; ];
export interface InvokeTabInfo2 {
icon: ReactNode;
workarea: ReactNode;
tooltip: string;
}
export const tabDict: Record<InvokeTabName, InvokeTabInfo2> = {
txt2img: {
icon: <Icon as={MdTextFields} boxSize={6} />,
workarea: <TextToImageWorkarea />,
tooltip: 'Text To Image',
},
img2img: {
icon: <Icon as={MdPhotoLibrary} boxSize={6} />,
workarea: <ImageToImageWorkarea />,
tooltip: 'Image To Image',
},
unifiedCanvas: {
icon: <Icon as={MdGridOn} boxSize={6} />,
workarea: <UnifiedCanvasWorkarea />,
tooltip: 'Unified Canvas',
},
nodes: {
icon: <Icon as={MdDeviceHub} boxSize={6} />,
workarea: <NodesWIP />,
tooltip: 'Nodes',
},
postprocess: {
icon: <Icon as={MdPhotoFilter} boxSize={6} />,
workarea: <PostProcessingWIP />,
tooltip: 'Post Processing',
},
training: {
icon: <Icon as={MdFlashOn} boxSize={6} />,
workarea: <TrainingWIP />,
tooltip: 'Training',
},
};
export default function InvokeTabs() { export default function InvokeTabs() {
const activeTab = useAppSelector(activeTabIndexSelector); const activeTab = useAppSelector(activeTabIndexSelector);
@ -192,7 +151,9 @@ export default function InvokeTabs() {
placement="end" placement="end"
> >
<Tab> <Tab>
<VisuallyHidden>{tab.tooltip}</VisuallyHidden> <VisuallyHidden>
{String(t(`common.${tab.id}` as ResourceKey))}
</VisuallyHidden>
{tab.icon} {tab.icon}
</Tab> </Tab>
</Tooltip> </Tooltip>
@ -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 ( return (
<Tabs <Tabs
isLazy isLazy

View File

@ -15,6 +15,27 @@ import InvokeAILogoComponent from 'features/system/components/InvokeAILogoCompon
import Scrollable from './common/Scrollable'; import Scrollable from './common/Scrollable';
import PinParametersPanelButton from './PinParametersPanelButton'; import PinParametersPanelButton from './PinParametersPanelButton';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { createSelector } from '@reduxjs/toolkit';
import { activeTabNameSelector, uiSelector } from '../store/uiSelectors';
import { isEqual } from 'lodash';
const parametersPanelSelector = createSelector(
[uiSelector, activeTabNameSelector],
(ui, activeTabName) => {
const { shouldPinParametersPanel, shouldShowParametersPanel } = ui;
return {
shouldPinParametersPanel,
shouldShowParametersPanel,
isResizable: activeTabName !== 'unifiedCanvas',
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
},
}
);
type ParametersPanelProps = { type ParametersPanelProps = {
children: ReactNode; children: ReactNode;
@ -23,12 +44,8 @@ type ParametersPanelProps = {
const ParametersPanel = ({ children }: ParametersPanelProps) => { const ParametersPanel = ({ children }: ParametersPanelProps) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const shouldPinParametersPanel = useAppSelector( const { shouldPinParametersPanel, shouldShowParametersPanel, isResizable } =
(state) => state.ui.shouldPinParametersPanel useAppSelector(parametersPanelSelector);
);
const shouldShowParametersPanel = useAppSelector(
(state) => state.ui.shouldShowParametersPanel
);
const closeParametersPanel = () => { const closeParametersPanel = () => {
dispatch(setShouldShowParametersPanel(false)); dispatch(setShouldShowParametersPanel(false));
@ -66,8 +83,8 @@ const ParametersPanel = ({ children }: ParametersPanelProps) => {
return ( return (
<ResizableDrawer <ResizableDrawer
direction="left" direction="left"
isResizable={false} isResizable={isResizable || !shouldPinParametersPanel}
isOpen={shouldShowParametersPanel || shouldPinParametersPanel} isOpen={shouldShowParametersPanel}
onClose={closeParametersPanel} onClose={closeParametersPanel}
isPinned={shouldPinParametersPanel} isPinned={shouldPinParametersPanel}
sx={{ sx={{

View File

@ -17,10 +17,9 @@ import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { LangDirection } from './types'; import { LangDirection } from './types';
import { import {
getHandleEnables, getHandleEnables,
getHandleStyles,
getMinMaxDimensions, getMinMaxDimensions,
getResizableStyles,
getSlideDirection, getSlideDirection,
getStyles,
parseAndPadSize, parseAndPadSize,
} from './util'; } from './util';
@ -65,26 +64,32 @@ const ResizableDrawer = ({
onResizeStart, onResizeStart,
onResizeStop, onResizeStop,
onResize, onResize,
handleWidth = '5px',
handleInteractWidth = '15px',
sx = {}, sx = {},
}: ResizableDrawerProps) => { }: ResizableDrawerProps) => {
const langDirection = useTheme().direction as LangDirection; const langDirection = useTheme().direction as LangDirection;
const outsideClickRef = useRef<HTMLDivElement>(null); const outsideClickRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState<number | string>( const defaultWidth = useMemo(
() =>
initialWidth ?? initialWidth ??
minWidth ?? minWidth ??
(['left', 'right'].includes(direction) ? 500 : '100vw') (['left', 'right'].includes(direction) ? 500 : '100vw'),
[initialWidth, minWidth, direction]
); );
const [height, setHeight] = useState<number | string>( const defaultHeight = useMemo(
() =>
initialHeight ?? initialHeight ??
minHeight ?? minHeight ??
(['top', 'bottom'].includes(direction) ? 500 : '100vh') (['top', 'bottom'].includes(direction) ? 500 : '100vh'),
[initialHeight, minHeight, direction]
); );
const [width, setWidth] = useState<number | string>(defaultWidth);
const [height, setHeight] = useState<number | string>(defaultHeight);
useOutsideClick({ useOutsideClick({
ref: outsideClickRef, ref: outsideClickRef,
handler: () => { handler: () => {
@ -101,40 +106,34 @@ const ResizableDrawer = ({
[isResizable, langDirection, direction] [isResizable, langDirection, direction]
); );
const handleStyles = useMemo(
() =>
getHandleStyles({
handleEnables,
handleStyle: {
width: handleInteractWidth,
},
}),
[handleEnables, handleInteractWidth]
);
const minMaxDimensions = useMemo( const minMaxDimensions = useMemo(
() => () =>
getMinMaxDimensions({ getMinMaxDimensions({
direction, direction,
minWidth: isPinned minWidth: isResizable
? parseAndPadSize(minWidth) ? parseAndPadSize(minWidth, 18)
: parseAndPadSize(minWidth, 36), : parseAndPadSize(minWidth),
maxWidth: isPinned maxWidth: isResizable
? parseAndPadSize(maxWidth) ? parseAndPadSize(maxWidth, 18)
: parseAndPadSize(maxWidth, 36), : parseAndPadSize(maxWidth),
minHeight: isPinned minHeight: isResizable
? parseAndPadSize(minHeight) ? parseAndPadSize(minHeight, 18)
: parseAndPadSize(minHeight, 36), : parseAndPadSize(minHeight),
maxHeight: isPinned maxHeight: isResizable
? parseAndPadSize(maxHeight) ? parseAndPadSize(maxHeight, 18)
: parseAndPadSize(maxHeight, 36), : parseAndPadSize(maxHeight),
}), }),
[minWidth, maxWidth, minHeight, maxHeight, direction, isPinned] [minWidth, maxWidth, minHeight, maxHeight, direction, isResizable]
); );
const resizableStyles = useMemo( const { containerStyles, handleStyles } = useMemo(
() => getResizableStyles({ isPinned, direction, handleWidth, isResizable }), () =>
[handleWidth, direction, isPinned, isResizable] getStyles({
isPinned,
isResizable,
direction,
}),
[isPinned, isResizable, direction]
); );
const slideDirection = useMemo( const slideDirection = useMemo(
@ -180,19 +179,19 @@ const ResizableDrawer = ({
> >
<ChakraResizeable <ChakraResizeable
size={{ size={{
width, width: isResizable ? width : defaultWidth,
height, height: isResizable ? height : defaultHeight,
}} }}
enable={handleEnables} enable={handleEnables}
handleStyles={handleStyles} handleStyles={handleStyles}
{...minMaxDimensions} {...minMaxDimensions}
sx={{ sx={{
borderColor: 'base.700', borderColor: 'base.800',
p: isPinned ? 0 : 4, p: isPinned ? 0 : 4,
bg: 'base.900', bg: 'base.900',
height: 'full', height: 'full',
boxShadow: !isPinned ? '0 0 4rem 0 rgba(0, 0, 0, 0.8)' : '', boxShadow: !isPinned ? '0 0 4rem 0 rgba(0, 0, 0, 0.8)' : '',
...resizableStyles, ...containerStyles,
...sx, ...sx,
}} }}
onResizeStart={(event, direction, elementRef) => { onResizeStart={(event, direction, elementRef) => {

View File

@ -1,7 +1,7 @@
import { ChakraProps, SlideDirection } from '@chakra-ui/react'; import { SlideDirection } from '@chakra-ui/react';
import { AnimationProps } from 'framer-motion'; import { AnimationProps } from 'framer-motion';
import { Enable } from 're-resizable'; import { HandleStyles } from 're-resizable';
import React from 'react'; import { CSSProperties } from 'react';
import { LangDirection } from './types'; import { LangDirection } from './types';
export type GetHandleEnablesOptions = { export type GetHandleEnablesOptions = {
@ -9,7 +9,9 @@ export type GetHandleEnablesOptions = {
langDirection: LangDirection; 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 = ({ export const getHandleEnables = ({
direction, direction,
langDirection, langDirection,
@ -26,7 +28,12 @@ export const getHandleEnables = ({
(langDirection !== 'rtl' && direction === 'right') || (langDirection !== 'rtl' && direction === 'right') ||
(langDirection === 'rtl' && direction === 'left'); (langDirection === 'rtl' && direction === 'left');
return { top, right, bottom, left }; return {
top,
right,
bottom,
left,
};
}; };
export type GetDefaultSizeOptions = { 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 = { export type GetAnimationsOptions = {
direction: SlideDirection; direction: SlideDirection;
langDirection: LangDirection; langDirection: LangDirection;
@ -176,43 +155,106 @@ export const getAnimations = ({
}; };
export type GetResizableStylesProps = { export type GetResizableStylesProps = {
direction: SlideDirection;
handleWidth: string | number;
isPinned: boolean; isPinned: boolean;
isResizable: boolean; isResizable: boolean;
direction: SlideDirection;
}; };
export const getResizableStyles = ({ // Expand the handle hitbox
isPinned, // TODO add borderRadius for pinned? const HANDLE_INTERACT_PADDING = '0.75rem';
direction,
handleWidth, // 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, isResizable,
}: GetResizableStylesProps): ChakraProps['sx'] => { direction,
if (isPinned && !isResizable) { }: GetResizableStylesProps): {
return {}; 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') { if (direction === 'top') {
return { return {
containerStyles: {
borderBottomWidth: handleWidth, borderBottomWidth: handleWidth,
}; paddingBottom: HANDLE_PADDING,
} },
handleStyles: {
if (direction === 'right') { top: {
return { borderInlineStartWidth: handleWidth }; paddingTop: HANDLE_INTERACT_PADDING,
} paddingBottom: HANDLE_INTERACT_PADDING,
bottom: handleOffset,
if (direction === 'bottom') { },
return { },
borderTopWidth: handleWidth,
}; };
} }
if (direction === 'left') { 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 = ( export const getSlideDirection = (
direction: SlideDirection, direction: SlideDirection,
langDirection: LangDirection langDirection: LangDirection
@ -238,6 +280,7 @@ export const getSlideDirection = (
return 'left'; 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) => { export const parseAndPadSize = (size?: number, padding?: number) => {
if (!size) { if (!size) {
return undefined; return undefined;

View File

@ -8,7 +8,7 @@ const scrollShadowBaseStyles: ChakraProps['sx'] = {
height: 24, height: 24,
left: 0, left: 0,
pointerEvents: 'none', pointerEvents: 'none',
transition: 'opacity 0.2s', transition: 'opacity 0.2s ease-in-out',
}; };
type ScrollableProps = { type ScrollableProps = {
@ -68,8 +68,7 @@ const Scrollable = ({ children }: ScrollableProps) => {
sx={{ sx={{
...scrollShadowBaseStyles, ...scrollShadowBaseStyles,
bottom: 0, bottom: 0,
boxShadow: boxShadow: 'inset 0 -5rem 2rem -2rem var(--invokeai-colors-base-900)',
'inset 0 -3.5rem 2rem -2rem var(--invokeai-colors-base-900)',
}} }}
></Box> ></Box>
<Box <Box
@ -77,8 +76,7 @@ const Scrollable = ({ children }: ScrollableProps) => {
sx={{ sx={{
...scrollShadowBaseStyles, ...scrollShadowBaseStyles,
top: 0, top: 0,
boxShadow: boxShadow: 'inset 0 5rem 2rem -2rem var(--invokeai-colors-base-900)',
'inset 0 3.5rem 2rem -2rem var(--invokeai-colors-base-900)',
}} }}
></Box> ></Box>
</Box> </Box>

View File

@ -3,7 +3,7 @@ export const tabMap = [
'img2img', 'img2img',
'unifiedCanvas', 'unifiedCanvas',
'nodes', 'nodes',
'postprocess', 'postprocessing',
'training', 'training',
] as const; ] as const;