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": "简体中文",
"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.",

View File

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

View File

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

View File

@ -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: <Icon as={MdTextFields} boxSize={6} />,
id: 'txt2img',
icon: <Icon as={MdTextFields} sx={tabIconStyles} />,
workarea: <TextToImageWorkarea />,
tooltip: 'Text To Image',
},
{
id: 'img2img',
icon: <Icon as={MdPhotoLibrary} boxSize={6} />,
icon: <Icon as={MdPhotoLibrary} sx={tabIconStyles} />,
workarea: <ImageToImageWorkarea />,
tooltip: 'Image To Image',
},
{
id: 'unifiedCanvas',
icon: <Icon as={MdGridOn} boxSize={6} />,
icon: <Icon as={MdGridOn} sx={tabIconStyles} />,
workarea: <UnifiedCanvasWorkarea />,
tooltip: 'Unified Canvas',
},
{
id: 'nodes',
icon: <Icon as={MdDeviceHub} boxSize={6} />,
icon: <Icon as={MdDeviceHub} sx={tabIconStyles} />,
workarea: <NodesWIP />,
tooltip: 'Nodes',
},
{
id: 'postProcessing',
icon: <Icon as={MdPhotoFilter} boxSize={6} />,
id: 'postprocessing',
icon: <Icon as={MdPhotoFilter} sx={tabIconStyles} />,
workarea: <PostProcessingWIP />,
tooltip: 'Post Processing',
},
{
id: 'training',
icon: <Icon as={MdFlashOn} boxSize={6} />,
icon: <Icon as={MdFlashOn} sx={tabIconStyles} />,
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() {
const activeTab = useAppSelector(activeTabIndexSelector);
@ -192,7 +151,9 @@ export default function InvokeTabs() {
placement="end"
>
<Tab>
<VisuallyHidden>{tab.tooltip}</VisuallyHidden>
<VisuallyHidden>
{String(t(`common.${tab.id}` as ResourceKey))}
</VisuallyHidden>
{tab.icon}
</Tab>
</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 (
<Tabs
isLazy

View File

@ -15,6 +15,27 @@ import InvokeAILogoComponent from 'features/system/components/InvokeAILogoCompon
import Scrollable from './common/Scrollable';
import PinParametersPanelButton from './PinParametersPanelButton';
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 = {
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 (
<ResizableDrawer
direction="left"
isResizable={false}
isOpen={shouldShowParametersPanel || shouldPinParametersPanel}
isResizable={isResizable || !shouldPinParametersPanel}
isOpen={shouldShowParametersPanel}
onClose={closeParametersPanel}
isPinned={shouldPinParametersPanel}
sx={{

View File

@ -17,10 +17,9 @@ import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { LangDirection } from './types';
import {
getHandleEnables,
getHandleStyles,
getMinMaxDimensions,
getResizableStyles,
getSlideDirection,
getStyles,
parseAndPadSize,
} from './util';
@ -65,26 +64,32 @@ const ResizableDrawer = ({
onResizeStart,
onResizeStop,
onResize,
handleWidth = '5px',
handleInteractWidth = '15px',
sx = {},
}: ResizableDrawerProps) => {
const langDirection = useTheme().direction as LangDirection;
const outsideClickRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState<number | string>(
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<number | string>(
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<number | string>(defaultWidth);
const [height, setHeight] = useState<number | string>(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 = ({
>
<ChakraResizeable
size={{
width,
height,
width: isResizable ? width : defaultWidth,
height: isResizable ? height : defaultHeight,
}}
enable={handleEnables}
handleStyles={handleStyles}
{...minMaxDimensions}
sx={{
borderColor: 'base.700',
borderColor: 'base.800',
p: isPinned ? 0 : 4,
bg: 'base.900',
height: 'full',
boxShadow: !isPinned ? '0 0 4rem 0 rgba(0, 0, 0, 0.8)' : '',
...resizableStyles,
...containerStyles,
...sx,
}}
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 { 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;

View File

@ -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)',
}}
></Box>
<Box
@ -77,8 +76,7 @@ const Scrollable = ({ children }: ScrollableProps) => {
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)',
}}
></Box>
</Box>

View File

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