feat(ui): remove floating panels, move all to resizable panels

There is a console error we can ignore when toggling gallery panel on canvas - this will be resolved in the next release of the resizable library
This commit is contained in:
psychedelicious
2023-08-23 14:17:18 +10:00
parent 6d10e40c9b
commit 73318c2847
39 changed files with 682 additions and 1507 deletions

View File

@ -90,7 +90,6 @@
"overlayscrollbars-react": "^0.5.0", "overlayscrollbars-react": "^0.5.0",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"query-string": "^8.1.0", "query-string": "^8.1.0",
"re-resizable": "^6.9.11",
"react": "^18.2.0", "react": "^18.2.0",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",

View File

@ -19,7 +19,7 @@
"toggleAutoscroll": "Toggle autoscroll", "toggleAutoscroll": "Toggle autoscroll",
"toggleLogViewer": "Toggle Log Viewer", "toggleLogViewer": "Toggle Log Viewer",
"showGallery": "Show Gallery", "showGallery": "Show Gallery",
"showOptionsPanel": "Show Options Panel", "showOptionsPanel": "Show Side Panel",
"menu": "Menu" "menu": "Menu"
}, },
"common": { "common": {
@ -577,7 +577,7 @@
"resetWebUI": "Reset Web UI", "resetWebUI": "Reset Web UI",
"resetWebUIDesc1": "Resetting the web UI only resets the browser's local cache of your images and remembered settings. It does not delete any images from disk.", "resetWebUIDesc1": "Resetting the web UI only resets the browser's local cache of your images and remembered settings. It does not delete any images from disk.",
"resetWebUIDesc2": "If images aren't showing up in the gallery or something else isn't working, please try resetting before submitting an issue on GitHub.", "resetWebUIDesc2": "If images aren't showing up in the gallery or something else isn't working, please try resetting before submitting an issue on GitHub.",
"resetComplete": "Web UI has been reset. Refresh the page to reload.", "resetComplete": "Web UI has been reset.",
"consoleLogLevel": "Log Level", "consoleLogLevel": "Log Level",
"shouldLogToConsole": "Console Logging", "shouldLogToConsole": "Console Logging",
"developer": "Developer", "developer": "Developer",

View File

@ -1,4 +1,4 @@
import { Flex, Grid, Portal } from '@chakra-ui/react'; import { Flex, Grid } from '@chakra-ui/react';
import { useLogger } from 'app/logging/useLogger'; import { useLogger } from 'app/logging/useLogger';
import { appStarted } from 'app/store/middleware/listenerMiddleware/listeners/appStarted'; import { appStarted } from 'app/store/middleware/listenerMiddleware/listeners/appStarted';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
@ -6,21 +6,17 @@ import { PartialAppConfig } from 'app/types/invokeai';
import ImageUploader from 'common/components/ImageUploader'; import ImageUploader from 'common/components/ImageUploader';
import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal'; import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal';
import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal'; import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal';
import GalleryDrawer from 'features/gallery/components/GalleryPanel';
import SiteHeader from 'features/system/components/SiteHeader'; import SiteHeader from 'features/system/components/SiteHeader';
import { configChanged } from 'features/system/store/configSlice'; import { configChanged } from 'features/system/store/configSlice';
import { languageSelector } from 'features/system/store/systemSelectors'; import { languageSelector } from 'features/system/store/systemSelectors';
import FloatingGalleryButton from 'features/ui/components/FloatingGalleryButton';
import FloatingParametersPanelButtons from 'features/ui/components/FloatingParametersPanelButtons';
import InvokeTabs from 'features/ui/components/InvokeTabs'; import InvokeTabs from 'features/ui/components/InvokeTabs';
import ParametersDrawer from 'features/ui/components/ParametersDrawer';
import i18n from 'i18n'; import i18n from 'i18n';
import { size } from 'lodash-es'; import { size } from 'lodash-es';
import { ReactNode, memo, useCallback, useEffect } from 'react'; import { ReactNode, memo, useCallback, useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import AppErrorBoundaryFallback from './AppErrorBoundaryFallback';
import GlobalHotkeys from './GlobalHotkeys'; import GlobalHotkeys from './GlobalHotkeys';
import Toaster from './Toaster'; import Toaster from './Toaster';
import AppErrorBoundaryFallback from './AppErrorBoundaryFallback';
const DEFAULT_CONFIG = {}; const DEFAULT_CONFIG = {};
@ -83,15 +79,6 @@ const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => {
</Flex> </Flex>
</Grid> </Grid>
</ImageUploader> </ImageUploader>
<GalleryDrawer />
<ParametersDrawer />
<Portal>
<FloatingParametersPanelButtons />
</Portal>
<Portal>
<FloatingGalleryButton />
</Portal>
</Grid> </Grid>
<DeleteImageModal /> <DeleteImageModal />
<ChangeBoardModal /> <ChangeBoardModal />

View File

@ -1,30 +1,21 @@
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { import {
ctrlKeyPressed, ctrlKeyPressed,
metaKeyPressed, metaKeyPressed,
shiftKeyPressed, shiftKeyPressed,
} from 'features/ui/store/hotkeysSlice'; } from 'features/ui/store/hotkeysSlice';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { setActiveTab } from 'features/ui/store/uiSlice';
import {
setActiveTab,
toggleGalleryPanel,
toggleParametersPanel,
togglePinGalleryPanel,
togglePinParametersPanel,
} from 'features/ui/store/uiSlice';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import React, { memo } from 'react'; import React, { memo } from 'react';
import { isHotkeyPressed, useHotkeys } from 'react-hotkeys-hook'; import { isHotkeyPressed, useHotkeys } from 'react-hotkeys-hook';
const globalHotkeysSelector = createSelector( const globalHotkeysSelector = createSelector(
[stateSelector], [stateSelector],
({ hotkeys, ui }) => { ({ hotkeys }) => {
const { shift, ctrl, meta } = hotkeys; const { shift, ctrl, meta } = hotkeys;
const { shouldPinParametersPanel, shouldPinGallery } = ui; return { shift, ctrl, meta };
return { shift, ctrl, meta, shouldPinGallery, shouldPinParametersPanel };
}, },
{ {
memoizeOptions: { memoizeOptions: {
@ -41,9 +32,7 @@ const globalHotkeysSelector = createSelector(
*/ */
const GlobalHotkeys: React.FC = () => { const GlobalHotkeys: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { shift, ctrl, meta, shouldPinParametersPanel, shouldPinGallery } = const { shift, ctrl, meta } = useAppSelector(globalHotkeysSelector);
useAppSelector(globalHotkeysSelector);
const activeTabName = useAppSelector(activeTabNameSelector);
useHotkeys( useHotkeys(
'*', '*',
@ -68,34 +57,6 @@ const GlobalHotkeys: React.FC = () => {
[shift, ctrl, meta] [shift, ctrl, meta]
); );
useHotkeys('o', () => {
dispatch(toggleParametersPanel());
if (activeTabName === 'unifiedCanvas' && shouldPinParametersPanel) {
dispatch(requestCanvasRescale());
}
});
useHotkeys(['shift+o'], () => {
dispatch(togglePinParametersPanel());
if (activeTabName === 'unifiedCanvas') {
dispatch(requestCanvasRescale());
}
});
useHotkeys('g', () => {
dispatch(toggleGalleryPanel());
if (activeTabName === 'unifiedCanvas' && shouldPinGallery) {
dispatch(requestCanvasRescale());
}
});
useHotkeys(['shift+g'], () => {
dispatch(togglePinGalleryPanel());
if (activeTabName === 'unifiedCanvas') {
dispatch(requestCanvasRescale());
}
});
useHotkeys('1', () => { useHotkeys('1', () => {
dispatch(setActiveTab('txt2img')); dispatch(setActiveTab('txt2img'));
}); });
@ -112,6 +73,10 @@ const GlobalHotkeys: React.FC = () => {
dispatch(setActiveTab('nodes')); dispatch(setActiveTab('nodes'));
}); });
useHotkeys('5', () => {
dispatch(setActiveTab('modelManager'));
});
return null; return null;
}; };

View File

@ -83,10 +83,6 @@ const ControlNet = (props: ControlNetProps) => {
p: 3, p: 3,
borderRadius: 'base', borderRadius: 'base',
position: 'relative', position: 'relative',
bg: 'base.200',
_dark: {
bg: 'base.850',
},
}} }}
> >
<Flex sx={{ gap: 2, alignItems: 'center' }}> <Flex sx={{ gap: 2, alignItems: 'center' }}>

View File

@ -1,119 +0,0 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { gallerySelector } from 'features/gallery/store/gallerySelectors';
import { setGalleryImageMinimumWidth } from 'features/gallery/store/gallerySlice';
import { clamp, isEqual } from 'lodash-es';
import { useHotkeys } from 'react-hotkeys-hook';
import { createSelector } from '@reduxjs/toolkit';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import ResizableDrawer from 'features/ui/components/common/ResizableDrawer/ResizableDrawer';
import {
activeTabNameSelector,
uiSelector,
} from 'features/ui/store/uiSelectors';
import { setShouldShowGallery } from 'features/ui/store/uiSlice';
import { memo } from 'react';
import ImageGalleryContent from './ImageGalleryContent';
const selector = createSelector(
[activeTabNameSelector, uiSelector, gallerySelector, isStagingSelector],
(activeTabName, ui, gallery, isStaging) => {
const { shouldPinGallery, shouldShowGallery } = ui;
const { galleryImageMinimumWidth } = gallery;
return {
activeTabName,
isStaging,
shouldPinGallery,
shouldShowGallery,
galleryImageMinimumWidth,
isResizable: activeTabName !== 'unifiedCanvas',
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
},
}
);
const GalleryDrawer = () => {
const dispatch = useAppDispatch();
const {
shouldPinGallery,
shouldShowGallery,
galleryImageMinimumWidth,
// activeTabName,
// isStaging,
// isResizable,
} = useAppSelector(selector);
const handleCloseGallery = () => {
dispatch(setShouldShowGallery(false));
shouldPinGallery && dispatch(requestCanvasRescale());
};
useHotkeys(
'esc',
() => {
dispatch(setShouldShowGallery(false));
},
{
enabled: () => !shouldPinGallery,
preventDefault: true,
},
[shouldPinGallery]
);
const IMAGE_SIZE_STEP = 32;
useHotkeys(
'shift+up',
() => {
if (galleryImageMinimumWidth < 256) {
const newMinWidth = clamp(
galleryImageMinimumWidth + IMAGE_SIZE_STEP,
32,
256
);
dispatch(setGalleryImageMinimumWidth(newMinWidth));
}
},
[galleryImageMinimumWidth]
);
useHotkeys(
'shift+down',
() => {
if (galleryImageMinimumWidth > 32) {
const newMinWidth = clamp(
galleryImageMinimumWidth - IMAGE_SIZE_STEP,
32,
256
);
dispatch(setGalleryImageMinimumWidth(newMinWidth));
}
},
[galleryImageMinimumWidth]
);
if (shouldPinGallery) {
return null;
}
return (
<ResizableDrawer
direction="right"
isResizable={true}
isOpen={shouldShowGallery}
onClose={handleCloseGallery}
minWidth={400}
>
<ImageGalleryContent />
</ResizableDrawer>
);
};
export default memo(GalleryDrawer);

View File

@ -1,45 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIIconButton from 'common/components/IAIIconButton';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { togglePinGalleryPanel } from 'features/ui/store/uiSlice';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
const selector = createSelector(
[stateSelector],
(state) => {
const { shouldPinGallery } = state.ui;
return {
shouldPinGallery,
};
},
defaultSelectorOptions
);
const GalleryPinButton = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { shouldPinGallery } = useAppSelector(selector);
const handleSetShouldPinGallery = () => {
dispatch(togglePinGalleryPanel());
dispatch(requestCanvasRescale());
};
return (
<IAIIconButton
size="sm"
aria-label={t('gallery.pinGallery')}
tooltip={`${t('gallery.pinGallery')} (Shift+G)`}
onClick={handleSetShouldPinGallery}
icon={shouldPinGallery ? <BsPinAngleFill /> : <BsPinAngle />}
/>
);
};
export default memo(GalleryPinButton);

View File

@ -18,7 +18,6 @@ import { FaImages, FaServer } from 'react-icons/fa';
import { galleryViewChanged } from '../store/gallerySlice'; import { galleryViewChanged } from '../store/gallerySlice';
import BoardsList from './Boards/BoardsList/BoardsList'; import BoardsList from './Boards/BoardsList/BoardsList';
import GalleryBoardName from './GalleryBoardName'; import GalleryBoardName from './GalleryBoardName';
import GalleryPinButton from './GalleryPinButton';
import GallerySettingsPopover from './GallerySettingsPopover'; import GallerySettingsPopover from './GallerySettingsPopover';
import GalleryImageGrid from './ImageGrid/GalleryImageGrid'; import GalleryImageGrid from './ImageGrid/GalleryImageGrid';
@ -75,7 +74,6 @@ const ImageGalleryContent = () => {
onToggle={onToggleBoardList} onToggle={onToggleBoardList}
/> />
<GallerySettingsPopover /> <GallerySettingsPopover />
<GalleryPinButton />
</Flex> </Flex>
<Box> <Box>
<BoardsList isOpen={isBoardListOpen} /> <BoardsList isOpen={isBoardListOpen} />

View File

@ -1,109 +1,85 @@
import { Flex } from '@chakra-ui/react'; import { Flex } from '@chakra-ui/react';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
import { memo, useState } from 'react';
import { MdDeviceHub } from 'react-icons/md';
import { Panel, PanelGroup } from 'react-resizable-panels';
import 'reactflow/dist/style.css';
import NodeEditorPanelGroup from './sidePanel/NodeEditorPanelGroup';
import { Flow } from './flow/Flow';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { memo } from 'react';
import { MdDeviceHub } from 'react-icons/md';
import 'reactflow/dist/style.css';
import AddNodePopover from './flow/AddNodePopover/AddNodePopover'; import AddNodePopover from './flow/AddNodePopover/AddNodePopover';
import { Flow } from './flow/Flow';
const NodeEditor = () => { const NodeEditor = () => {
const [isPanelCollapsed, setIsPanelCollapsed] = useState(false);
const isReady = useAppSelector((state) => state.nodes.isReady); const isReady = useAppSelector((state) => state.nodes.isReady);
return ( return (
<PanelGroup <Flex
id="node-editor" layerStyle="first"
autoSaveId="node-editor" sx={{
direction="horizontal" position: 'relative',
style={{ height: '100%', width: '100%' }} width: 'full',
height: 'full',
borderRadius: 'base',
alignItems: 'center',
justifyContent: 'center',
}}
> >
<Panel <AnimatePresence>
id="node-editor-panel-group" {isReady && (
collapsible <motion.div
onCollapse={setIsPanelCollapsed} initial={{
minSize={25} opacity: 0,
> }}
<NodeEditorPanelGroup /> animate={{
</Panel> opacity: 1,
<ResizeHandle transition: { duration: 0.2 },
collapsedDirection={isPanelCollapsed ? 'left' : undefined} }}
/> exit={{
<Panel id="node-editor-content"> opacity: 0,
<Flex transition: { duration: 0.2 },
layerStyle="first" }}
sx={{ style={{ position: 'relative', width: '100%', height: '100%' }}
position: 'relative', >
width: 'full', <Flow />
height: 'full', <AddNodePopover />
borderRadius: 'base', </motion.div>
alignItems: 'center', )}
justifyContent: 'center', </AnimatePresence>
}} <AnimatePresence>
> {!isReady && (
<AnimatePresence> <motion.div
{isReady && ( initial={{
<motion.div opacity: 0,
initial={{ }}
opacity: 0, animate={{
}} opacity: 1,
animate={{ transition: { duration: 0.2 },
opacity: 1, }}
transition: { duration: 0.2 }, exit={{
}} opacity: 0,
exit={{ transition: { duration: 0.2 },
opacity: 0, }}
transition: { duration: 0.2 }, style={{ position: 'absolute', width: '100%', height: '100%' }}
}} >
style={{ position: 'relative', width: '100%', height: '100%' }} <Flex
> layerStyle="first"
<Flow /> sx={{
<AddNodePopover /> position: 'relative',
</motion.div> width: 'full',
)} height: 'full',
</AnimatePresence> borderRadius: 'base',
<AnimatePresence> alignItems: 'center',
{!isReady && ( justifyContent: 'center',
<motion.div pointerEvents: 'none',
initial={{ }}
opacity: 0, >
}} <IAINoContentFallback
animate={{ label="Loading Nodes..."
opacity: 1, icon={MdDeviceHub}
transition: { duration: 0.2 }, />
}} </Flex>
exit={{ </motion.div>
opacity: 0, )}
transition: { duration: 0.2 }, </AnimatePresence>
}} </Flex>
style={{ position: 'absolute', width: '100%', height: '100%' }}
>
<Flex
layerStyle="first"
sx={{
position: 'relative',
width: 'full',
height: 'full',
borderRadius: 'base',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<IAINoContentFallback
label="Loading Nodes..."
icon={MdDeviceHub}
/>
</Flex>
</motion.div>
)}
</AnimatePresence>
</Flex>
</Panel>
</PanelGroup>
); );
}; };

View File

@ -1,23 +1,36 @@
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle'; import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
import { memo, useState } from 'react'; import { usePanelStorage } from 'features/ui/hooks/usePanelStorage';
import { Panel, PanelGroup } from 'react-resizable-panels'; import { memo, useCallback, useRef, useState } from 'react';
import {
ImperativePanelGroupHandle,
Panel,
PanelGroup,
} from 'react-resizable-panels';
import 'reactflow/dist/style.css'; import 'reactflow/dist/style.css';
import WorkflowPanel from './workflow/WorkflowPanel';
import InspectorPanel from './inspector/InspectorPanel'; import InspectorPanel from './inspector/InspectorPanel';
import WorkflowPanel from './workflow/WorkflowPanel';
const NodeEditorPanelGroup = () => { const NodeEditorPanelGroup = () => {
const [isTopPanelCollapsed, setIsTopPanelCollapsed] = useState(false); const [isTopPanelCollapsed, setIsTopPanelCollapsed] = useState(false);
const [isBottomPanelCollapsed, setIsBottomPanelCollapsed] = useState(false); const [isBottomPanelCollapsed, setIsBottomPanelCollapsed] = useState(false);
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
const panelStorage = usePanelStorage();
const handleDoubleClickHandle = useCallback(() => {
if (!panelGroupRef.current) {
return;
}
panelGroupRef.current.setLayout([50, 50]);
}, []);
return ( return (
<PanelGroup <PanelGroup
id="node-editor-panel_group" ref={panelGroupRef}
autoSaveId="node-editor-panel_group" id="workflow-panel-group"
direction="vertical" direction="vertical"
style={{ height: '100%', width: '100%' }} style={{ height: '100%', width: '100%' }}
storage={panelStorage}
> >
<Panel <Panel
id="node-editor-panel_group_workflow" id="workflow"
collapsible collapsible
onCollapse={setIsTopPanelCollapsed} onCollapse={setIsTopPanelCollapsed}
minSize={25} minSize={25}
@ -26,6 +39,7 @@ const NodeEditorPanelGroup = () => {
</Panel> </Panel>
<ResizeHandle <ResizeHandle
direction="vertical" direction="vertical"
onDoubleClick={handleDoubleClickHandle}
collapsedDirection={ collapsedDirection={
isTopPanelCollapsed isTopPanelCollapsed
? 'top' ? 'top'
@ -35,7 +49,7 @@ const NodeEditorPanelGroup = () => {
} }
/> />
<Panel <Panel
id="node-editor-panel_group_inspector" id="inspector"
collapsible collapsible
onCollapse={setIsBottomPanelCollapsed} onCollapse={setIsBottomPanelCollapsed}
minSize={25} minSize={25}

View File

@ -23,9 +23,8 @@ import { useFeatureStatus } from '../../../../system/hooks/useFeatureStatus';
const promptInputSelector = createSelector( const promptInputSelector = createSelector(
[stateSelector, activeTabNameSelector], [stateSelector, activeTabNameSelector],
({ generation, ui }, activeTabName) => { ({ generation }, activeTabName) => {
return { return {
shouldPinParametersPanel: ui.shouldPinParametersPanel,
prompt: generation.positivePrompt, prompt: generation.positivePrompt,
activeTabName, activeTabName,
}; };
@ -42,8 +41,7 @@ const promptInputSelector = createSelector(
*/ */
const ParamPositiveConditioning = () => { const ParamPositiveConditioning = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { prompt, shouldPinParametersPanel, activeTabName } = const { prompt, activeTabName } = useAppSelector(promptInputSelector);
useAppSelector(promptInputSelector);
const isReady = useIsReadyToInvoke(); const isReady = useIsReadyToInvoke();
const promptRef = useRef<HTMLTextAreaElement>(null); const promptRef = useRef<HTMLTextAreaElement>(null);
const { isOpen, onClose, onOpen } = useDisclosure(); const { isOpen, onClose, onOpen } = useDisclosure();
@ -148,7 +146,7 @@ const ParamPositiveConditioning = () => {
<Box <Box
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: shouldPinParametersPanel ? 5 : 0, top: 0,
insetInlineEnd: 0, insetInlineEnd: 0,
}} }}
> >

View File

@ -55,11 +55,11 @@ const selector = createSelector(
interface InvokeButton interface InvokeButton
extends Omit<IAIButtonProps | IAIIconButtonProps, 'aria-label'> { extends Omit<IAIButtonProps | IAIIconButtonProps, 'aria-label'> {
iconButton?: boolean; asIconButton?: boolean;
} }
export default function InvokeButton(props: InvokeButton) { export default function InvokeButton(props: InvokeButton) {
const { iconButton = false, ...rest } = props; const { asIconButton = false, sx, ...rest } = props;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { isReady, isProcessing } = useIsReadyToInvoke(); const { isReady, isProcessing } = useIsReadyToInvoke();
const { activeTabName } = useAppSelector(selector); const { activeTabName } = useAppSelector(selector);
@ -87,21 +87,22 @@ export default function InvokeButton(props: InvokeButton) {
<Box style={{ position: 'relative' }}> <Box style={{ position: 'relative' }}>
{!isReady && ( {!isReady && (
<Box <Box
borderRadius="base" sx={{
style={{
position: 'absolute', position: 'absolute',
bottom: '0', bottom: '0',
left: '0', left: '0',
right: '0', right: '0',
height: '100%', height: '100%',
overflow: 'clip', overflow: 'clip',
borderRadius: 'base',
...sx,
}} }}
{...rest} {...rest}
> >
<ProgressBar /> <ProgressBar />
</Box> </Box>
)} )}
{iconButton ? ( {asIconButton ? (
<IAIIconButton <IAIIconButton
aria-label={t('parameters.invoke')} aria-label={t('parameters.invoke')}
type="submit" type="submit"
@ -112,12 +113,13 @@ export default function InvokeButton(props: InvokeButton) {
colorScheme="accent" colorScheme="accent"
isLoading={isProcessing} isLoading={isProcessing}
id="invoke-button" id="invoke-button"
{...rest}
sx={{ sx={{
w: 'full', w: 'full',
flexGrow: 1, flexGrow: 1,
...(isProcessing ? IN_PROGRESS_STYLES : {}), ...(isProcessing ? IN_PROGRESS_STYLES : {}),
...sx,
}} }}
{...rest}
/> />
) : ( ) : (
<IAIButton <IAIButton
@ -131,13 +133,14 @@ export default function InvokeButton(props: InvokeButton) {
leftIcon={isProcessing ? undefined : <FaPlay />} leftIcon={isProcessing ? undefined : <FaPlay />}
isLoading={isProcessing} isLoading={isProcessing}
loadingText={t('parameters.invoke')} loadingText={t('parameters.invoke')}
{...rest}
sx={{ sx={{
w: 'full', w: 'full',
flexGrow: 1, flexGrow: 1,
fontWeight: 700, fontWeight: 700,
...(isProcessing ? IN_PROGRESS_STYLES : {}), ...(isProcessing ? IN_PROGRESS_STYLES : {}),
...sx,
}} }}
{...rest}
> >
Invoke Invoke
</IAIButton> </IAIButton>
@ -166,7 +169,11 @@ export const InvokeButtonTooltipContent = memo(() => {
))} ))}
</UnorderedList> </UnorderedList>
)} )}
<Divider opacity={0.2} /> <Divider
opacity={0.2}
borderColor="base.50"
_dark={{ borderColor: 'base.900' }}
/>
<Text fontWeight={400} fontStyle="oblique 10deg"> <Text fontWeight={400} fontStyle="oblique 10deg">
Adding images to{' '} Adding images to{' '}
<Text as="span" fontWeight={600}> <Text as="span" fontWeight={600}>

View File

@ -9,10 +9,6 @@ export default function ParamSDXLConcatButton() {
(state: RootState) => state.sdxl.shouldConcatSDXLStylePrompt (state: RootState) => state.sdxl.shouldConcatSDXLStylePrompt
); );
const shouldPinParametersPanel = useAppSelector(
(state: RootState) => state.ui.shouldPinParametersPanel
);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleShouldConcatPromptChange = () => { const handleShouldConcatPromptChange = () => {
@ -31,7 +27,7 @@ export default function ParamSDXLConcatButton() {
sx={{ sx={{
position: 'absolute', position: 'absolute',
insetInlineEnd: 1, insetInlineEnd: 1,
top: shouldPinParametersPanel ? 12 : 20, top: 6,
border: 'none', border: 'none',
color: shouldConcatSDXLStylePrompt ? 'accent.500' : 'base.500', color: shouldConcatSDXLStylePrompt ? 'accent.500' : 'base.500',
_hover: { _hover: {

View File

@ -0,0 +1,82 @@
import {
Flex,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
useDisclosure,
} from '@chakra-ui/react';
import { LOCALSTORAGE_KEYS, LOCALSTORAGE_PREFIX } from 'app/store/constants';
import IAIButton from 'common/components/IAIButton';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
onSettingsModalClose: () => void;
};
const ResetWebUIButton = ({ onSettingsModalClose }: Props) => {
const { t } = useTranslation();
const [countdown, setCountdown] = useState(5);
const {
isOpen: isRefreshModalOpen,
onOpen: onRefreshModalOpen,
onClose: onRefreshModalClose,
} = useDisclosure();
const handleClickResetWebUI = useCallback(() => {
// Only remove our keys
Object.keys(window.localStorage).forEach((key) => {
if (
LOCALSTORAGE_KEYS.includes(key) ||
key.startsWith(LOCALSTORAGE_PREFIX)
) {
localStorage.removeItem(key);
}
});
onSettingsModalClose();
onRefreshModalOpen();
setInterval(() => setCountdown((prev) => prev - 1), 1000);
}, [onSettingsModalClose, onRefreshModalOpen]);
useEffect(() => {
if (countdown <= 0) {
window.location.reload();
}
}, [countdown]);
return (
<>
<IAIButton colorScheme="error" onClick={handleClickResetWebUI}>
{t('settings.resetWebUI')}
</IAIButton>
<Modal
closeOnOverlayClick={false}
isOpen={isRefreshModalOpen}
onClose={onRefreshModalClose}
isCentered
closeOnEsc={false}
>
<ModalOverlay backdropFilter="blur(40px)" />
<ModalContent>
<ModalHeader />
<ModalBody>
<Flex justifyContent="center">
<Text fontSize="lg">
<Text>{t('settings.resetComplete')}</Text>
<Text>Reloading in {countdown}...</Text>
</Text>
</Flex>
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
</>
);
};
export default memo(ResetWebUIButton);

View File

@ -42,6 +42,7 @@ import {
memo, memo,
useCallback, useCallback,
useEffect, useEffect,
useState,
} from 'react'; } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LogLevelName } from 'roarr'; import { LogLevelName } from 'roarr';
@ -113,6 +114,7 @@ type SettingsModalProps = {
const SettingsModal = ({ children, config }: SettingsModalProps) => { const SettingsModal = ({ children, config }: SettingsModalProps) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t } = useTranslation(); const { t } = useTranslation();
const [countdown, setCountdown] = useState(3);
const shouldShowBetaLayout = config?.shouldShowBetaLayout ?? true; const shouldShowBetaLayout = config?.shouldShowBetaLayout ?? true;
const shouldShowDeveloperSettings = const shouldShowDeveloperSettings =
@ -179,8 +181,15 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
}); });
onSettingsModalClose(); onSettingsModalClose();
onRefreshModalOpen(); onRefreshModalOpen();
setInterval(() => setCountdown((prev) => prev - 1), 1000);
}, [onSettingsModalClose, onRefreshModalOpen]); }, [onSettingsModalClose, onRefreshModalOpen]);
useEffect(() => {
if (countdown <= 0) {
window.location.reload();
}
}, [countdown]);
const handleLogLevelChanged = useCallback( const handleLogLevelChanged = useCallback(
(v: string) => { (v: string) => {
dispatch(consoleLogLevelChanged(v as LogLevelName)); dispatch(consoleLogLevelChanged(v as LogLevelName));
@ -381,6 +390,7 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
isOpen={isRefreshModalOpen} isOpen={isRefreshModalOpen}
onClose={onRefreshModalClose} onClose={onRefreshModalClose}
isCentered isCentered
closeOnEsc={false}
> >
<ModalOverlay backdropFilter="blur(40px)" /> <ModalOverlay backdropFilter="blur(40px)" />
<ModalContent> <ModalContent>
@ -388,7 +398,9 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
<ModalBody> <ModalBody>
<Flex justifyContent="center"> <Flex justifyContent="center">
<Text fontSize="lg"> <Text fontSize="lg">
<Text>{t('settings.resetComplete')}</Text> <Text>
{t('settings.resetComplete')} Reloading in {countdown}...
</Text>
</Text> </Text>
</Flex> </Flex>
</ModalBody> </ModalBody>

View File

@ -1,65 +1,61 @@
import { createSelector } from '@reduxjs/toolkit'; import { Flex } from '@chakra-ui/layout';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { Portal } from '@chakra-ui/portal';
import { useAppDispatch } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIIconButton from 'common/components/IAIIconButton';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { setShouldShowGallery } from 'features/ui/store/uiSlice'; import { RefObject, memo } from 'react';
import { isEqual } from 'lodash-es';
import { memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MdPhotoLibrary } from 'react-icons/md'; import { MdPhotoLibrary } from 'react-icons/md';
import { activeTabNameSelector, uiSelector } from '../store/uiSelectors'; import { ImperativePanelHandle } from 'react-resizable-panels';
import { NO_GALLERY_TABS } from './InvokeTabs';
const floatingGalleryButtonSelector = createSelector( type Props = {
[activeTabNameSelector, uiSelector], isGalleryCollapsed: boolean;
(activeTabName, ui) => { galleryPanelRef: RefObject<ImperativePanelHandle>;
const { shouldPinGallery, shouldShowGallery } = ui; };
return { const FloatingGalleryButton = ({
shouldPinGallery, isGalleryCollapsed,
shouldShowGalleryButton: NO_GALLERY_TABS.includes(activeTabName) galleryPanelRef,
? false }: Props) => {
: !shouldShowGallery,
};
},
{ memoizeOptions: { resultEqualityCheck: isEqual } }
);
const FloatingGalleryButton = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { shouldPinGallery, shouldShowGalleryButton } = useAppSelector(
floatingGalleryButtonSelector
);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleShowGallery = () => { const handleShowGallery = () => {
dispatch(setShouldShowGallery(true)); galleryPanelRef.current?.expand();
shouldPinGallery && dispatch(requestCanvasRescale()); dispatch(requestCanvasRescale());
}; };
return shouldShowGalleryButton ? ( if (!isGalleryCollapsed) {
<IAIIconButton return null;
tooltip="Show Gallery (G)" }
tooltipProps={{ placement: 'top' }}
aria-label={t('accessibility.showGallery')} return (
onClick={handleShowGallery} <Portal>
sx={{ <Flex
pos: 'absolute', pos="absolute"
top: '50%', transform="translate(0, -50%)"
transform: 'translate(0, -50%)', minW={8}
p: 0, top="50%"
insetInlineEnd: 0, insetInlineEnd={0}
px: 3, >
h: 48, <IAIIconButton
w: 8, tooltip="Show Gallery (G)"
borderStartEndRadius: 0, tooltipProps={{ placement: 'top' }}
borderEndEndRadius: 0, aria-label={t('common.showGalleryPanel')}
shadow: '2xl', onClick={handleShowGallery}
}} icon={<MdPhotoLibrary />}
> sx={{
<MdPhotoLibrary /> p: 0,
</IAIIconButton> px: 3,
) : null; h: 48,
borderStartEndRadius: 0,
borderEndEndRadius: 0,
shadow: '2xl',
}}
/>
</Flex>
</Portal>
);
}; };
export default memo(FloatingGalleryButton); export default memo(FloatingGalleryButton);

View File

@ -1,20 +1,14 @@
import { ChakraProps, Flex } from '@chakra-ui/react'; import { ChakraProps, Flex, Portal } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch } from 'app/store/storeHooks';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIIconButton from 'common/components/IAIIconButton';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import CancelButton from 'features/parameters/components/ProcessButtons/CancelButton'; import CancelButton from 'features/parameters/components/ProcessButtons/CancelButton';
import InvokeButton from 'features/parameters/components/ProcessButtons/InvokeButton'; import InvokeButton from 'features/parameters/components/ProcessButtons/InvokeButton';
import { import { RefObject, memo } from 'react';
activeTabNameSelector,
uiSelector,
} from 'features/ui/store/uiSelectors';
import { setShouldShowParametersPanel } from 'features/ui/store/uiSlice';
import { isEqual } from 'lodash-es';
import { memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaSlidersH } from 'react-icons/fa'; import { FaSlidersH } from 'react-icons/fa';
import { ImperativePanelHandle } from 'react-resizable-panels';
const floatingButtonStyles: ChakraProps['sx'] = { const floatingButtonStyles: ChakraProps['sx'] = {
borderStartStartRadius: 0, borderStartStartRadius: 0,
@ -22,81 +16,50 @@ const floatingButtonStyles: ChakraProps['sx'] = {
shadow: '2xl', shadow: '2xl',
}; };
export const floatingParametersPanelButtonSelector = createSelector( type Props = {
[uiSelector, activeTabNameSelector], isSidePanelCollapsed: boolean;
(ui, activeTabName) => { sidePanelRef: RefObject<ImperativePanelHandle>;
const { };
shouldPinParametersPanel,
shouldUseCanvasBetaLayout,
shouldShowParametersPanel,
} = ui;
const canvasBetaLayoutCheck = const FloatingSidePanelButtons = ({
shouldUseCanvasBetaLayout && activeTabName === 'unifiedCanvas'; isSidePanelCollapsed,
sidePanelRef,
const shouldShowProcessButtons = }: Props) => {
!canvasBetaLayoutCheck &&
(!shouldPinParametersPanel || !shouldShowParametersPanel);
const shouldShowParametersPanelButton =
!canvasBetaLayoutCheck &&
!shouldShowParametersPanel &&
['txt2img', 'img2img', 'unifiedCanvas'].includes(activeTabName);
return {
shouldPinParametersPanel,
shouldShowParametersPanelButton,
shouldShowProcessButtons,
};
},
{ memoizeOptions: { resultEqualityCheck: isEqual } }
);
const FloatingParametersPanelButtons = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation(); const { t } = useTranslation();
const { const dispatch = useAppDispatch();
shouldShowProcessButtons,
shouldShowParametersPanelButton,
shouldPinParametersPanel,
} = useAppSelector(floatingParametersPanelButtonSelector);
const handleShowOptionsPanel = () => { const handleShowSidePanel = () => {
dispatch(setShouldShowParametersPanel(true)); sidePanelRef.current?.expand();
shouldPinParametersPanel && dispatch(requestCanvasRescale()); dispatch(requestCanvasRescale());
}; };
if (!shouldShowParametersPanelButton) { if (!isSidePanelCollapsed) {
return null; return null;
} }
return ( return (
<Flex <Portal>
pos="absolute" <Flex
transform="translate(0, -50%)" pos="absolute"
minW={8} transform="translate(0, -50%)"
top="50%" minW={8}
insetInlineStart="4.5rem" top="50%"
direction="column" insetInlineStart="4.5rem"
gap={2} direction="column"
> gap={2}
<IAIIconButton
tooltip="Show Options Panel (O)"
tooltipProps={{ placement: 'top' }}
aria-label={t('accessibility.showOptionsPanel')}
onClick={handleShowOptionsPanel}
sx={floatingButtonStyles}
> >
<FaSlidersH /> <IAIIconButton
</IAIIconButton> tooltip="Show Side Panel (O, T)"
{shouldShowProcessButtons && ( aria-label={t('common.showOptionsPanel')}
<> onClick={handleShowSidePanel}
<InvokeButton iconButton sx={floatingButtonStyles} /> sx={floatingButtonStyles}
<CancelButton sx={floatingButtonStyles} /> icon={<FaSlidersH />}
</> />
)} <InvokeButton asIconButton sx={floatingButtonStyles} />
</Flex> <CancelButton sx={floatingButtonStyles} />
</Flex>
</Portal>
); );
}; };
export default memo(FloatingParametersPanelButtons); export default memo(FloatingSidePanelButtons);

View File

@ -11,12 +11,13 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import AuxiliaryProgressIndicator from 'app/components/AuxiliaryProgressIndicator'; import AuxiliaryProgressIndicator from 'app/components/AuxiliaryProgressIndicator';
import { RootState, stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import ImageGalleryContent from 'features/gallery/components/ImageGalleryContent'; import ImageGalleryContent from 'features/gallery/components/ImageGalleryContent';
import NodeEditorPanelGroup from 'features/nodes/components/sidePanel/NodeEditorPanelGroup';
import { InvokeTabName, tabMap } from 'features/ui/store/tabMap'; import { InvokeTabName, tabMap } from 'features/ui/store/tabMap';
import { setActiveTab, togglePanels } from 'features/ui/store/uiSlice'; import { setActiveTab } from 'features/ui/store/uiSlice';
import { ResourceKey } from 'i18next'; import { ResourceKey } from 'i18next';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { MouseEvent, ReactNode, memo, useCallback, useMemo } from 'react'; import { MouseEvent, ReactNode, memo, useCallback, useMemo } from 'react';
@ -25,11 +26,15 @@ import { useTranslation } from 'react-i18next';
import { FaCube, FaFont, FaImage } from 'react-icons/fa'; import { FaCube, FaFont, FaImage } from 'react-icons/fa';
import { MdDeviceHub, MdGridOn } from 'react-icons/md'; import { MdDeviceHub, MdGridOn } from 'react-icons/md';
import { Panel, PanelGroup } from 'react-resizable-panels'; import { Panel, PanelGroup } from 'react-resizable-panels';
import { useMinimumPanelSize } from '../hooks/useMinimumPanelSize'; import { usePanel } from '../hooks/usePanel';
import { usePanelStorage } from '../hooks/usePanelStorage';
import { import {
activeTabIndexSelector, activeTabIndexSelector,
activeTabNameSelector, activeTabNameSelector,
} from '../store/uiSelectors'; } from '../store/uiSelectors';
import FloatingGalleryButton from './FloatingGalleryButton';
import FloatingSidePanelButtons from './FloatingParametersPanelButtons';
import ParametersPanel from './ParametersPanel';
import ImageTab from './tabs/ImageToImage/ImageToImageTab'; import ImageTab from './tabs/ImageToImage/ImageToImageTab';
import ModelManagerTab from './tabs/ModelManager/ModelManagerTab'; import ModelManagerTab from './tabs/ModelManager/ModelManagerTab';
import NodesTab from './tabs/Nodes/NodesTab'; import NodesTab from './tabs/Nodes/NodesTab';
@ -89,32 +94,20 @@ const enabledTabsSelector = createSelector(
} }
); );
const MIN_GALLERY_WIDTH = 350; const SIDE_PANEL_MIN_SIZE_PX = 448;
const DEFAULT_GALLERY_PCT = 20; const MAIN_PANEL_MIN_SIZE_PX = 448;
const GALLERY_PANEL_MIN_SIZE_PX = 360;
export const NO_GALLERY_TABS: InvokeTabName[] = ['modelManager']; export const NO_GALLERY_TABS: InvokeTabName[] = ['modelManager'];
export const NO_SIDE_PANEL_TABS: InvokeTabName[] = ['modelManager'];
const InvokeTabs = () => { const InvokeTabs = () => {
const activeTab = useAppSelector(activeTabIndexSelector); const activeTab = useAppSelector(activeTabIndexSelector);
const activeTabName = useAppSelector(activeTabNameSelector); const activeTabName = useAppSelector(activeTabNameSelector);
const enabledTabs = useAppSelector(enabledTabsSelector); const enabledTabs = useAppSelector(enabledTabsSelector);
const { shouldPinGallery, shouldPinParametersPanel, shouldShowGallery } =
useAppSelector((state: RootState) => state.ui);
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useHotkeys(
'f',
() => {
dispatch(togglePanels());
(shouldPinGallery || shouldPinParametersPanel) &&
dispatch(requestCanvasRescale());
},
[shouldPinGallery, shouldPinParametersPanel]
);
const handleResizeGallery = useCallback(() => { const handleResizeGallery = useCallback(() => {
if (activeTabName === 'unifiedCanvas') { if (activeTabName === 'unifiedCanvas') {
dispatch(requestCanvasRescale()); dispatch(requestCanvasRescale());
@ -153,9 +146,6 @@ const InvokeTabs = () => {
[enabledTabs] [enabledTabs]
); );
const { ref: galleryPanelRef, minSizePct: galleryMinSizePct } =
useMinimumPanelSize(MIN_GALLERY_WIDTH, DEFAULT_GALLERY_PCT, 'app');
const handleTabChange = useCallback( const handleTabChange = useCallback(
(index: number) => { (index: number) => {
const activeTabName = tabMap[index]; const activeTabName = tabMap[index];
@ -167,6 +157,63 @@ const InvokeTabs = () => {
[dispatch] [dispatch]
); );
const {
minSize: sidePanelMinSize,
isCollapsed: isSidePanelCollapsed,
setIsCollapsed: setIsSidePanelCollapsed,
ref: sidePanelRef,
reset: resetSidePanel,
expand: expandSidePanel,
collapse: collapseSidePanel,
toggle: toggleSidePanel,
} = usePanel(SIDE_PANEL_MIN_SIZE_PX, 'pixels');
const {
ref: galleryPanelRef,
minSize: galleryPanelMinSize,
isCollapsed: isGalleryPanelCollapsed,
setIsCollapsed: setIsGalleryPanelCollapsed,
reset: resetGalleryPanel,
expand: expandGalleryPanel,
collapse: collapseGalleryPanel,
toggle: toggleGalleryPanel,
} = usePanel(GALLERY_PANEL_MIN_SIZE_PX, 'pixels');
useHotkeys(
'f',
() => {
if (isGalleryPanelCollapsed || isSidePanelCollapsed) {
expandGalleryPanel();
expandSidePanel();
} else {
collapseSidePanel();
collapseGalleryPanel();
}
dispatch(requestCanvasRescale());
},
[dispatch, isGalleryPanelCollapsed, isSidePanelCollapsed]
);
useHotkeys(
['t', 'o'],
() => {
toggleSidePanel();
dispatch(requestCanvasRescale());
},
[dispatch]
);
useHotkeys(
'g',
() => {
toggleGalleryPanel();
dispatch(requestCanvasRescale());
},
[dispatch]
);
const panelStorage = usePanelStorage();
return ( return (
<Tabs <Tabs
variant="appTabs" variant="appTabs"
@ -195,35 +242,68 @@ const InvokeTabs = () => {
autoSaveId="app" autoSaveId="app"
direction="horizontal" direction="horizontal"
style={{ height: '100%', width: '100%' }} style={{ height: '100%', width: '100%' }}
storage={panelStorage}
units="pixels"
> >
<Panel id="main"> {!NO_SIDE_PANEL_TABS.includes(activeTabName) && (
<>
<Panel
order={0}
id="side"
ref={sidePanelRef}
defaultSize={sidePanelMinSize}
minSize={sidePanelMinSize}
onResize={handleResizeGallery}
onCollapse={setIsSidePanelCollapsed}
collapsible
>
{activeTabName === 'nodes' ? (
<NodeEditorPanelGroup />
) : (
<ParametersPanel />
)}
</Panel>
<ResizeHandle
onDoubleClick={resetSidePanel}
isCollapsed={isSidePanelCollapsed}
collapsedDirection={isSidePanelCollapsed ? 'left' : undefined}
/>
<FloatingSidePanelButtons
isSidePanelCollapsed={isSidePanelCollapsed}
sidePanelRef={sidePanelRef}
/>
</>
)}
<Panel id="main" order={1} minSize={MAIN_PANEL_MIN_SIZE_PX}>
<TabPanels style={{ height: '100%', width: '100%' }}> <TabPanels style={{ height: '100%', width: '100%' }}>
{tabPanels} {tabPanels}
</TabPanels> </TabPanels>
</Panel> </Panel>
{shouldPinGallery && {!NO_GALLERY_TABS.includes(activeTabName) && (
shouldShowGallery && <>
!NO_GALLERY_TABS.includes(activeTabName) && ( <ResizeHandle
<> isCollapsed={isGalleryPanelCollapsed}
<ResizeHandle /> onDoubleClick={resetGalleryPanel}
<Panel collapsedDirection={isGalleryPanelCollapsed ? 'right' : undefined}
ref={galleryPanelRef} />
onResize={handleResizeGallery} <Panel
id="gallery" id="gallery"
order={3} ref={galleryPanelRef}
defaultSize={ order={2}
galleryMinSizePct > DEFAULT_GALLERY_PCT && onResize={handleResizeGallery}
galleryMinSizePct < 100 // prevent this error https://github.com/bvaughn/react-resizable-panels/blob/main/packages/react-resizable-panels/src/Panel.ts#L96 defaultSize={galleryPanelMinSize}
? galleryMinSizePct minSize={galleryPanelMinSize}
: DEFAULT_GALLERY_PCT onCollapse={setIsGalleryPanelCollapsed}
} collapsible
minSize={galleryMinSizePct} >
maxSize={50} <ImageGalleryContent />
> </Panel>
<ImageGalleryContent /> <FloatingGalleryButton
</Panel> isGalleryCollapsed={isGalleryPanelCollapsed}
</> galleryPanelRef={galleryPanelRef}
)} />
</>
)}
</PanelGroup> </PanelGroup>
</Tabs> </Tabs>
); );

View File

@ -1,116 +0,0 @@
import { Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import SDXLImageToImageTabParameters from 'features/sdxl/components/SDXLImageToImageTabParameters';
import SDXLTextToImageTabParameters from 'features/sdxl/components/SDXLTextToImageTabParameters';
import InvokeAILogoComponent from 'features/system/components/InvokeAILogoComponent';
import {
activeTabNameSelector,
uiSelector,
} from 'features/ui/store/uiSelectors';
import { setShouldShowParametersPanel } from 'features/ui/store/uiSlice';
import { memo, useMemo } from 'react';
import { PARAMETERS_PANEL_WIDTH } from 'theme/util/constants';
import PinParametersPanelButton from './PinParametersPanelButton';
import ResizableDrawer from './common/ResizableDrawer/ResizableDrawer';
import ImageToImageTabParameters from './tabs/ImageToImage/ImageToImageTabParameters';
import TextToImageTabParameters from './tabs/TextToImage/TextToImageTabParameters';
import UnifiedCanvasParameters from './tabs/UnifiedCanvas/UnifiedCanvasParameters';
const selector = createSelector(
[uiSelector, activeTabNameSelector],
(ui, activeTabName) => {
const { shouldPinParametersPanel, shouldShowParametersPanel } = ui;
return {
activeTabName,
shouldPinParametersPanel,
shouldShowParametersPanel,
};
},
defaultSelectorOptions
);
const ParametersDrawer = () => {
const dispatch = useAppDispatch();
const { shouldPinParametersPanel, shouldShowParametersPanel, activeTabName } =
useAppSelector(selector);
const handleClosePanel = () => {
dispatch(setShouldShowParametersPanel(false));
};
const model = useAppSelector((state: RootState) => state.generation.model);
const drawerContent = useMemo(() => {
if (activeTabName === 'txt2img') {
return model && model.base_model === 'sdxl' ? (
<SDXLTextToImageTabParameters />
) : (
<TextToImageTabParameters />
);
}
if (activeTabName === 'img2img') {
return model && model.base_model === 'sdxl' ? (
<SDXLImageToImageTabParameters />
) : (
<ImageToImageTabParameters />
);
}
if (activeTabName === 'unifiedCanvas') {
return <UnifiedCanvasParameters />;
}
return null;
}, [activeTabName, model]);
if (shouldPinParametersPanel) {
return null;
}
return (
<ResizableDrawer
direction="left"
isResizable={false}
isOpen={shouldShowParametersPanel}
onClose={handleClosePanel}
>
<Flex
sx={{
flexDir: 'column',
h: 'full',
w: PARAMETERS_PANEL_WIDTH,
gap: 2,
position: 'relative',
flexShrink: 0,
overflowY: 'auto',
}}
>
<Flex
paddingBottom={4}
justifyContent="space-between"
alignItems="center"
>
<InvokeAILogoComponent />
<PinParametersPanelButton />
</Flex>
<Flex
sx={{
gap: 2,
flexDirection: 'column',
h: 'full',
w: 'full',
}}
>
{drawerContent}
</Flex>
</Flex>
</ResizableDrawer>
);
};
export default memo(ParametersDrawer);

View File

@ -0,0 +1,109 @@
import { Box, Flex } from '@chakra-ui/react';
import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import SDXLImageToImageTabParameters from 'features/sdxl/components/SDXLImageToImageTabParameters';
import SDXLTextToImageTabParameters from 'features/sdxl/components/SDXLTextToImageTabParameters';
import SDXLUnifiedCanvasTabParameters from 'features/sdxl/components/SDXLUnifiedCanvasTabParameters';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import { PropsWithChildren, memo } from 'react';
import { activeTabNameSelector } from '../store/uiSelectors';
import ImageToImageTabParameters from './tabs/ImageToImage/ImageToImageTabParameters';
import TextToImageTabParameters from './tabs/TextToImage/TextToImageTabParameters';
import UnifiedCanvasParameters from './tabs/UnifiedCanvas/UnifiedCanvasParameters';
const ParametersPanel = () => {
const activeTabName = useAppSelector(activeTabNameSelector);
const model = useAppSelector((state: RootState) => state.generation.model);
if (activeTabName === 'txt2img') {
return (
<ParametersPanelWrapper>
{model && model.base_model === 'sdxl' ? (
<SDXLTextToImageTabParameters />
) : (
<TextToImageTabParameters />
)}
</ParametersPanelWrapper>
);
}
if (activeTabName === 'img2img') {
return (
<ParametersPanelWrapper>
{model && model.base_model === 'sdxl' ? (
<SDXLImageToImageTabParameters />
) : (
<ImageToImageTabParameters />
)}
</ParametersPanelWrapper>
);
}
if (activeTabName === 'unifiedCanvas') {
return (
<ParametersPanelWrapper>
{model && model.base_model === 'sdxl' ? (
<SDXLUnifiedCanvasTabParameters />
) : (
<UnifiedCanvasParameters />
)}
</ParametersPanelWrapper>
);
}
return null;
};
export default memo(ParametersPanel);
const ParametersPanelWrapper = memo((props: PropsWithChildren) => {
return (
<Flex
sx={{
w: 'full',
h: 'full',
position: 'relative',
borderRadius: 'base',
}}
>
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
<OverlayScrollbarsComponent
defer
style={{ height: '100%', width: '100%' }}
options={{
scrollbars: {
visibility: 'auto',
autoHide: 'scroll',
autoHideDelay: 800,
theme: 'os-theme-dark',
},
overflow: {
x: 'hidden',
},
}}
>
<Flex
sx={{
gap: 2,
flexDirection: 'column',
h: 'full',
w: 'full',
}}
>
{props.children}
</Flex>
</OverlayScrollbarsComponent>
</Box>
</Flex>
);
});
ParametersPanelWrapper.displayName = 'ParametersPanelWrapper';

View File

@ -1,57 +0,0 @@
import { Box, Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { PropsWithChildren, memo } from 'react';
import { PARAMETERS_PANEL_WIDTH } from 'theme/util/constants';
import { uiSelector } from '../store/uiSelectors';
import PinParametersPanelButton from './PinParametersPanelButton';
const selector = createSelector(uiSelector, (ui) => {
const { shouldPinParametersPanel, shouldShowParametersPanel } = ui;
return {
shouldPinParametersPanel,
shouldShowParametersPanel,
};
});
type ParametersPinnedWrapperProps = PropsWithChildren;
const ParametersPinnedWrapper = (props: ParametersPinnedWrapperProps) => {
const { shouldPinParametersPanel, shouldShowParametersPanel } =
useAppSelector(selector);
if (!(shouldPinParametersPanel && shouldShowParametersPanel)) {
return null;
}
return (
<Box
sx={{
position: 'relative',
h: 'full',
w: PARAMETERS_PANEL_WIDTH,
flexShrink: 0,
}}
>
<Flex
sx={{
gap: 2,
flexDirection: 'column',
h: 'full',
w: 'full',
position: 'absolute',
overflowY: 'auto',
}}
>
{props.children}
</Flex>
<PinParametersPanelButton
sx={{ position: 'absolute', top: 0, insetInlineEnd: 0 }}
/>
</Box>
);
};
export default memo(ParametersPinnedWrapper);

View File

@ -1,59 +0,0 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton, {
IAIIconButtonProps,
} from 'common/components/IAIIconButton';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { useTranslation } from 'react-i18next';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
import { setShouldPinParametersPanel } from '../store/uiSlice';
import { memo } from 'react';
type PinParametersPanelButtonProps = Omit<IAIIconButtonProps, 'aria-label'>;
const PinParametersPanelButton = (props: PinParametersPanelButtonProps) => {
const { sx } = props;
const dispatch = useAppDispatch();
const shouldPinParametersPanel = useAppSelector(
(state) => state.ui.shouldPinParametersPanel
);
const { t } = useTranslation();
const handleClickPinOptionsPanel = () => {
dispatch(setShouldPinParametersPanel(!shouldPinParametersPanel));
dispatch(requestCanvasRescale());
};
return (
<IAIIconButton
{...props}
tooltip={t('common.pinOptionsPanel')}
aria-label={t('common.pinOptionsPanel')}
onClick={handleClickPinOptionsPanel}
icon={shouldPinParametersPanel ? <BsPinAngleFill /> : <BsPinAngle />}
variant="ghost"
size="sm"
sx={{
color: 'base.500',
_hover: {
color: 'base.600',
},
_active: {
color: 'base.700',
},
_dark: {
color: 'base.500',
_hover: {
color: 'base.400',
},
_active: {
color: 'base.300',
},
},
...sx,
}}
/>
);
};
export default memo(PinParametersPanelButton);

View File

@ -1,196 +0,0 @@
import {
Box,
chakra,
ChakraProps,
Slide,
useOutsideClick,
useTheme,
SlideDirection,
useColorMode,
} from '@chakra-ui/react';
import {
Resizable,
ResizableProps,
ResizeCallback,
ResizeStartCallback,
} from 're-resizable';
import { ReactNode, memo, useEffect, useMemo, useRef, useState } from 'react';
import { LangDirection } from './types';
import {
getHandleEnables,
getMinMaxDimensions,
getSlideDirection,
getStyles,
} from './util';
import { mode } from 'theme/util/mode';
type ResizableDrawerProps = ResizableProps & {
children: ReactNode;
isResizable: boolean;
isOpen: boolean;
onClose: () => void;
direction?: SlideDirection;
initialWidth?: number;
minWidth?: number;
maxWidth?: number;
initialHeight?: number;
minHeight?: number;
maxHeight?: number;
onResizeStart?: ResizeStartCallback;
onResizeStop?: ResizeCallback;
onResize?: ResizeCallback;
handleWidth?: string | number;
handleInteractWidth?: string | number;
sx?: ChakraProps['sx'];
};
const ChakraResizeable = chakra(Resizable, {
shouldForwardProp: (prop) => !['sx'].includes(prop),
});
const ResizableDrawer = ({
direction = 'left',
isResizable,
isOpen,
onClose,
children,
initialWidth,
minWidth,
maxWidth,
initialHeight,
minHeight,
maxHeight,
onResizeStart,
onResizeStop,
onResize,
sx = {},
}: ResizableDrawerProps) => {
const langDirection = useTheme().direction as LangDirection;
const { colorMode } = useColorMode();
const outsideClickRef = useRef<HTMLDivElement>(null);
const defaultWidth = useMemo(
() =>
initialWidth ??
minWidth ??
(['left', 'right'].includes(direction) ? 'auto' : '100%'),
[initialWidth, minWidth, direction]
);
const defaultHeight = useMemo(
() =>
initialHeight ??
minHeight ??
(['top', 'bottom'].includes(direction) ? 'auto' : '100%'),
[initialHeight, minHeight, direction]
);
const [width, setWidth] = useState<number | string>(defaultWidth);
const [height, setHeight] = useState<number | string>(defaultHeight);
useOutsideClick({
ref: outsideClickRef,
handler: () => {
onClose();
},
enabled: isOpen,
});
const handleEnables = useMemo(
() => (isResizable ? getHandleEnables({ direction, langDirection }) : {}),
[isResizable, langDirection, direction]
);
const minMaxDimensions = useMemo(
() =>
getMinMaxDimensions({
direction,
minWidth,
maxWidth,
minHeight,
maxHeight,
}),
[minWidth, maxWidth, minHeight, maxHeight, direction]
);
const { containerStyles, handleStyles } = useMemo(
() =>
getStyles({
isResizable,
direction,
}),
[isResizable, direction]
);
const slideDirection = useMemo(
() => getSlideDirection(direction, langDirection),
[direction, langDirection]
);
useEffect(() => {
if (['left', 'right'].includes(direction)) {
setHeight('100vh');
// setHeight(isPinned ? '100%' : '100vh');
}
if (['top', 'bottom'].includes(direction)) {
setWidth('100vw');
// setWidth(isPinned ? '100%' : '100vw');
}
}, [direction]);
return (
<Slide
direction={slideDirection}
in={isOpen}
motionProps={{ initial: false }}
style={{ width: 'full' }}
>
<Box
ref={outsideClickRef}
sx={{
width: 'full',
height: 'full',
}}
>
<ChakraResizeable
size={{
width: isResizable ? width : defaultWidth,
height: isResizable ? height : defaultHeight,
}}
enable={handleEnables}
handleStyles={handleStyles}
{...minMaxDimensions}
sx={{
borderColor: mode('base.200', 'base.800')(colorMode),
p: 4,
bg: mode('base.50', 'base.900')(colorMode),
height: 'full',
shadow: isOpen ? 'dark-lg' : undefined,
...containerStyles,
...sx,
}}
onResizeStart={(event, direction, elementRef) => {
onResizeStart && onResizeStart(event, direction, elementRef);
}}
onResize={(event, direction, elementRef, delta) => {
onResize && onResize(event, direction, elementRef, delta);
}}
onResizeStop={(event, direction, elementRef, delta) => {
if (['left', 'right'].includes(direction)) {
setWidth(Number(width) + delta.width);
}
if (['top', 'bottom'].includes(direction)) {
setHeight(Number(height) + delta.height);
}
onResizeStop && onResizeStop(event, direction, elementRef, delta);
}}
>
{children}
</ChakraResizeable>
</Box>
</Slide>
);
};
export default memo(ResizableDrawer);

View File

@ -1,2 +0,0 @@
export type Placement = 'top' | 'right' | 'bottom' | 'left';
export type LangDirection = 'ltr' | 'rtl' | undefined;

View File

@ -1,283 +0,0 @@
import { SlideDirection } from '@chakra-ui/react';
import { AnimationProps } from 'framer-motion';
import { HandleStyles } from 're-resizable';
import { CSSProperties } from 'react';
import { LangDirection } from './types';
export type GetHandleEnablesOptions = {
direction: SlideDirection;
langDirection: LangDirection;
};
/**
* Determine handles to enable. `re-resizable` doesn't handle RTL, so we have to do that here.
*/
export const getHandleEnables = ({
direction,
langDirection,
}: GetHandleEnablesOptions) => {
const top = direction === 'bottom';
const right =
(langDirection !== 'rtl' && direction === 'left') ||
(langDirection === 'rtl' && direction === 'right');
const bottom = direction === 'top';
const left =
(langDirection !== 'rtl' && direction === 'right') ||
(langDirection === 'rtl' && direction === 'left');
return {
top,
right,
bottom,
left,
};
};
export type GetDefaultSizeOptions = {
initialWidth?: string | number;
initialHeight?: string | number;
direction: SlideDirection;
};
// Get default sizes based on direction and initial values
export const getDefaultSize = ({
initialWidth,
initialHeight,
direction,
}: GetDefaultSizeOptions) => {
const width =
initialWidth ?? (['left', 'right'].includes(direction) ? 500 : '100vw');
const height =
initialHeight ?? (['top', 'bottom'].includes(direction) ? 500 : '100vh');
return { width, height };
};
export type GetMinMaxDimensionsOptions = {
direction: SlideDirection;
minWidth?: number;
maxWidth?: number;
minHeight?: number;
maxHeight?: number;
};
// Get the min/max width/height based on direction and provided values
export const getMinMaxDimensions = ({
direction,
minWidth,
maxWidth,
minHeight,
maxHeight,
}: GetMinMaxDimensionsOptions) => {
const minW =
minWidth ?? (['left', 'right'].includes(direction) ? 10 : undefined);
const maxW =
maxWidth ?? (['left', 'right'].includes(direction) ? '95vw' : undefined);
const minH =
minHeight ?? (['top', 'bottom'].includes(direction) ? 10 : undefined);
const maxH =
maxHeight ?? (['top', 'bottom'].includes(direction) ? '95vh' : undefined);
return {
...(minW ? { minWidth: minW } : {}),
...(maxW ? { maxWidth: maxW } : {}),
...(minH ? { minHeight: minH } : {}),
...(maxH ? { maxHeight: maxH } : {}),
};
};
export type GetAnimationsOptions = {
direction: SlideDirection;
langDirection: LangDirection;
};
// Get the framer-motion animation props, taking into account language direction
export const getAnimations = ({
direction,
langDirection,
}: GetAnimationsOptions): AnimationProps => {
const baseAnimation = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
// chakra consumes the transition prop, which, for it, is a string.
// however we know the transition prop will make it to framer motion,
// which wants it as an object. cast as string to satisfy TS.
transition: { duration: 0.2, ease: 'easeInOut' },
};
const langDirectionFactor = langDirection === 'rtl' ? -1 : 1;
if (direction === 'top') {
return {
...baseAnimation,
initial: { y: -999 },
animate: { y: 0 },
exit: { y: -999 },
};
}
if (direction === 'right') {
return {
...baseAnimation,
initial: { x: 999 * langDirectionFactor },
animate: { x: 0 },
exit: { x: 999 * langDirectionFactor },
};
}
if (direction === 'bottom') {
return {
...baseAnimation,
initial: { y: 999 },
animate: { y: 0 },
exit: { y: 999 },
};
}
if (direction === 'left') {
return {
...baseAnimation,
initial: { x: -999 * langDirectionFactor },
animate: { x: 0 },
exit: { x: -999 * langDirectionFactor },
};
}
return {};
};
export type GetResizableStylesProps = {
isResizable: boolean;
direction: SlideDirection;
};
// Expand the handle hitbox
const HANDLE_INTERACT_PADDING = '0.75rem';
// Visible padding around handle
const HANDLE_PADDING = '1rem';
const HANDLE_WIDTH = '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 = ({
isResizable,
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: {} };
// }
// Calculate the positioning offset of the handle hitbox so it is centered over the handle
const handleOffset = `calc((2 * ${HANDLE_INTERACT_PADDING} + ${HANDLE_WIDTH}) / -2)`;
if (direction === 'top') {
return {
containerStyles: {
borderBottomWidth: HANDLE_WIDTH,
paddingBottom: HANDLE_PADDING,
},
handleStyles: isResizable
? {
top: {
paddingTop: HANDLE_INTERACT_PADDING,
paddingBottom: HANDLE_INTERACT_PADDING,
bottom: handleOffset,
},
}
: {},
};
}
if (direction === 'left') {
return {
containerStyles: {
borderInlineEndWidth: HANDLE_WIDTH,
paddingInlineEnd: HANDLE_PADDING,
},
handleStyles: isResizable
? {
right: {
paddingInlineStart: HANDLE_INTERACT_PADDING,
paddingInlineEnd: HANDLE_INTERACT_PADDING,
insetInlineEnd: handleOffset,
},
}
: {},
};
}
if (direction === 'bottom') {
return {
containerStyles: {
borderTopWidth: HANDLE_WIDTH,
paddingTop: HANDLE_PADDING,
},
handleStyles: isResizable
? {
bottom: {
paddingTop: HANDLE_INTERACT_PADDING,
paddingBottom: HANDLE_INTERACT_PADDING,
top: handleOffset,
},
}
: {},
};
}
if (direction === 'right') {
return {
containerStyles: {
borderInlineStartWidth: HANDLE_WIDTH,
paddingInlineStart: HANDLE_PADDING,
},
handleStyles: isResizable
? {
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
) => {
if (['top', 'bottom'].includes(direction)) {
return direction;
}
if (direction === 'left') {
if (langDirection === 'rtl') {
return 'right';
}
return 'left';
}
if (direction === 'right') {
if (langDirection === 'rtl') {
return 'left';
}
return 'right';
}
return 'left';
};

View File

@ -1,73 +1,63 @@
import { Box, Flex } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { RootState } from 'app/store/store'; import { useAppDispatch } from 'app/store/storeHooks';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import InitialImageDisplay from 'features/parameters/components/Parameters/ImageToImage/InitialImageDisplay'; import InitialImageDisplay from 'features/parameters/components/Parameters/ImageToImage/InitialImageDisplay';
import SDXLImageToImageTabParameters from 'features/sdxl/components/SDXLImageToImageTabParameters'; import { usePanelStorage } from 'features/ui/hooks/usePanelStorage';
import { memo, useCallback, useRef } from 'react'; import { memo, useCallback, useRef } from 'react';
import { import {
ImperativePanelGroupHandle, ImperativePanelGroupHandle,
Panel, Panel,
PanelGroup, PanelGroup,
} from 'react-resizable-panels'; } from 'react-resizable-panels';
import ParametersPinnedWrapper from '../../ParametersPinnedWrapper';
import ResizeHandle from '../ResizeHandle'; import ResizeHandle from '../ResizeHandle';
import TextToImageTabMain from '../TextToImage/TextToImageTabMain'; import TextToImageTabMain from '../TextToImage/TextToImageTabMain';
import ImageToImageTabParameters from './ImageToImageTabParameters';
const ImageToImageTab = () => { const ImageToImageTab = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null); const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
const model = useAppSelector((state: RootState) => state.generation.model);
const handleDoubleClickHandle = useCallback(() => { const handleDoubleClickHandle = useCallback(() => {
if (!panelGroupRef.current) { if (!panelGroupRef.current) {
return; return;
} }
panelGroupRef.current.setLayout([50, 50]); panelGroupRef.current.setLayout([50, 50]);
}, []); }, []);
const panelStorage = usePanelStorage();
return ( return (
<Flex sx={{ gap: 4, w: 'full', h: 'full' }}> <Box sx={{ w: 'full', h: 'full' }}>
<ParametersPinnedWrapper> <PanelGroup
{model && model.base_model === 'sdxl' ? ( ref={panelGroupRef}
<SDXLImageToImageTabParameters /> autoSaveId="imageTab.content"
) : ( direction="horizontal"
<ImageToImageTabParameters /> style={{ height: '100%', width: '100%' }}
)} storage={panelStorage}
</ParametersPinnedWrapper> units="percentages"
<Box sx={{ w: 'full', h: 'full' }}> >
<PanelGroup <Panel
ref={panelGroupRef} id="imageTab.content.initImage"
autoSaveId="imageTab.content" order={0}
direction="horizontal" defaultSize={50}
style={{ height: '100%', width: '100%' }} minSize={25}
style={{ position: 'relative' }}
> >
<Panel <InitialImageDisplay />
id="imageTab.content.initImage" </Panel>
order={0} <ResizeHandle onDoubleClick={handleDoubleClickHandle} />
defaultSize={50} <Panel
minSize={25} id="imageTab.content.selectedImage"
style={{ position: 'relative' }} order={1}
> defaultSize={50}
<InitialImageDisplay /> minSize={25}
</Panel> onResize={() => {
<ResizeHandle onDoubleClick={handleDoubleClickHandle} /> dispatch(requestCanvasRescale());
<Panel }}
id="imageTab.content.selectedImage" >
order={1} <TextToImageTabMain />
defaultSize={50} </Panel>
minSize={25} </PanelGroup>
onResize={() => { </Box>
dispatch(requestCanvasRescale());
}}
>
<TextToImageTabMain />
</Panel>
</PanelGroup>
</Box>
</Flex>
); );
}; };

View File

@ -5,16 +5,27 @@ import { PanelResizeHandle } from 'react-resizable-panels';
type ResizeHandleProps = Omit<FlexProps, 'direction'> & { type ResizeHandleProps = Omit<FlexProps, 'direction'> & {
direction?: 'horizontal' | 'vertical'; direction?: 'horizontal' | 'vertical';
collapsedDirection?: 'top' | 'bottom' | 'left' | 'right'; collapsedDirection?: 'top' | 'bottom' | 'left' | 'right';
isCollapsed?: boolean;
}; };
const ResizeHandle = (props: ResizeHandleProps) => { const ResizeHandle = (props: ResizeHandleProps) => {
const { direction = 'horizontal', collapsedDirection, ...rest } = props; const {
direction = 'horizontal',
collapsedDirection,
isCollapsed = false,
...rest
} = props;
const bg = useColorModeValue('base.100', 'base.850'); const bg = useColorModeValue('base.100', 'base.850');
const hoverBg = useColorModeValue('base.300', 'base.700'); const hoverBg = useColorModeValue('base.300', 'base.700');
if (direction === 'horizontal') { if (direction === 'horizontal') {
return ( return (
<PanelResizeHandle> <PanelResizeHandle
style={{
visibility: isCollapsed ? 'hidden' : 'visible',
width: isCollapsed ? 0 : 'auto',
}}
>
<Flex <Flex
className="resize-handle-horizontal" className="resize-handle-horizontal"
sx={{ sx={{
@ -50,7 +61,12 @@ const ResizeHandle = (props: ResizeHandleProps) => {
} }
return ( return (
<PanelResizeHandle> <PanelResizeHandle
style={{
visibility: isCollapsed ? 'hidden' : 'visible',
width: isCollapsed ? 0 : 'auto',
}}
>
<Flex <Flex
className="resize-handle-vertical" className="resize-handle-vertical"
sx={{ sx={{

View File

@ -1,26 +1,8 @@
import { Flex } from '@chakra-ui/react';
import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import SDXLTextToImageTabParameters from 'features/sdxl/components/SDXLTextToImageTabParameters';
import { memo } from 'react'; import { memo } from 'react';
import ParametersPinnedWrapper from '../../ParametersPinnedWrapper';
import TextToImageTabMain from './TextToImageTabMain'; import TextToImageTabMain from './TextToImageTabMain';
import TextToImageTabParameters from './TextToImageTabParameters';
const TextToImageTab = () => { const TextToImageTab = () => {
const model = useAppSelector((state: RootState) => state.generation.model); return <TextToImageTabMain />;
return (
<Flex sx={{ gap: 4, w: 'full', h: 'full' }}>
<ParametersPinnedWrapper>
{model && model.base_model === 'sdxl' ? (
<SDXLTextToImageTabParameters />
) : (
<TextToImageTabParameters />
)}
</ParametersPinnedWrapper>
<TextToImageTabMain />
</Flex>
);
}; };
export default memo(TextToImageTab); export default memo(TextToImageTab);

View File

@ -1,45 +0,0 @@
import { Flex } from '@chakra-ui/layout';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import CancelButton from 'features/parameters/components/ProcessButtons/CancelButton';
import InvokeButton from 'features/parameters/components/ProcessButtons/InvokeButton';
import { setShouldShowParametersPanel } from 'features/ui/store/uiSlice';
import { useTranslation } from 'react-i18next';
import { FaSlidersH } from 'react-icons/fa';
export default function UnifiedCanvasProcessingButtons() {
const shouldPinParametersPanel = useAppSelector(
(state) => state.ui.shouldPinParametersPanel
);
const shouldShowParametersPanel = useAppSelector(
(state) => state.ui.shouldShowParametersPanel
);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const handleShowOptionsPanel = () => {
dispatch(setShouldShowParametersPanel(true));
shouldPinParametersPanel && dispatch(requestCanvasRescale());
};
return !shouldPinParametersPanel || !shouldShowParametersPanel ? (
<Flex flexDirection="column" gap={2}>
<IAIIconButton
tooltip={`${t('parameters.showOptionsPanel')} (O)`}
tooltipProps={{ placement: 'top' }}
aria-label={t('parameters.showOptionsPanel')}
onClick={handleShowOptionsPanel}
>
<FaSlidersH />
</IAIIconButton>
<Flex>
<InvokeButton iconButton />
</Flex>
<Flex>
<CancelButton width="100%" height="40px" btnGroupWidth="100%" />
</Flex>
</Flex>
) : null;
}

View File

@ -2,6 +2,7 @@ import { Flex } from '@chakra-ui/react';
import IAICanvasRedoButton from 'features/canvas/components/IAICanvasToolbar/IAICanvasRedoButton'; import IAICanvasRedoButton from 'features/canvas/components/IAICanvasToolbar/IAICanvasRedoButton';
import IAICanvasUndoButton from 'features/canvas/components/IAICanvasToolbar/IAICanvasUndoButton'; import IAICanvasUndoButton from 'features/canvas/components/IAICanvasToolbar/IAICanvasUndoButton';
import { memo } from 'react';
import UnifiedCanvasSettings from './UnifiedCanvasToolSettings/UnifiedCanvasSettings'; import UnifiedCanvasSettings from './UnifiedCanvasToolSettings/UnifiedCanvasSettings';
import UnifiedCanvasCopyToClipboard from './UnifiedCanvasToolbar/UnifiedCanvasCopyToClipboard'; import UnifiedCanvasCopyToClipboard from './UnifiedCanvasToolbar/UnifiedCanvasCopyToClipboard';
import UnifiedCanvasDownloadImage from './UnifiedCanvasToolbar/UnifiedCanvasDownloadImage'; import UnifiedCanvasDownloadImage from './UnifiedCanvasToolbar/UnifiedCanvasDownloadImage';
@ -9,12 +10,10 @@ import UnifiedCanvasFileUploader from './UnifiedCanvasToolbar/UnifiedCanvasFileU
import UnifiedCanvasLayerSelect from './UnifiedCanvasToolbar/UnifiedCanvasLayerSelect'; import UnifiedCanvasLayerSelect from './UnifiedCanvasToolbar/UnifiedCanvasLayerSelect';
import UnifiedCanvasMergeVisible from './UnifiedCanvasToolbar/UnifiedCanvasMergeVisible'; import UnifiedCanvasMergeVisible from './UnifiedCanvasToolbar/UnifiedCanvasMergeVisible';
import UnifiedCanvasMoveTool from './UnifiedCanvasToolbar/UnifiedCanvasMoveTool'; import UnifiedCanvasMoveTool from './UnifiedCanvasToolbar/UnifiedCanvasMoveTool';
import UnifiedCanvasProcessingButtons from './UnifiedCanvasToolbar/UnifiedCanvasProcessingButtons';
import UnifiedCanvasResetCanvas from './UnifiedCanvasToolbar/UnifiedCanvasResetCanvas'; import UnifiedCanvasResetCanvas from './UnifiedCanvasToolbar/UnifiedCanvasResetCanvas';
import UnifiedCanvasResetView from './UnifiedCanvasToolbar/UnifiedCanvasResetView'; import UnifiedCanvasResetView from './UnifiedCanvasToolbar/UnifiedCanvasResetView';
import UnifiedCanvasSaveToGallery from './UnifiedCanvasToolbar/UnifiedCanvasSaveToGallery'; import UnifiedCanvasSaveToGallery from './UnifiedCanvasToolbar/UnifiedCanvasSaveToGallery';
import UnifiedCanvasToolSelect from './UnifiedCanvasToolbar/UnifiedCanvasToolSelect'; import UnifiedCanvasToolSelect from './UnifiedCanvasToolbar/UnifiedCanvasToolSelect';
import { memo } from 'react';
const UnifiedCanvasToolbarBeta = () => { const UnifiedCanvasToolbarBeta = () => {
return ( return (
@ -47,7 +46,6 @@ const UnifiedCanvasToolbarBeta = () => {
</Flex> </Flex>
<UnifiedCanvasSettings /> <UnifiedCanvasSettings />
<UnifiedCanvasProcessingButtons />
</Flex> </Flex>
); );
}; };

View File

@ -1,26 +1,8 @@
import { Flex } from '@chakra-ui/react';
import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import SDXLUnifiedCanvasTabParameters from 'features/sdxl/components/SDXLUnifiedCanvasTabParameters';
import { memo } from 'react'; import { memo } from 'react';
import ParametersPinnedWrapper from '../../ParametersPinnedWrapper';
import UnifiedCanvasContent from './UnifiedCanvasContent'; import UnifiedCanvasContent from './UnifiedCanvasContent';
import UnifiedCanvasParameters from './UnifiedCanvasParameters';
const UnifiedCanvasTab = () => { const UnifiedCanvasTab = () => {
const model = useAppSelector((state: RootState) => state.generation.model); return <UnifiedCanvasContent />;
return (
<Flex sx={{ gap: 4, w: 'full', h: 'full' }}>
<ParametersPinnedWrapper>
{model && model.base_model === 'sdxl' ? (
<SDXLUnifiedCanvasTabParameters />
) : (
<UnifiedCanvasParameters />
)}
</ParametersPinnedWrapper>
<UnifiedCanvasContent />
</Flex>
);
}; };
export default memo(UnifiedCanvasTab); export default memo(UnifiedCanvasTab);

View File

@ -1,75 +0,0 @@
// adapted from https://github.com/bvaughn/react-resizable-panels/issues/141#issuecomment-1540048714
import {
RefObject,
useCallback,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { ImperativePanelHandle } from 'react-resizable-panels';
export const useMinimumPanelSize = (
minSizePx: number,
defaultSizePct: number,
groupId: string,
orientation: 'horizontal' | 'vertical' = 'horizontal'
): {
ref: RefObject<ImperativePanelHandle>;
minSizePct: number;
} => {
const ref = useRef<ImperativePanelHandle>(null);
const [minSizePct, setMinSizePct] = useState(defaultSizePct);
const handleWindowResize = useCallback(() => {
const size = ref.current?.getSize();
if (size !== undefined && size < minSizePct) {
ref.current?.resize(minSizePct);
}
}, [minSizePct]);
useLayoutEffect(() => {
const panelGroup = document.querySelector(
`[data-panel-group-id="${groupId}"]`
);
const resizeHandles = document.querySelectorAll(
orientation === 'horizontal'
? '.resize-handle-horizontal'
: '.resize-handle-vertical'
);
if (!panelGroup) {
return;
}
const observer = new ResizeObserver(() => {
let dim =
orientation === 'horizontal'
? panelGroup.getBoundingClientRect().width
: panelGroup.getBoundingClientRect().height;
resizeHandles.forEach((resizeHandle) => {
dim -=
orientation === 'horizontal'
? resizeHandle.getBoundingClientRect().width
: resizeHandle.getBoundingClientRect().height;
});
// Minimum size in pixels is a percentage of the PanelGroup's width/height
setMinSizePct((minSizePx / dim) * 100);
});
observer.observe(panelGroup);
resizeHandles.forEach((resizeHandle) => {
observer.observe(resizeHandle);
});
window.addEventListener('resize', handleWindowResize);
return () => {
observer.disconnect();
window.removeEventListener('resize', handleWindowResize);
};
}, [groupId, handleWindowResize, minSizePct, minSizePx, orientation]);
return { ref, minSizePct };
};

View File

@ -0,0 +1,52 @@
import { useCallback, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { ImperativePanelHandle, Units } from 'react-resizable-panels';
export const usePanel = (minSize: number, units: Units) => {
const ref = useRef<ImperativePanelHandle>(null);
const [isCollapsed, setIsCollapsed] = useState(() =>
Boolean(ref.current?.getCollapsed())
);
const toggle = useCallback(() => {
if (ref.current?.getCollapsed()) {
flushSync(() => {
ref.current?.expand();
});
} else {
flushSync(() => {
ref.current?.collapse();
});
}
}, []);
const expand = useCallback(() => {
flushSync(() => {
ref.current?.expand();
});
}, []);
const collapse = useCallback(() => {
flushSync(() => {
ref.current?.collapse();
});
}, []);
const reset = useCallback(() => {
flushSync(() => {
ref.current?.resize(minSize, units);
});
}, [minSize, units]);
return {
ref,
minSize,
isCollapsed,
setIsCollapsed,
reset,
toggle,
expand,
collapse,
};
};

View File

@ -0,0 +1,17 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useCallback } from 'react';
import { panelsChanged } from '../store/uiSlice';
export const usePanelStorage = () => {
const dispatch = useAppDispatch();
const panels = useAppSelector((state) => state.ui.panels);
const getItem = useCallback((name: string) => panels[name] ?? '', [panels]);
const setItem = useCallback(
(name: string, value: string) => {
dispatch(panelsChanged({ name, value }));
},
[dispatch]
);
return { getItem, setItem };
};

View File

@ -6,4 +6,5 @@ import { UIState } from './uiTypes';
export const uiPersistDenylist: (keyof UIState)[] = [ export const uiPersistDenylist: (keyof UIState)[] = [
'shouldShowImageDetails', 'shouldShowImageDetails',
'globalContextMenuCloseTrigger', 'globalContextMenuCloseTrigger',
'panels',
]; ];

View File

@ -8,19 +8,16 @@ import { UIState } from './uiTypes';
export const initialUIState: UIState = { export const initialUIState: UIState = {
activeTab: 0, activeTab: 0,
shouldPinParametersPanel: true,
shouldShowParametersPanel: true,
shouldShowImageDetails: false, shouldShowImageDetails: false,
shouldUseCanvasBetaLayout: false, shouldUseCanvasBetaLayout: false,
shouldShowExistingModelsInSearch: false, shouldShowExistingModelsInSearch: false,
shouldUseSliders: false, shouldUseSliders: false,
shouldPinGallery: true,
shouldShowGallery: true,
shouldHidePreview: false, shouldHidePreview: false,
shouldShowProgressInViewer: true, shouldShowProgressInViewer: true,
shouldShowEmbeddingPicker: false, shouldShowEmbeddingPicker: false,
favoriteSchedulers: [], favoriteSchedulers: [],
globalContextMenuCloseTrigger: 0, globalContextMenuCloseTrigger: 0,
panels: {},
}; };
export const uiSlice = createSlice({ export const uiSlice = createSlice({
@ -30,13 +27,6 @@ export const uiSlice = createSlice({
setActiveTab: (state, action: PayloadAction<InvokeTabName>) => { setActiveTab: (state, action: PayloadAction<InvokeTabName>) => {
setActiveTabReducer(state, action.payload); setActiveTabReducer(state, action.payload);
}, },
setShouldPinParametersPanel: (state, action: PayloadAction<boolean>) => {
state.shouldPinParametersPanel = action.payload;
state.shouldShowParametersPanel = true;
},
setShouldShowParametersPanel: (state, action: PayloadAction<boolean>) => {
state.shouldShowParametersPanel = action.payload;
},
setShouldShowImageDetails: (state, action: PayloadAction<boolean>) => { setShouldShowImageDetails: (state, action: PayloadAction<boolean>) => {
state.shouldShowImageDetails = action.payload; state.shouldShowImageDetails = action.payload;
}, },
@ -55,36 +45,6 @@ export const uiSlice = createSlice({
setShouldUseSliders: (state, action: PayloadAction<boolean>) => { setShouldUseSliders: (state, action: PayloadAction<boolean>) => {
state.shouldUseSliders = action.payload; state.shouldUseSliders = action.payload;
}, },
setShouldShowGallery: (state, action: PayloadAction<boolean>) => {
state.shouldShowGallery = action.payload;
},
togglePinGalleryPanel: (state) => {
state.shouldPinGallery = !state.shouldPinGallery;
if (!state.shouldPinGallery) {
state.shouldShowGallery = true;
}
},
togglePinParametersPanel: (state) => {
state.shouldPinParametersPanel = !state.shouldPinParametersPanel;
if (!state.shouldPinParametersPanel) {
state.shouldShowParametersPanel = true;
}
},
toggleParametersPanel: (state) => {
state.shouldShowParametersPanel = !state.shouldShowParametersPanel;
},
toggleGalleryPanel: (state) => {
state.shouldShowGallery = !state.shouldShowGallery;
},
togglePanels: (state) => {
if (state.shouldShowGallery || state.shouldShowParametersPanel) {
state.shouldShowGallery = false;
state.shouldShowParametersPanel = false;
} else {
state.shouldShowGallery = true;
state.shouldShowParametersPanel = true;
}
},
setShouldShowProgressInViewer: (state, action: PayloadAction<boolean>) => { setShouldShowProgressInViewer: (state, action: PayloadAction<boolean>) => {
state.shouldShowProgressInViewer = action.payload; state.shouldShowProgressInViewer = action.payload;
}, },
@ -100,6 +60,12 @@ export const uiSlice = createSlice({
contextMenusClosed: (state) => { contextMenusClosed: (state) => {
state.globalContextMenuCloseTrigger += 1; state.globalContextMenuCloseTrigger += 1;
}, },
panelsChanged: (
state,
action: PayloadAction<{ name: string; value: string }>
) => {
state.panels[action.payload.name] = action.payload.value;
},
}, },
extraReducers(builder) { extraReducers(builder) {
builder.addCase(initialImageChanged, (state) => { builder.addCase(initialImageChanged, (state) => {
@ -110,23 +76,16 @@ export const uiSlice = createSlice({
export const { export const {
setActiveTab, setActiveTab,
setShouldPinParametersPanel,
setShouldShowParametersPanel,
setShouldShowImageDetails, setShouldShowImageDetails,
setShouldUseCanvasBetaLayout, setShouldUseCanvasBetaLayout,
setShouldShowExistingModelsInSearch, setShouldShowExistingModelsInSearch,
setShouldUseSliders, setShouldUseSliders,
setShouldHidePreview, setShouldHidePreview,
setShouldShowGallery,
togglePanels,
togglePinGalleryPanel,
togglePinParametersPanel,
toggleParametersPanel,
toggleGalleryPanel,
setShouldShowProgressInViewer, setShouldShowProgressInViewer,
favoriteSchedulersChanged, favoriteSchedulersChanged,
toggleEmbeddingPicker, toggleEmbeddingPicker,
contextMenusClosed, contextMenusClosed,
panelsChanged,
} = uiSlice.actions; } = uiSlice.actions;
export default uiSlice.reducer; export default uiSlice.reducer;

View File

@ -14,17 +14,14 @@ export type Rect = Coordinates & Dimensions;
export interface UIState { export interface UIState {
activeTab: number; activeTab: number;
shouldPinParametersPanel: boolean;
shouldShowParametersPanel: boolean;
shouldShowImageDetails: boolean; shouldShowImageDetails: boolean;
shouldUseCanvasBetaLayout: boolean; shouldUseCanvasBetaLayout: boolean;
shouldShowExistingModelsInSearch: boolean; shouldShowExistingModelsInSearch: boolean;
shouldUseSliders: boolean; shouldUseSliders: boolean;
shouldHidePreview: boolean; shouldHidePreview: boolean;
shouldPinGallery: boolean;
shouldShowGallery: boolean;
shouldShowProgressInViewer: boolean; shouldShowProgressInViewer: boolean;
shouldShowEmbeddingPicker: boolean; shouldShowEmbeddingPicker: boolean;
favoriteSchedulers: SchedulerParam[]; favoriteSchedulers: SchedulerParam[];
globalContextMenuCloseTrigger: number; globalContextMenuCloseTrigger: number;
panels: Record<string, string>;
} }

View File

@ -45,6 +45,11 @@ export const theme: ThemeOverride = {
color: 'base.900', color: 'base.900',
'.chakra-ui-dark &': { bg: 'base.800', color: 'base.100' }, '.chakra-ui-dark &': { bg: 'base.800', color: 'base.100' },
}, },
third: {
bg: 'base.300',
color: 'base.900',
'.chakra-ui-dark &': { bg: 'base.750', color: 'base.100' },
},
nodeBody: { nodeBody: {
bg: 'base.100', bg: 'base.100',
color: 'base.900', color: 'base.900',

View File

@ -5633,11 +5633,6 @@ rc@^1.2.7:
minimist "^1.2.0" minimist "^1.2.0"
strip-json-comments "~2.0.1" strip-json-comments "~2.0.1"
re-resizable@^6.9.11:
version "6.9.11"
resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.9.11.tgz#f356e27877f12d926d076ab9ad9ff0b95912b475"
integrity sha512-a3hiLWck/NkmyLvGWUuvkAmN1VhwAz4yOhS6FdMTaxCUVN9joIWkT11wsO68coG/iEYuwn+p/7qAmfQzRhiPLQ==
react-clientside-effect@^1.2.6: react-clientside-effect@^1.2.6:
version "1.2.6" version "1.2.6"
resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz#29f9b14e944a376b03fb650eed2a754dd128ea3a" resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz#29f9b14e944a376b03fb650eed2a754dd128ea3a"