feat(ui): revise app layout strategy, add interaction scopes for hotkeys

This commit is contained in:
psychedelicious 2024-08-18 23:37:49 +10:00
parent d6b3e6c07d
commit f78f4ca25f
33 changed files with 807 additions and 613 deletions

View File

@ -9,6 +9,7 @@ import ImageUploadOverlay from 'common/components/ImageUploadOverlay';
import { useClearStorage } from 'common/hooks/useClearStorage';
import { useFullscreenDropzone } from 'common/hooks/useFullscreenDropzone';
import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
import { useScopeFocusWatcher } from 'common/hooks/interactionScopes';
import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal';
import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal';
import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal';
@ -16,7 +17,7 @@ import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterM
import { StylePresetModal } from 'features/stylePresets/components/StylePresetForm/StylePresetModal';
import { configChanged } from 'features/system/store/configSlice';
import { languageSelector } from 'features/system/store/systemSelectors';
import InvokeTabs from 'features/ui/components/InvokeTabs';
import { AppContent } from 'features/ui/components/AppContent';
import type { InvokeTabName } from 'features/ui/store/tabMap';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow';
@ -93,6 +94,7 @@ const App = ({ config = DEFAULT_CONFIG, selectedImage, selectedWorkflowId, desti
useStarterModelsToast();
useSyncQueueStatus();
useScopeFocusWatcher();
return (
<ErrorBoundary onReset={handleReset} FallbackComponent={AppErrorBoundaryFallback}>
@ -105,7 +107,7 @@ const App = ({ config = DEFAULT_CONFIG, selectedImage, selectedWorkflowId, desti
{...dropzone.getRootProps()}
>
<input {...dropzone.getInputProps()} />
<InvokeTabs />
<AppContent />
<AnimatePresence>
{dropzone.isDragActive && isHandlingUpload && (
<ImageUploadOverlay dropzone={dropzone} setIsHandlingUpload={setIsHandlingUpload} />

View File

@ -0,0 +1,163 @@
import { logger } from 'app/logging/logger';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { objectKeys } from 'common/util/objectKeys';
import { isEqual } from 'lodash-es';
import type { Atom } from 'nanostores';
import { atom, computed } from 'nanostores';
import type { RefObject } from 'react';
import { useEffect, useMemo } from 'react';
const log = logger('system');
const _INTERACTION_SCOPES = ['gallery', 'canvas', 'workflows', 'imageViewer'] as const;
type InteractionScope = (typeof _INTERACTION_SCOPES)[number];
export const $activeScopes = atom<Set<InteractionScope>>(new Set());
type InteractionScopeData = {
targets: Set<HTMLElement>;
$isActive: Atom<boolean>;
};
export const INTERACTION_SCOPES: Record<InteractionScope, InteractionScopeData> = _INTERACTION_SCOPES.reduce(
(acc, region) => {
acc[region] = {
targets: new Set(),
$isActive: computed($activeScopes, (activeScopes) => activeScopes.has(region)),
};
return acc;
},
{} as Record<InteractionScope, InteractionScopeData>
);
const formatScopes = (interactionScopes: Set<InteractionScope>) => {
if (interactionScopes.size === 0) {
return 'none';
}
return Array.from(interactionScopes).join(', ');
};
export const addScope = (scope: InteractionScope) => {
const currentScopes = $activeScopes.get();
if (currentScopes.has(scope)) {
return;
}
const newScopes = new Set(currentScopes);
newScopes.add(scope);
$activeScopes.set(newScopes);
log.trace(`Added scope ${scope}: ${formatScopes($activeScopes.get())}`);
};
export const removeScope = (scope: InteractionScope) => {
const currentScopes = $activeScopes.get();
if (!currentScopes.has(scope)) {
return;
}
const newScopes = new Set(currentScopes);
newScopes.delete(scope);
$activeScopes.set(newScopes);
log.trace(`Removed scope ${scope}: ${formatScopes($activeScopes.get())}`);
};
export const setScopes = (scopes: InteractionScope[]) => {
const newScopes = new Set(scopes);
$activeScopes.set(newScopes);
log.trace(`Set scopes: ${formatScopes($activeScopes.get())}`);
};
export const clearScopes = () => {
$activeScopes.set(new Set());
log.trace(`Cleared scopes`);
};
export const useScopeOnFocus = (scope: InteractionScope, ref: RefObject<HTMLElement>) => {
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
INTERACTION_SCOPES[scope].targets.add(element);
return () => {
INTERACTION_SCOPES[scope].targets.delete(element);
};
}, [ref, scope]);
};
type UseScopeOnMountOptions = {
mount?: boolean;
unmount?: boolean;
};
const defaultUseScopeOnMountOptions: UseScopeOnMountOptions = {
mount: true,
unmount: true,
};
export const useScopeOnMount = (scope: InteractionScope, options?: UseScopeOnMountOptions) => {
useEffect(() => {
const { mount, unmount } = { ...defaultUseScopeOnMountOptions, ...options };
if (mount) {
addScope(scope);
}
return () => {
if (unmount) {
removeScope(scope);
}
};
}, [options, scope]);
};
export const useScopeImperativeApi = (scope: InteractionScope) => {
const api = useMemo(() => {
return {
add: () => {
addScope(scope);
},
remove: () => {
removeScope(scope);
},
};
}, [scope]);
return api;
};
const handleFocusEvent = (_event: FocusEvent) => {
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement)) {
return;
}
const newActiveScopes = new Set<InteractionScope>();
for (const scope of objectKeys(INTERACTION_SCOPES)) {
for (const element of INTERACTION_SCOPES[scope].targets) {
if (element.contains(activeElement)) {
newActiveScopes.add(scope);
}
}
}
const oldActiveScopes = $activeScopes.get();
if (!isEqual(oldActiveScopes, newActiveScopes)) {
$activeScopes.set(newActiveScopes);
log.trace(`Scopes changed: ${formatScopes($activeScopes.get())}`);
}
};
export const useScopeFocusWatcher = () => {
useAssertSingleton('useScopeFocusWatcher');
useEffect(() => {
window.addEventListener('focus', handleFocusEvent, true);
return () => {
window.removeEventListener('focus', handleFocusEvent, true);
};
}, []);
};

View File

@ -1,4 +1,5 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { addScope, removeScope, setScopes } from 'common/hooks/interactionScopes';
import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem';
import { useClearQueue } from 'features/queue/hooks/useClearQueue';
import { useQueueBack } from 'features/queue/hooks/useQueueBack';
@ -16,7 +17,7 @@ export const useGlobalHotkeys = () => {
['ctrl+enter', 'meta+enter'],
queueBack,
{
enabled: () => !isDisabledQueueBack && !isLoadingQueueBack,
enabled: !isDisabledQueueBack && !isLoadingQueueBack,
preventDefault: true,
enableOnFormTags: ['input', 'textarea', 'select'],
},
@ -29,7 +30,7 @@ export const useGlobalHotkeys = () => {
['ctrl+shift+enter', 'meta+shift+enter'],
queueFront,
{
enabled: () => !isDisabledQueueFront && !isLoadingQueueFront,
enabled: !isDisabledQueueFront && !isLoadingQueueFront,
preventDefault: true,
enableOnFormTags: ['input', 'textarea', 'select'],
},
@ -46,7 +47,7 @@ export const useGlobalHotkeys = () => {
['shift+x'],
cancelQueueItem,
{
enabled: () => !isDisabledCancelQueueItem && !isLoadingCancelQueueItem,
enabled: !isDisabledCancelQueueItem && !isLoadingCancelQueueItem,
preventDefault: true,
},
[cancelQueueItem, isDisabledCancelQueueItem, isLoadingCancelQueueItem]
@ -58,7 +59,7 @@ export const useGlobalHotkeys = () => {
['ctrl+shift+x', 'meta+shift+x'],
clearQueue,
{
enabled: () => !isDisabledClearQueue && !isLoadingClearQueue,
enabled: !isDisabledClearQueue && !isLoadingClearQueue,
preventDefault: true,
},
[clearQueue, isDisabledClearQueue, isLoadingClearQueue]
@ -68,6 +69,8 @@ export const useGlobalHotkeys = () => {
'1',
() => {
dispatch(setActiveTab('generation'));
addScope('canvas');
removeScope('workflows');
},
[dispatch]
);
@ -75,25 +78,39 @@ export const useGlobalHotkeys = () => {
useHotkeys(
'2',
() => {
dispatch(setActiveTab('workflows'));
dispatch(setActiveTab('upscaling'));
removeScope('canvas');
removeScope('workflows');
},
[dispatch]
);
useHotkeys(
'3',
() => {
dispatch(setActiveTab('workflows'));
removeScope('canvas');
addScope('workflows');
},
[dispatch]
);
useHotkeys(
'4',
() => {
if (isModelManagerEnabled) {
dispatch(setActiveTab('models'));
setScopes([]);
}
},
[dispatch, isModelManagerEnabled]
);
useHotkeys(
isModelManagerEnabled ? '4' : '3',
isModelManagerEnabled ? '5' : '4',
() => {
dispatch(setActiveTab('queue'));
setScopes([]);
},
[dispatch, isModelManagerEnabled]
);

View File

@ -1,6 +1,7 @@
import { Flex } from '@invoke-ai/ui-library';
import IAIDroppable from 'common/components/IAIDroppable';
import type { AddLayerFromImageDropData } from 'features/dnd/types';
import { useIsImageViewerOpen } from 'features/gallery/components/ImageViewer/useImageViewer';
import { memo } from 'react';
const addLayerFromImageDropData: AddLayerFromImageDropData = {
@ -9,6 +10,12 @@ const addLayerFromImageDropData: AddLayerFromImageDropData = {
};
export const CanvasDropArea = memo(() => {
const isImageViewerOpen = useIsImageViewerOpen();
if (isImageViewerOpen) {
return null;
}
return (
<Flex position="absolute" top={0} right={0} bottom={0} left={0} gap={2} pointerEvents="none">
<IAIDroppable dropLabel="Create Layer" data={addLayerFromImageDropData} />

View File

@ -1,5 +1,6 @@
import { $shift, IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { INTERACTION_SCOPES } from 'common/hooks/interactionScopes';
import { $canvasManager } from 'features/controlLayers/konva/CanvasManager';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
@ -9,6 +10,7 @@ import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
export const CanvasResetViewButton = memo(() => {
const { t } = useTranslation();
const canvasManager = useStore($canvasManager);
const isCanvasActive = useStore(INTERACTION_SCOPES.canvas.$isActive);
const resetZoom = useCallback(() => {
if (!canvasManager) {
@ -32,8 +34,8 @@ export const CanvasResetViewButton = memo(() => {
}
}, [resetView, resetZoom]);
useHotkeys('r', resetView);
useHotkeys('shift+r', resetZoom);
useHotkeys('r', resetView, { enabled: isCanvasActive }, [isCanvasActive]);
useHotkeys('shift+r', resetZoom, { enabled: isCanvasActive }, [isCanvasActive]);
return (
<IconButton

View File

@ -1,20 +1,20 @@
import { Flex } from '@invoke-ai/ui-library';
import type { Meta, StoryObj } from '@storybook/react';
import { ControlLayersEditor } from 'features/controlLayers/components/ControlLayersEditor';
import { CanvasEditor } from 'features/controlLayers/components/ControlLayersEditor';
const meta: Meta<typeof ControlLayersEditor> = {
const meta: Meta<typeof CanvasEditor> = {
title: 'Feature/ControlLayers',
tags: ['autodocs'],
component: ControlLayersEditor,
component: CanvasEditor,
};
export default meta;
type Story = StoryObj<typeof ControlLayersEditor>;
type Story = StoryObj<typeof CanvasEditor>;
const Component = () => {
return (
<Flex w={1500} h={1500}>
<ControlLayersEditor />
<CanvasEditor />
</Flex>
);
};

View File

@ -1,18 +1,27 @@
/* eslint-disable i18next/no-literal-string */
import { Flex } from '@invoke-ai/ui-library';
import { useScopeOnFocus } from 'common/hooks/interactionScopes';
import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea';
import { ControlLayersToolbar } from 'features/controlLayers/components/ControlLayersToolbar';
import { StageComponent } from 'features/controlLayers/components/StageComponent';
import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
import { memo } from 'react';
import { memo, useRef } from 'react';
export const CanvasEditor = memo(() => {
const ref = useRef<HTMLDivElement>(null);
useScopeOnFocus('canvas', ref);
export const ControlLayersEditor = memo(() => {
return (
<Flex
tabIndex={-1}
ref={ref}
layerStyle="first"
p={2}
borderRadius="base"
position="relative"
flexDirection="column"
height="100%"
width="100%"
height="full"
width="full"
gap={2}
alignItems="center"
justifyContent="center"
@ -22,12 +31,9 @@ export const ControlLayersEditor = memo(() => {
<Flex position="absolute" bottom={2} gap={2} align="center" justify="center">
<StagingAreaToolbar />
</Flex>
{/* <Flex position="absolute" top={0} right={0} bottom={0} left={0} align="center" justify="center">
<CanvasResizer />
</Flex> */}
<CanvasDropArea />
</Flex>
);
});
ControlLayersEditor.displayName = 'ControlLayersEditor';
CanvasEditor.displayName = 'CanvasEditor';

View File

@ -80,7 +80,7 @@ export const StageComponent = memo(({ asPreview = false }: Props) => {
);
return (
<Flex position="relative" w="full" h="full">
<Flex position="relative" w="full" h="full" bg={canvasBackgroundStyle === 'checkerboard' ? 'base.900' : 'base.850'}>
{canvasBackgroundStyle === 'checkerboard' && (
<Flex
position="absolute"

View File

@ -1,6 +1,7 @@
import { Button, ButtonGroup, IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { INTERACTION_SCOPES } from 'common/hooks/interactionScopes';
import {
$shouldShowStagedImage,
sessionNextStagedImageSelected,
@ -37,13 +38,13 @@ StagingAreaToolbar.displayName = 'StagingAreaToolbar';
export const StagingAreaToolbarContent = memo(() => {
const dispatch = useAppDispatch();
const stagingArea = useAppSelector((s) => s.canvasV2.session);
const session = useAppSelector((s) => s.canvasV2.session);
const shouldShowStagedImage = useStore($shouldShowStagedImage);
const images = useMemo(() => stagingArea.stagedImages, [stagingArea]);
const images = useMemo(() => session.stagedImages, [session]);
const selectedImage = useMemo(() => {
return images[stagingArea.selectedStagedImageIndex] ?? null;
}, [images, stagingArea.selectedStagedImageIndex]);
return images[session.selectedStagedImageIndex] ?? null;
}, [images, session.selectedStagedImageIndex]);
const isCanvasActive = useStore(INTERACTION_SCOPES.canvas.$isActive);
// const [changeIsImageIntermediate] = useChangeImageIsIntermediateMutation();
const { t } = useTranslation();
@ -60,8 +61,8 @@ export const StagingAreaToolbarContent = memo(() => {
if (!selectedImage) {
return;
}
dispatch(sessionStagingAreaImageAccepted({ index: stagingArea.selectedStagedImageIndex }));
}, [dispatch, selectedImage, stagingArea.selectedStagedImageIndex]);
dispatch(sessionStagingAreaImageAccepted({ index: session.selectedStagedImageIndex }));
}, [dispatch, selectedImage, session.selectedStagedImageIndex]);
const onDiscardOne = useCallback(() => {
if (!selectedImage) {
@ -70,9 +71,9 @@ export const StagingAreaToolbarContent = memo(() => {
if (images.length === 1) {
dispatch(sessionStagingAreaReset());
} else {
dispatch(sessionStagedImageDiscarded({ index: stagingArea.selectedStagedImageIndex }));
dispatch(sessionStagedImageDiscarded({ index: session.selectedStagedImageIndex }));
}
}, [selectedImage, images.length, dispatch, stagingArea.selectedStagedImageIndex]);
}, [selectedImage, images.length, dispatch, session.selectedStagedImageIndex]);
const onDiscardAll = useCallback(() => {
dispatch(sessionStagingAreaReset());
@ -95,25 +96,43 @@ export const StagingAreaToolbarContent = memo(() => {
]
);
useHotkeys(['left'], onPrev, {
useHotkeys(
['left'],
onPrev,
{
preventDefault: true,
});
enabled: isCanvasActive,
},
[isCanvasActive]
);
useHotkeys(['right'], onNext, {
useHotkeys(
['right'],
onNext,
{
preventDefault: true,
});
enabled: isCanvasActive,
},
[isCanvasActive]
);
useHotkeys(['enter'], onAccept, {
useHotkeys(
['enter'],
onAccept,
{
preventDefault: true,
});
enabled: isCanvasActive,
},
[isCanvasActive]
);
const counterText = useMemo(() => {
if (images.length > 0) {
return `${(stagingArea.selectedStagedImageIndex ?? 0) + 1} of ${images.length}`;
return `${(session.selectedStagedImageIndex ?? 0) + 1} of ${images.length}`;
} else {
return `0 of 0`;
}
}, [images.length, stagingArea.selectedStagedImageIndex]);
}, [images.length, session.selectedStagedImageIndex]);
return (
<>

View File

@ -1,5 +1,6 @@
import { Box, Button, Collapse, Divider, Flex, IconButton, useDisclosure } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useScopeOnFocus } from 'common/hooks/interactionScopes';
import { GalleryHeader } from 'features/gallery/components/GalleryHeader';
import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice';
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
@ -18,19 +19,21 @@ import GallerySettingsPopover from './GallerySettingsPopover/GallerySettingsPopo
const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0 };
const ImageGalleryContent = () => {
const GalleryPanelContent = () => {
const { t } = useTranslation();
const boardSearchText = useAppSelector((s) => s.gallery.boardSearchText);
const dispatch = useAppDispatch();
const boardSearchDisclosure = useDisclosure({ defaultIsOpen: !!boardSearchText.length });
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
const ref = useRef<HTMLDivElement>(null);
useScopeOnFocus('gallery', ref);
const boardsListPanelOptions = useMemo<UsePanelOptions>(
() => ({
id: 'boards-list-panel',
unit: 'pixels',
minSize: 128,
defaultSize: 256,
fallbackMinSizePct: 20,
defaultSize: 20,
panelGroupRef,
panelGroupDirection: 'vertical',
}),
@ -55,7 +58,7 @@ const ImageGalleryContent = () => {
}, [boardSearchText.length, boardSearchDisclosure, boardsListPanel, dispatch]);
return (
<Flex position="relative" flexDirection="column" h="full" w="full" pt={2}>
<Flex ref={ref} position="relative" flexDirection="column" h="full" w="full" pt={2} tabIndex={-1}>
<Flex alignItems="center" gap={0}>
<GalleryHeader />
<Flex alignItems="center" justifyContent="space-between" w="full">
@ -90,15 +93,7 @@ const ImageGalleryContent = () => {
</Flex>
<PanelGroup ref={panelGroupRef} direction="vertical">
<Panel
id="boards-list-panel"
ref={boardsListPanel.ref}
defaultSize={boardsListPanel.defaultSize}
minSize={boardsListPanel.minSize}
onCollapse={boardsListPanel.onCollapse}
onExpand={boardsListPanel.onExpand}
collapsible
>
<Panel collapsible {...boardsListPanel.panelProps}>
<Flex flexDir="column" w="full" h="full">
<Collapse in={boardSearchDisclosure.isOpen} style={COLLAPSE_STYLES}>
<Box w="full" pt={2}>
@ -109,11 +104,7 @@ const ImageGalleryContent = () => {
<BoardsListWrapper />
</Flex>
</Panel>
<ResizeHandle
id="gallery-panel-handle"
orientation="horizontal"
onDoubleClick={boardsListPanel.onDoubleClickHandle}
/>
<ResizeHandle id="gallery-panel-handle" orientation="horizontal" {...boardsListPanel.resizeHandleProps} />
<Panel id="gallery-wrapper-panel" minSize={20}>
<Gallery />
</Panel>
@ -122,4 +113,4 @@ const ImageGalleryContent = () => {
);
};
export default memo(ImageGalleryContent);
export default memo(GalleryPanelContent);

View File

@ -1,16 +1,25 @@
import { Tag, TagCloseButton, TagLabel } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { $activeScopes } from 'common/hooks/interactionScopes';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { selectionChanged } from 'features/gallery/store/gallerySlice';
import { $isGalleryPanelOpen } from 'features/ui/store/uiSlice';
import { computed } from 'nanostores';
import { useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
const $isSelectAllEnabled = computed([$activeScopes, $isGalleryPanelOpen], (activeScopes, isGalleryPanelOpen) => {
return activeScopes.has('gallery') && !activeScopes.has('workflows') && isGalleryPanelOpen;
});
export const GallerySelectionCountTag = () => {
const dispatch = useAppDispatch();
const { selection } = useAppSelector((s) => s.gallery);
const { t } = useTranslation();
const { imageDTOs } = useGalleryImages();
const isSelectAllEnabled = useStore($isSelectAllEnabled);
const onClearSelection = useCallback(() => {
dispatch(selectionChanged([]));
@ -20,7 +29,16 @@ export const GallerySelectionCountTag = () => {
dispatch(selectionChanged([...selection, ...imageDTOs]));
}, [dispatch, selection, imageDTOs]);
useHotkeys(['ctrl+a', 'meta+a'], onSelectPage, { preventDefault: true }, [onSelectPage]);
useHotkeys(['ctrl+a', 'meta+a'], onSelectPage, { preventDefault: true, enabled: isSelectAllEnabled }, [
onSelectPage,
isSelectAllEnabled,
]);
useHotkeys('esc', onClearSelection, { enabled: selection.length > 0 && isSelectAllEnabled }, [
onClearSelection,
selection,
isSelectAllEnabled,
]);
if (selection.length <= 1) {
return null;

View File

@ -4,6 +4,7 @@ import { skipToken } from '@reduxjs/toolkit/query';
import { $isConnected } from 'app/hooks/useSocketIO';
import { adHocPostProcessingRequested } from 'app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { INTERACTION_SCOPES } from 'common/hooks/interactionScopes';
import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems';
@ -46,7 +47,7 @@ const CurrentImageButtons = () => {
const isUpscalingEnabled = useFeatureStatus('upscaling');
const isQueueMutationInProgress = useIsQueueMutationInProgress();
const { t } = useTranslation();
const isImageViewerActive = useStore(INTERACTION_SCOPES.imageViewer.$isActive);
const { currentData: imageDTO } = useGetImageDTOQuery(lastSelectedImage?.image_name ?? skipToken);
const { recallAll, remix, recallSeed, recallPrompts, hasMetadata, hasSeed, hasPrompts, isLoadingMetadata } =
@ -61,18 +62,9 @@ const CurrentImageButtons = () => {
getAndLoadEmbeddedWorkflow(lastSelectedImage.image_name);
}, [getAndLoadEmbeddedWorkflow, lastSelectedImage]);
useHotkeys('w', handleLoadWorkflow, [lastSelectedImage]);
useHotkeys('a', recallAll, [recallAll]);
useHotkeys('s', recallSeed, [recallSeed]);
useHotkeys('p', recallPrompts, [recallPrompts]);
useHotkeys('r', remix, [remix]);
const handleUseSize = useCallback(() => {
parseAndRecallImageDimensions(lastSelectedImage);
}, [lastSelectedImage]);
useHotkeys('d', handleUseSize, [handleUseSize]);
const handleSendToImageToImage = useCallback(() => {
if (!imageDTO) {
return;
@ -81,9 +73,6 @@ const CurrentImageButtons = () => {
dispatch(sentImageToImg2Img());
dispatch(setActiveTab('generation'));
}, [dispatch, imageDTO]);
useHotkeys('shift+i', handleSendToImageToImage, [imageDTO]);
const handleClickUpscale = useCallback(() => {
if (!imageDTO) {
return;
@ -98,24 +87,21 @@ const CurrentImageButtons = () => {
dispatch(imagesToDeleteSelected(selection));
}, [dispatch, imageDTO, selection]);
useHotkeys('w', handleLoadWorkflow, { enabled: isImageViewerActive }, [lastSelectedImage, isImageViewerActive]);
useHotkeys('a', recallAll, { enabled: isImageViewerActive }, [recallAll, isImageViewerActive]);
useHotkeys('s', recallSeed, { enabled: isImageViewerActive }, [recallSeed, isImageViewerActive]);
useHotkeys('p', recallPrompts, { enabled: isImageViewerActive }, [recallPrompts, isImageViewerActive]);
useHotkeys('r', remix, { enabled: isImageViewerActive }, [remix, isImageViewerActive]);
useHotkeys('d', handleUseSize, { enabled: isImageViewerActive }, [handleUseSize, isImageViewerActive]);
useHotkeys('shift+i', handleSendToImageToImage, { enabled: isImageViewerActive }, [imageDTO, isImageViewerActive]);
useHotkeys(
'Shift+U',
() => {
handleClickUpscale();
},
{
enabled: () => Boolean(isUpscalingEnabled && !shouldDisableToolbarButtons && isConnected),
},
[isUpscalingEnabled, imageDTO, shouldDisableToolbarButtons, isConnected]
handleClickUpscale,
{ enabled: Boolean(isUpscalingEnabled && isImageViewerActive && isConnected) },
[isUpscalingEnabled, imageDTO, shouldDisableToolbarButtons, isConnected, isImageViewerActive]
);
useHotkeys(
'delete',
() => {
handleDelete();
},
[dispatch, imageDTO]
);
useHotkeys('delete', handleDelete, { enabled: isImageViewerActive }, [imageDTO, isImageViewerActive]);
return (
<>

View File

@ -1,21 +1,14 @@
import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import IAIDroppable from 'common/components/IAIDroppable';
import type { CurrentImageDropData, SelectForCompareDropData } from 'features/dnd/types';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import type { SelectForCompareDropData } from 'features/dnd/types';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { selectComparisonImages } from './common';
const setCurrentImageDropData: CurrentImageDropData = {
id: 'current-image',
actionType: 'SET_CURRENT_IMAGE',
};
export const ImageComparisonDroppable = memo(() => {
const { t } = useTranslation();
const imageViewer = useImageViewer();
const { firstImage, secondImage } = useAppSelector(selectComparisonImages);
const selectForCompareDropData = useMemo<SelectForCompareDropData>(
() => ({
@ -29,14 +22,6 @@ export const ImageComparisonDroppable = memo(() => {
[firstImage?.image_name, secondImage?.image_name]
);
if (!imageViewer.isOpen) {
return (
<Flex position="absolute" top={0} right={0} bottom={0} left={0} gap={2} pointerEvents="none">
<IAIDroppable data={setCurrentImageDropData} dropLabel={t('gallery.openInViewer')} />
</Flex>
);
}
return (
<Flex position="absolute" top={0} right={0} bottom={0} left={0} gap={2} pointerEvents="none">
<IAIDroppable data={selectForCompareDropData} dropLabel={t('gallery.selectForCompare')} />

View File

@ -1,19 +1,25 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useScopeOnFocus, useScopeOnMount } from 'common/hooks/interactionScopes';
import { CompareToolbar } from 'features/gallery/components/ImageViewer/CompareToolbar';
import CurrentImagePreview from 'features/gallery/components/ImageViewer/CurrentImagePreview';
import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison';
import { ImageComparisonDroppable } from 'features/gallery/components/ImageViewer/ImageComparisonDroppable';
import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar';
import { memo } from 'react';
import { memo, useRef } from 'react';
import { useMeasure } from 'react-use';
import { useImageViewer } from './useImageViewer';
export const ImageViewer = memo(() => {
const imageViewer = useImageViewer();
const isComparing = useAppSelector((s) => s.gallery.imageToCompare !== null);
const [containerRef, containerDims] = useMeasure<HTMLDivElement>();
const ref = useRef<HTMLDivElement>(null);
useScopeOnFocus('imageViewer', ref);
useScopeOnMount('imageViewer');
return (
<Flex
ref={ref}
tabIndex={-1}
layerStyle="first"
borderRadius="base"
position="absolute"
@ -27,12 +33,13 @@ export const ImageViewer = memo(() => {
alignItems="center"
justifyContent="center"
>
{imageViewer.isComparing && <CompareToolbar />}
{!imageViewer.isComparing && <ViewerToolbar />}
{isComparing && <CompareToolbar />}
{!isComparing && <ViewerToolbar />}
<Box ref={containerRef} w="full" h="full">
{!imageViewer.isComparing && <CurrentImagePreview />}
{imageViewer.isComparing && <ImageComparison containerDims={containerDims} />}
{!isComparing && <CurrentImagePreview />}
{isComparing && <ImageComparison containerDims={containerDims} />}
</Box>
<ImageComparisonDroppable />
</Flex>
);
});

View File

@ -2,30 +2,60 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { imageToCompareChanged, isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
import { useCallback } from 'react';
export const useIsImageViewerOpen = () => {
const isOpen = useAppSelector((s) => {
const tab = s.ui.activeTab;
const workflowsMode = s.workflow.mode;
if (tab === 'models' || tab === 'queue') {
return false;
}
if (tab === 'workflows' && workflowsMode === 'edit') {
return false;
}
if (tab === 'workflows' && workflowsMode === 'view') {
return true;
}
if (tab === 'upscaling') {
return true;
}
return s.gallery.isImageViewerOpen;
});
return isOpen;
};
export const useImageViewer = () => {
const dispatch = useAppDispatch();
const isComparing = useAppSelector((s) => s.gallery.imageToCompare !== null);
const isOpen = useAppSelector((s) => s.gallery.isImageViewerOpen);
const isNaturallyOpen = useAppSelector((s) => s.gallery.isImageViewerOpen);
const isForcedOpen = useAppSelector(
(s) => s.ui.activeTab === 'upscaling' || (s.ui.activeTab === 'workflows' && s.workflow.mode === 'view')
);
const onClose = useCallback(() => {
if (isComparing && isOpen) {
if (isForcedOpen) {
return;
}
if (isComparing && isNaturallyOpen) {
dispatch(imageToCompareChanged(null));
} else {
dispatch(isImageViewerOpenChanged(false));
}
}, [dispatch, isComparing, isOpen]);
}, [dispatch, isComparing, isForcedOpen, isNaturallyOpen]);
const onOpen = useCallback(() => {
dispatch(isImageViewerOpenChanged(true));
}, [dispatch]);
const onToggle = useCallback(() => {
if (isComparing && isOpen) {
if (isForcedOpen) {
return;
}
if (isComparing && isNaturallyOpen) {
dispatch(imageToCompareChanged(null));
} else {
dispatch(isImageViewerOpenChanged(!isOpen));
dispatch(isImageViewerOpenChanged(!isNaturallyOpen));
}
}, [dispatch, isComparing, isOpen]);
}, [dispatch, isComparing, isForcedOpen, isNaturallyOpen]);
return { isOpen, onOpen, onClose, onToggle, isComparing };
return { isOpen: isNaturallyOpen || isForcedOpen, onOpen, onClose, onToggle, isComparing };
};

View File

@ -1,19 +1,35 @@
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { $activeScopes } from 'common/hooks/interactionScopes';
import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation';
import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { $isGalleryPanelOpen } from 'features/ui/store/uiSlice';
import { computed } from 'nanostores';
import { useHotkeys } from 'react-hotkeys-hook';
import { useListImagesQuery } from 'services/api/endpoints/images';
const $leftRightHotkeysEnabled = computed($activeScopes, (activeScopes) => {
// The left and right hotkeys can be used when the gallery is focused and the canvas is not focused, OR when the image viewer is focused.
return (!activeScopes.has('staging-area') && !activeScopes.has('canvas')) || activeScopes.has('imageViewer');
});
const $upDownHotkeysEnabled = computed([$activeScopes, $isGalleryPanelOpen], (activeScopes, isGalleryPanelOpen) => {
// The up and down hotkeys can be used when the gallery is focused and the canvas is not focused, and the gallery panel is open.
return !activeScopes.has('staging-area') && !activeScopes.has('canvas') && isGalleryPanelOpen;
});
/**
* Registers gallery hotkeys. This hook is a singleton.
*/
export const useGalleryHotkeys = () => {
const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging);
useAssertSingleton('useGalleryHotkeys');
const { goNext, goPrev, isNextEnabled, isPrevEnabled } = useGalleryPagination();
const queryArgs = useAppSelector(selectListImagesQueryArgs);
const queryResult = useListImagesQuery(queryArgs);
const leftRightHotkeysEnabled = useStore($leftRightHotkeysEnabled);
const upDownHotkeysEnabled = useStore($upDownHotkeysEnabled);
const {
handleLeftImage,
@ -35,15 +51,13 @@ export const useGalleryHotkeys = () => {
}
handleLeftImage(e.altKey);
},
[handleLeftImage, isOnFirstImageOfView, goPrev, isPrevEnabled, queryResult.isFetching]
{ preventDefault: true, enabled: leftRightHotkeysEnabled },
[handleLeftImage, isOnFirstImageOfView, goPrev, isPrevEnabled, queryResult.isFetching, leftRightHotkeysEnabled]
);
useHotkeys(
['right', 'alt+right'],
(e) => {
if (isStaging) {
return;
}
if (isOnLastImageOfView && isNextEnabled && !queryResult.isFetching) {
goNext(e.altKey ? 'alt+arrow' : 'arrow');
return;
@ -52,38 +66,33 @@ export const useGalleryHotkeys = () => {
handleRightImage(e.altKey);
}
},
[isStaging, isOnLastImageOfView, goNext, isNextEnabled, queryResult.isFetching, handleRightImage]
{ preventDefault: true, enabled: leftRightHotkeysEnabled },
[isOnLastImageOfView, goNext, isNextEnabled, queryResult.isFetching, handleRightImage, leftRightHotkeysEnabled]
);
useHotkeys(
['up', 'alt+up'],
(e) => {
if (isStaging) {
return;
}
if (isOnFirstRow && isPrevEnabled && !queryResult.isFetching) {
goPrev(e.altKey ? 'alt+arrow' : 'arrow');
return;
}
handleUpImage(e.altKey);
},
{ preventDefault: true },
[isStaging, handleUpImage, isOnFirstRow, goPrev, isPrevEnabled, queryResult.isFetching]
{ preventDefault: true, enabled: upDownHotkeysEnabled },
[handleUpImage, isOnFirstRow, goPrev, isPrevEnabled, queryResult.isFetching, upDownHotkeysEnabled]
);
useHotkeys(
['down', 'alt+down'],
(e) => {
if (isStaging) {
return;
}
if (isOnLastRow && isNextEnabled && !queryResult.isFetching) {
goNext(e.altKey ? 'alt+arrow' : 'arrow');
return;
}
handleDownImage(e.altKey);
},
{ preventDefault: true },
[isStaging, isOnLastRow, goNext, isNextEnabled, queryResult.isFetching, handleDownImage]
{ preventDefault: true, enabled: upDownHotkeysEnabled },
[isOnLastRow, goNext, isNextEnabled, queryResult.isFetching, handleDownImage, upDownHotkeysEnabled]
);
};

View File

@ -5,9 +5,6 @@ import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel';
import { LoadWorkflowFromGraphModal } from 'features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal';
import { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog';
import type { AnimationProps } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import type { CSSProperties } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { MdDeviceHub } from 'react-icons/md';
@ -18,28 +15,6 @@ import { Flow } from './flow/Flow';
import BottomLeftPanel from './flow/panels/BottomLeftPanel/BottomLeftPanel';
import MinimapPanel from './flow/panels/MinimapPanel/MinimapPanel';
const isReadyMotionStyles: CSSProperties = {
position: 'relative',
width: '100%',
height: '100%',
};
const notIsReadyMotionStyles: CSSProperties = {
position: 'absolute',
width: '100%',
height: '100%',
};
const initial: AnimationProps['initial'] = {
opacity: 0,
};
const animate: AnimationProps['animate'] = {
opacity: 1,
transition: { duration: 0.2 },
};
const exit: AnimationProps['exit'] = {
opacity: 0,
transition: { duration: 0.2 },
};
const NodeEditor = () => {
const { data, isLoading } = useGetOpenAPISchemaQuery();
const { t } = useTranslation();
@ -53,9 +28,8 @@ const NodeEditor = () => {
alignItems="center"
justifyContent="center"
>
<AnimatePresence>
{data && (
<motion.div initial={initial} animate={animate} exit={exit} style={isReadyMotionStyles}>
<>
<Flow />
<AddNodePopover />
<TopPanel />
@ -63,27 +37,9 @@ const NodeEditor = () => {
<MinimapPanel />
<SaveWorkflowAsDialog />
<LoadWorkflowFromGraphModal />
</motion.div>
</>
)}
</AnimatePresence>
<AnimatePresence>
{isLoading && (
<motion.div initial={initial} animate={animate} exit={exit} style={notIsReadyMotionStyles}>
<Flex
layerStyle="first"
position="relative"
width="full"
height="full"
borderRadius="base"
alignItems="center"
justifyContent="center"
pointerEvents="none"
>
<IAINoContentFallback label={t('nodes.loadingNodes')} icon={MdDeviceHub} />
</Flex>
</motion.div>
)}
</AnimatePresence>
{isLoading && <IAINoContentFallback label={t('nodes.loadingNodes')} icon={MdDeviceHub} />}
</Flex>
);
};

View File

@ -5,6 +5,7 @@ import { Combobox, Flex, Popover, PopoverAnchor, PopoverBody, PopoverContent } f
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppStore } from 'app/store/storeHooks';
import type { SelectInstance } from 'chakra-react-select';
import { INTERACTION_SCOPES } from 'common/hooks/interactionScopes';
import { useBuildNode } from 'features/nodes/hooks/useBuildNode';
import {
$cursorPos,
@ -67,6 +68,7 @@ const AddNodePopover = () => {
const pendingConnection = useStore($pendingConnection);
const isOpen = useStore($isAddNodePopoverOpen);
const store = useAppStore();
const isWorkflowsActive = useStore(INTERACTION_SCOPES.workflows.$isActive);
const filteredTemplates = useMemo(() => {
// If we have a connection in progress, we need to filter the node choices
@ -214,14 +216,7 @@ const AddNodePopover = () => {
}
}, []);
const handleHotkeyClose: HotkeyCallback = useCallback(() => {
if ($isAddNodePopoverOpen.get()) {
closeAddNodePopover();
}
}, []);
useHotkeys(['shift+a', 'space'], handleHotkeyOpen);
useHotkeys(['escape'], handleHotkeyClose, { enableOnFormTags: ['TEXTAREA'] });
useHotkeys(['shift+a', 'space'], handleHotkeyOpen, { enabled: isWorkflowsActive }, [isWorkflowsActive]);
const noOptionsMessage = useCallback(() => t('nodes.noMatchingNodes'), [t]);

View File

@ -1,6 +1,7 @@
import { useGlobalMenuClose, useToken } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
import { INTERACTION_SCOPES, useScopeImperativeApi } from 'common/hooks/interactionScopes';
import { useConnection } from 'features/nodes/hooks/useConnection';
import { useCopyPaste } from 'features/nodes/hooks/useCopyPaste';
import { useSyncExecutionState } from 'features/nodes/hooks/useExecutionState';
@ -79,16 +80,13 @@ export const Flow = memo(() => {
const cancelConnection = useReactFlowStore(selectCancelConnection);
const updateNodeInternals = useUpdateNodeInternals();
const store = useAppStore();
const isWorkflowsActive = useStore(INTERACTION_SCOPES.workflows.$isActive);
const workflowsScopeApi = useScopeImperativeApi('workflows');
useWorkflowWatcher();
useSyncExecutionState();
const [borderRadius] = useToken('radii', ['base']);
const flowStyles = useMemo<CSSProperties>(
() => ({
borderRadius,
}),
[borderRadius]
);
const flowStyles = useMemo<CSSProperties>(() => ({ borderRadius }), [borderRadius]);
const onNodesChange: OnNodesChange = useCallback(
(nodeChanges) => {
@ -121,7 +119,8 @@ export const Flow = memo(() => {
const { onCloseGlobal } = useGlobalMenuClose();
const handlePaneClick = useCallback(() => {
onCloseGlobal();
}, [onCloseGlobal]);
workflowsScopeApi.add();
}, [onCloseGlobal, workflowsScopeApi]);
const onInit: OnInit = useCallback((flow) => {
$flow.set(flow);
@ -237,7 +236,7 @@ export const Flow = memo(() => {
},
[dispatch, store]
);
useHotkeys(['Ctrl+a', 'Meta+a'], onSelectAllHotkey);
useHotkeys(['Ctrl+a', 'Meta+a'], onSelectAllHotkey, { enabled: isWorkflowsActive }, [isWorkflowsActive]);
const onPasteHotkey = useCallback(
(e: KeyboardEvent) => {

View File

@ -1,5 +1,7 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo } from 'react';
import InvocationCacheStatus from './InvocationCacheStatus';
@ -9,9 +11,18 @@ import QueueTabQueueControls from './QueueTabQueueControls';
const QueueTabContent = () => {
const isInvocationCacheEnabled = useFeatureStatus('invocationCache');
const activeTabName = useAppSelector(activeTabNameSelector);
return (
<Flex borderRadius="base" w="full" h="full" flexDir="column" gap={2}>
<Flex
display={activeTabName === 'queue' ? undefined : 'none'}
hidden={activeTabName !== 'queue'}
borderRadius="base"
w="full"
h="full"
flexDir="column"
gap={2}
>
<Flex gap={2} w="full">
<QueueTabQueueControls />
<QueueStatus />

View File

@ -0,0 +1,181 @@
import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useScopeOnFocus } from 'common/hooks/interactionScopes';
import { CanvasEditor } from 'features/controlLayers/components/ControlLayersEditor';
import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent';
import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
import { useIsImageViewerOpen } from 'features/gallery/components/ImageViewer/useImageViewer';
import NodeEditorPanelGroup from 'features/nodes/components/sidePanel/NodeEditorPanelGroup';
import FloatingGalleryButton from 'features/ui/components/FloatingGalleryButton';
import FloatingParametersPanelButtons from 'features/ui/components/FloatingParametersPanelButtons';
import ParametersPanelTextToImage from 'features/ui/components/ParametersPanels/ParametersPanelTextToImage';
import { TabMountGate } from 'features/ui/components/TabMountGate';
import ModelManagerTab from 'features/ui/components/tabs/ModelManagerTab';
import NodesTab from 'features/ui/components/tabs/NodesTab';
import QueueTab from 'features/ui/components/tabs/QueueTab';
import { TabVisibilityGate } from 'features/ui/components/TabVisibilityGate';
import { VerticalNavBar } from 'features/ui/components/VerticalNavBar';
import type { UsePanelOptions } from 'features/ui/hooks/usePanel';
import { usePanel } from 'features/ui/hooks/usePanel';
import { usePanelStorage } from 'features/ui/hooks/usePanelStorage';
import type { InvokeTabName } from 'features/ui/store/tabMap';
import { $isGalleryPanelOpen, $isParametersPanelOpen } from 'features/ui/store/uiSlice';
import type { CSSProperties } from 'react';
import { memo, useMemo, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
import { Panel, PanelGroup } from 'react-resizable-panels';
import ParametersPanelUpscale from './ParametersPanels/ParametersPanelUpscale';
import ResizeHandle from './tabs/ResizeHandle';
const TABS_WITH_GALLERY_PANEL: InvokeTabName[] = ['generation', 'upscaling', 'workflows'] as const;
const TABS_WITH_OPTIONS_PANEL: InvokeTabName[] = ['generation', 'upscaling', 'workflows'] as const;
const panelStyles: CSSProperties = { position: 'relative', height: '100%', width: '100%' };
const GALLERY_MIN_SIZE_PX = 310;
const GALLERY_MIN_SIZE_PCT = 20;
const OPTIONS_PANEL_MIN_SIZE_PX = 430;
const OPTIONS_PANEL_MIN_SIZE_PCT = 20;
export const onGalleryPanelCollapse = (isCollapsed: boolean) => $isGalleryPanelOpen.set(!isCollapsed);
export const onParametersPanelCollapse = (isCollapsed: boolean) => $isParametersPanelOpen.set(!isCollapsed);
export const AppContent = memo(() => {
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
const isImageViewerOpen = useIsImageViewerOpen();
const shouldShowGalleryPanel = useAppSelector((s) => TABS_WITH_GALLERY_PANEL.includes(s.ui.activeTab));
const shouldShowOptionsPanel = useAppSelector((s) => TABS_WITH_OPTIONS_PANEL.includes(s.ui.activeTab));
const ref = useRef<HTMLDivElement>(null);
useScopeOnFocus('gallery', ref);
const optionsPanelUsePanelOptions = useMemo<UsePanelOptions>(
() => ({
id: 'options-panel',
unit: 'pixels',
minSize: OPTIONS_PANEL_MIN_SIZE_PX,
defaultSize: OPTIONS_PANEL_MIN_SIZE_PCT,
panelGroupRef,
panelGroupDirection: 'horizontal',
onCollapse: onParametersPanelCollapse,
}),
[]
);
const galleryPanelUsePanelOptions = useMemo<UsePanelOptions>(
() => ({
id: 'gallery-panel',
unit: 'pixels',
minSize: GALLERY_MIN_SIZE_PX,
defaultSize: GALLERY_MIN_SIZE_PCT,
panelGroupRef,
panelGroupDirection: 'horizontal',
onCollapse: onGalleryPanelCollapse,
}),
[]
);
const panelStorage = usePanelStorage();
const optionsPanel = usePanel(optionsPanelUsePanelOptions);
const galleryPanel = usePanel(galleryPanelUsePanelOptions);
useHotkeys('g', galleryPanel.toggle, [galleryPanel.toggle]);
useHotkeys(['t', 'o'], optionsPanel.toggle, [optionsPanel.toggle]);
useHotkeys(
'shift+r',
() => {
optionsPanel.reset();
galleryPanel.reset();
},
[optionsPanel.reset, galleryPanel.reset]
);
useHotkeys(
'f',
() => {
if (optionsPanel.isCollapsed || galleryPanel.isCollapsed) {
optionsPanel.expand();
galleryPanel.expand();
} else {
optionsPanel.collapse();
galleryPanel.collapse();
}
},
[
optionsPanel.isCollapsed,
galleryPanel.isCollapsed,
optionsPanel.expand,
galleryPanel.expand,
optionsPanel.collapse,
galleryPanel.collapse,
]
);
return (
<Flex ref={ref} id="invoke-app-tabs" w="full" h="full" gap={4} p={4}>
<VerticalNavBar />
<Flex position="relative" w="full" h="full" gap={4}>
<PanelGroup
ref={panelGroupRef}
id="app-panel-group"
autoSaveId="app"
direction="horizontal"
style={panelStyles}
storage={panelStorage}
>
<Panel order={0} collapsible style={panelStyles} {...optionsPanel.panelProps}>
<TabMountGate tab="generation">
<TabVisibilityGate tab="generation">
<ParametersPanelTextToImage />
</TabVisibilityGate>
</TabMountGate>
<TabMountGate tab="upscaling">
<TabVisibilityGate tab="upscaling">
<ParametersPanelUpscale />
</TabVisibilityGate>
</TabMountGate>
<TabMountGate tab="workflows">
<TabVisibilityGate tab="workflows">
<NodeEditorPanelGroup />
</TabVisibilityGate>
</TabMountGate>
</Panel>
<ResizeHandle id="options-main-handle" orientation="vertical" {...optionsPanel.resizeHandleProps} />
<Panel id="main-panel" order={1} minSize={20} style={panelStyles}>
<TabMountGate tab="generation">
<TabVisibilityGate tab="generation">
<CanvasEditor />
</TabVisibilityGate>
</TabMountGate>
{/* upscaling tab has no content of its own - uses image viewer only */}
<TabMountGate tab="workflows">
<TabVisibilityGate tab="workflows">
<NodesTab />
</TabVisibilityGate>
</TabMountGate>
{isImageViewerOpen && <ImageViewer />}
</Panel>
<ResizeHandle id="main-gallery-handle" orientation="vertical" {...galleryPanel.resizeHandleProps} />
<Panel order={2} style={panelStyles} collapsible {...galleryPanel.panelProps}>
<GalleryPanelContent />
</Panel>
</PanelGroup>
{shouldShowOptionsPanel && <FloatingParametersPanelButtons panelApi={optionsPanel} />}
{shouldShowGalleryPanel && <FloatingGalleryButton panelApi={galleryPanel} />}
<TabMountGate tab="models">
<TabVisibilityGate tab="models">
<ModelManagerTab />
</TabVisibilityGate>
</TabMountGate>
<TabMountGate tab="models">
<TabVisibilityGate tab="queue">
<QueueTab />
</TabVisibilityGate>
</TabMountGate>
</Flex>
</Flex>
);
});
AppContent.displayName = 'AppContent';

View File

@ -1,303 +0,0 @@
import { Flex, IconButton, Spacer, Tab, TabList, TabPanel, TabPanels, Tabs, Tooltip } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { $customNavComponent } from 'app/store/nanostores/customNavComponent';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import ImageGalleryContent from 'features/gallery/components/ImageGalleryContent';
import NodeEditorPanelGroup from 'features/nodes/components/sidePanel/NodeEditorPanelGroup';
import InvokeAILogoComponent from 'features/system/components/InvokeAILogoComponent';
import SettingsMenu from 'features/system/components/SettingsModal/SettingsMenu';
import StatusIndicator from 'features/system/components/StatusIndicator';
import { selectConfigSlice } from 'features/system/store/configSlice';
import FloatingGalleryButton from 'features/ui/components/FloatingGalleryButton';
import FloatingParametersPanelButtons from 'features/ui/components/FloatingParametersPanelButtons';
import ParametersPanelTextToImage from 'features/ui/components/ParametersPanels/ParametersPanelTextToImage';
import ModelManagerTab from 'features/ui/components/tabs/ModelManagerTab';
import NodesTab from 'features/ui/components/tabs/NodesTab';
import QueueTab from 'features/ui/components/tabs/QueueTab';
import TextToImageTab from 'features/ui/components/tabs/TextToImageTab';
import type { UsePanelOptions } from 'features/ui/hooks/usePanel';
import { usePanel } from 'features/ui/hooks/usePanel';
import { usePanelStorage } from 'features/ui/hooks/usePanelStorage';
import type { InvokeTabName } from 'features/ui/store/tabMap';
import { TAB_NUMBER_MAP } from 'features/ui/store/tabMap';
import { activeTabIndexSelector, activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { setActiveTab } from 'features/ui/store/uiSlice';
import type { CSSProperties, MouseEvent, ReactElement, ReactNode } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { MdZoomOutMap } from 'react-icons/md';
import { PiFlowArrowBold } from 'react-icons/pi';
import { RiBox2Line, RiInputMethodLine, RiPlayList2Fill } from 'react-icons/ri';
import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
import { Panel, PanelGroup } from 'react-resizable-panels';
import ParametersPanelUpscale from './ParametersPanels/ParametersPanelUpscale';
import ResizeHandle from './tabs/ResizeHandle';
import UpscalingTab from './tabs/UpscalingTab';
type TabData = {
id: InvokeTabName;
translationKey: string;
icon: ReactElement;
content: ReactNode;
parametersPanel?: ReactNode;
};
const TAB_DATA: Record<InvokeTabName, TabData> = {
generation: {
id: 'generation',
translationKey: 'ui.tabs.generation',
icon: <RiInputMethodLine />,
content: <TextToImageTab />,
parametersPanel: <ParametersPanelTextToImage />,
},
upscaling: {
id: 'upscaling',
translationKey: 'ui.tabs.upscaling',
icon: <MdZoomOutMap />,
content: <UpscalingTab />,
parametersPanel: <ParametersPanelUpscale />,
},
workflows: {
id: 'workflows',
translationKey: 'ui.tabs.workflows',
icon: <PiFlowArrowBold />,
content: <NodesTab />,
parametersPanel: <NodeEditorPanelGroup />,
},
models: {
id: 'models',
translationKey: 'ui.tabs.models',
icon: <RiBox2Line />,
content: <ModelManagerTab />,
},
queue: {
id: 'queue',
translationKey: 'ui.tabs.queue',
icon: <RiPlayList2Fill />,
content: <QueueTab />,
},
};
const enabledTabsSelector = createMemoizedSelector(selectConfigSlice, (config) =>
TAB_NUMBER_MAP.map((tabName) => TAB_DATA[tabName]).filter((tab) => !config.disabledTabs.includes(tab.id))
);
const NO_GALLERY_PANEL_TABS: InvokeTabName[] = ['models', 'queue'];
const panelStyles: CSSProperties = { height: '100%', width: '100%' };
const GALLERY_MIN_SIZE_PX = 310;
const GALLERY_MIN_SIZE_PCT = 20;
const OPTIONS_PANEL_MIN_SIZE_PX = 430;
const OPTIONS_PANEL_MIN_SIZE_PCT = 20;
const appPanelGroupId = 'app-panel-group';
const InvokeTabs = () => {
const activeTabIndex = useAppSelector(activeTabIndexSelector);
const activeTabName = useAppSelector(activeTabNameSelector);
const enabledTabs = useAppSelector(enabledTabsSelector);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const customNavComponent = useStore($customNavComponent);
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
const handleClickTab = useCallback((e: MouseEvent<HTMLElement>) => {
if (e.target instanceof HTMLElement) {
e.target.blur();
}
}, []);
const shouldShowGalleryPanel = useMemo(() => !NO_GALLERY_PANEL_TABS.includes(activeTabName), [activeTabName]);
const tabs = useMemo(
() =>
enabledTabs.map((tab) => (
<Tooltip key={tab.id} label={t(tab.translationKey)} placement="end">
<Tab
as={IconButton}
p={0}
onClick={handleClickTab}
icon={tab.icon}
size="md"
fontSize="24px"
variant="appTab"
data-selected={activeTabName === tab.id}
aria-label={t(tab.translationKey)}
data-testid={t(tab.translationKey)}
/>
</Tooltip>
)),
[enabledTabs, t, handleClickTab, activeTabName]
);
const tabPanels = useMemo(
() => enabledTabs.map((tab) => <TabPanel key={tab.id}>{tab.content}</TabPanel>),
[enabledTabs]
);
const handleTabChange = useCallback(
(index: number) => {
const tab = enabledTabs[index];
if (!tab) {
return;
}
dispatch(setActiveTab(tab.id));
},
[dispatch, enabledTabs]
);
const optionsPanelUsePanelOptions = useMemo<UsePanelOptions>(
() => ({
unit: 'pixels',
minSize: OPTIONS_PANEL_MIN_SIZE_PX,
fallbackMinSizePct: OPTIONS_PANEL_MIN_SIZE_PCT,
panelGroupRef,
panelGroupDirection: 'horizontal',
}),
[]
);
const galleryPanelUsePanelOptions = useMemo<UsePanelOptions>(
() => ({
unit: 'pixels',
minSize: GALLERY_MIN_SIZE_PX,
fallbackMinSizePct: GALLERY_MIN_SIZE_PCT,
panelGroupRef,
panelGroupDirection: 'horizontal',
}),
[]
);
const panelStorage = usePanelStorage();
const optionsPanel = usePanel(optionsPanelUsePanelOptions);
const galleryPanel = usePanel(galleryPanelUsePanelOptions);
useHotkeys('g', galleryPanel.toggle, [galleryPanel.toggle]);
useHotkeys(['t', 'o'], optionsPanel.toggle, [optionsPanel.toggle]);
useHotkeys(
'shift+r',
() => {
optionsPanel.reset();
galleryPanel.reset();
},
[optionsPanel.reset, galleryPanel.reset]
);
useHotkeys(
'f',
() => {
if (optionsPanel.isCollapsed || galleryPanel.isCollapsed) {
optionsPanel.expand();
galleryPanel.expand();
} else {
optionsPanel.collapse();
galleryPanel.collapse();
}
},
[
optionsPanel.isCollapsed,
galleryPanel.isCollapsed,
optionsPanel.expand,
galleryPanel.expand,
optionsPanel.collapse,
galleryPanel.collapse,
]
);
return (
<Tabs
id="invoke-app-tabs"
variant="appTabs"
defaultIndex={activeTabIndex}
index={activeTabIndex}
onChange={handleTabChange}
w="full"
h="full"
gap={4}
p={4}
isLazy
>
<Flex flexDir="column" alignItems="center" pt={4} pb={2} gap={4}>
<InvokeAILogoComponent />
<TabList gap={4} pt={6} h="full" flexDir="column">
{tabs}
</TabList>
<Spacer />
<StatusIndicator />
{customNavComponent ? customNavComponent : <SettingsMenu />}
</Flex>
<PanelGroup
ref={panelGroupRef}
id={appPanelGroupId}
autoSaveId="app"
direction="horizontal"
style={panelStyles}
storage={panelStorage}
>
{!!TAB_DATA[activeTabName].parametersPanel && (
<>
<Panel
id="options-panel"
ref={optionsPanel.ref}
order={0}
defaultSize={optionsPanel.minSize}
minSize={optionsPanel.minSize}
onCollapse={optionsPanel.onCollapse}
onExpand={optionsPanel.onExpand}
collapsible
>
{TAB_DATA[activeTabName].parametersPanel}
</Panel>
<ResizeHandle
id="options-main-handle"
onDoubleClick={optionsPanel.onDoubleClickHandle}
orientation="vertical"
/>
</>
)}
<Panel id="main-panel" order={1} minSize={20}>
<TabPanels w="full" h="full">
{tabPanels}
</TabPanels>
</Panel>
{shouldShowGalleryPanel && (
<>
<ResizeHandle
id="main-gallery-handle"
orientation="vertical"
onDoubleClick={galleryPanel.onDoubleClickHandle}
/>
<Panel
id="gallery-panel"
ref={galleryPanel.ref}
order={2}
defaultSize={galleryPanel.minSize}
minSize={galleryPanel.minSize}
onCollapse={galleryPanel.onCollapse}
onExpand={galleryPanel.onExpand}
collapsible
>
<ImageGalleryContent />
</Panel>
</>
)}
</PanelGroup>
{!!TAB_DATA[activeTabName].parametersPanel && <FloatingParametersPanelButtons panelApi={optionsPanel} />}
{shouldShowGalleryPanel && <FloatingGalleryButton panelApi={galleryPanel} />}
</Tabs>
);
};
export default memo(InvokeTabs);
const ParametersPanelComponent = memo(() => {
const activeTabName = useAppSelector(activeTabNameSelector);
if (activeTabName === 'workflows') {
return <NodeEditorPanelGroup />;
} else {
return <ParametersPanelTextToImage />;
}
});
ParametersPanelComponent.displayName = 'ParametersPanelComponent';

View File

@ -0,0 +1,32 @@
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import type { InvokeTabName } from 'features/ui/store/tabMap';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { memo, type ReactElement, useCallback } from 'react';
export const TabButton = memo(({ tab, icon, label }: { tab: InvokeTabName; icon: ReactElement; label: string }) => {
const dispatch = useAppDispatch();
const activeTabName = useAppSelector(activeTabNameSelector);
const onClick = useCallback(() => {
dispatch(setActiveTab(tab));
}, [dispatch, tab]);
return (
<Tooltip label={label} placement="end">
<IconButton
p={0}
onClick={onClick}
icon={icon}
size="md"
fontSize="24px"
variant="appTab"
data-selected={activeTabName === tab}
aria-label={label}
data-testid={label}
/>
</Tooltip>
);
});
TabButton.displayName = 'TabButton';

View File

@ -0,0 +1,22 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectConfigSlice } from 'features/system/store/configSlice';
import type { InvokeTabName } from 'features/ui/store/tabMap';
import type { PropsWithChildren } from 'react';
import { memo, useMemo } from 'react';
export const TabMountGate = memo(({ tab, children }: PropsWithChildren<{ tab: InvokeTabName }>) => {
const selectIsTabEnabled = useMemo(
() => createSelector(selectConfigSlice, (config) => !config.disabledTabs.includes(tab)),
[tab]
);
const isEnabled = useAppSelector(selectIsTabEnabled);
if (!isEnabled) {
return null;
}
return children;
});
TabMountGate.displayName = 'TabMountGate';

View File

@ -0,0 +1,29 @@
import { Box } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import type { InvokeTabName } from 'features/ui/store/tabMap';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
export const TabVisibilityGate = memo(({ tab, children }: PropsWithChildren<{ tab: InvokeTabName }>) => {
const activeTabName = useAppSelector((s) => s.ui.activeTab);
return (
<Box
display={activeTabName === tab ? undefined : 'none'}
pointerEvents={activeTabName === tab ? undefined : 'none'}
userSelect={activeTabName === tab ? undefined : 'none'}
hidden={activeTabName !== tab}
w="full"
h="full"
position="absolute"
top={0}
right={0}
bottom={0}
left={0}
>
{children}
</Box>
);
});
TabVisibilityGate.displayName = 'TabVisibilityGate';

View File

@ -0,0 +1,47 @@
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $customNavComponent } from 'app/store/nanostores/customNavComponent';
import InvokeAILogoComponent from 'features/system/components/InvokeAILogoComponent';
import SettingsMenu from 'features/system/components/SettingsModal/SettingsMenu';
import StatusIndicator from 'features/system/components/StatusIndicator';
import { TabMountGate } from 'features/ui/components/TabMountGate';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { MdZoomOutMap } from 'react-icons/md';
import { PiFlowArrowBold } from 'react-icons/pi';
import { RiBox2Line, RiInputMethodLine, RiPlayList2Fill } from 'react-icons/ri';
import { TabButton } from './TabButton';
export const VerticalNavBar = memo(() => {
const { t } = useTranslation();
const customNavComponent = useStore($customNavComponent);
return (
<Flex flexDir="column" alignItems="center" pt={4} pb={2} gap={4}>
<InvokeAILogoComponent />
<Flex gap={4} pt={6} h="full" flexDir="column">
<TabMountGate tab="generation">
<TabButton tab="generation" icon={<RiInputMethodLine />} label={t('ui.tabs.generation')} />
</TabMountGate>
<TabMountGate tab="upscaling">
<TabButton tab="upscaling" icon={<MdZoomOutMap />} label={t('ui.tabs.upscaling')} />
</TabMountGate>
<TabMountGate tab="workflows">
<TabButton tab="workflows" icon={<PiFlowArrowBold />} label={t('ui.tabs.workflows')} />
</TabMountGate>
<TabMountGate tab="models">
<TabButton tab="models" icon={<RiBox2Line />} label={t('ui.tabs.models')} />
</TabMountGate>
<TabMountGate tab="queue">
<TabButton tab="queue" icon={<RiPlayList2Fill />} label={t('ui.tabs.queue')} />
</TabMountGate>
</Flex>
<Spacer />
<StatusIndicator />
{customNavComponent ? customNavComponent : <SettingsMenu />}
</Flex>
);
});
VerticalNavBar.displayName = 'VerticalNavBar';

View File

@ -1,27 +1,34 @@
import { Box } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { ImageComparisonDroppable } from 'features/gallery/components/ImageViewer/ImageComparisonDroppable';
import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
import { useScopeOnFocus } from 'common/hooks/interactionScopes';
import NodeEditor from 'features/nodes/components/NodeEditor';
import { memo } from 'react';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useRef } from 'react';
import { ReactFlowProvider } from 'reactflow';
const NodesTab = () => {
const mode = useAppSelector((s) => s.workflow.mode);
if (mode === 'view') {
return (
<Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
<ImageViewer />
<ImageComparisonDroppable />
</Box>
);
}
const activeTabName = useAppSelector(activeTabNameSelector);
const ref = useRef<HTMLDivElement>(null);
useScopeOnFocus('workflows', ref);
return (
<Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
<Box
display={activeTabName === 'workflows' ? undefined : 'none'}
hidden={activeTabName !== 'workflows'}
ref={ref}
layerStyle="first"
position="relative"
w="full"
h="full"
p={2}
borderRadius="base"
>
{mode === 'edit' && (
<ReactFlowProvider>
<NodeEditor />
</ReactFlowProvider>
)}
</Box>
);
};

View File

@ -1,8 +1,13 @@
import { Flex } from '@invoke-ai/ui-library';
import QueueTabContent from 'features/queue/components/QueueTabContent';
import { memo } from 'react';
const QueueTab = () => {
return <QueueTabContent />;
return (
<Flex w="full" h="full">
<QueueTabContent />
</Flex>
);
};
export default memo(QueueTab);

View File

@ -17,7 +17,6 @@ const ResizeHandle = (props: ResizeHandleProps) => {
<ChakraPanelResizeHandle {...rest}>
<Flex sx={sx} data-orientation={orientation}>
<Box className="resize-handle-inner" data-orientation={orientation} />
<Box className="resize-handle-drag-handle" data-orientation={orientation} />
</Flex>
</ChakraPanelResizeHandle>
);
@ -59,22 +58,4 @@ const sx: SystemStyleObject = {
transitionProperty: 'inherit',
transitionDuration: 'inherit',
},
'.resize-handle-drag-handle': {
pos: 'absolute',
borderRadius: '1px',
transitionProperty: 'inherit',
transitionDuration: 'inherit',
'&[data-orientation="horizontal"]': {
w: '30px',
h: '6px',
insetInlineStart: '50%',
transform: 'translate(-50%, 0)',
},
'&[data-orientation="vertical"]': {
w: '6px',
h: '30px',
insetBlockStart: '50%',
transform: 'translate(0, -50%)',
},
},
};

View File

@ -1,21 +1,11 @@
import { Box } from '@invoke-ai/ui-library';
import { ControlLayersEditor } from 'features/controlLayers/components/ControlLayersEditor';
import { ImageComparisonDroppable } from 'features/gallery/components/ImageViewer/ImageComparisonDroppable';
import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { CanvasEditor } from 'features/controlLayers/components/ControlLayersEditor';
import { memo } from 'react';
const TextToImageTab = () => {
const imageViewer = useImageViewer();
return (
<Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
<ControlLayersEditor />
{imageViewer.isOpen && (
<>
<ImageViewer />
<ImageComparisonDroppable />
</>
)}
<CanvasEditor />
</Box>
);
};

View File

@ -1,11 +1,23 @@
import { Box } from '@invoke-ai/ui-library';
import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
import { useAppSelector } from 'app/store/storeHooks';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo } from 'react';
const UpscalingTab = () => {
const activeTabName = useAppSelector(activeTabNameSelector);
return (
<Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
<ImageViewer />
<Box
display={activeTabName === 'upscaling' ? undefined : 'none'}
hidden={activeTabName !== 'upscaling'}
layerStyle="first"
position="relative"
w="full"
h="full"
p={2}
borderRadius="base"
>
{/* <ImageViewer /> */}
</Box>
);
};

View File

@ -5,6 +5,8 @@ import type {
ImperativePanelHandle,
PanelOnCollapse,
PanelOnExpand,
PanelProps,
PanelResizeHandleProps,
} from 'react-resizable-panels';
import { getPanelGroupElement, getResizeHandleElementsForGroup } from 'react-resizable-panels';
@ -12,6 +14,7 @@ type Direction = 'horizontal' | 'vertical';
export type UsePanelOptions =
| {
id: string;
/**
* The minimum size of the panel as a percentage.
*/
@ -24,8 +27,10 @@ export type UsePanelOptions =
* The unit of the minSize
*/
unit: 'percentages';
onCollapse?: (isCollapsed: boolean) => void;
}
| {
id: string;
/**
* The minimum size of the panel in pixels.
*/
@ -47,44 +52,18 @@ export type UsePanelOptions =
* A ref to the panel group.
*/
panelGroupRef: RefObject<ImperativePanelGroupHandle>;
onCollapse?: (isCollapsed: boolean) => void;
};
export type UsePanelReturn = {
/**
* The ref to the panel handle.
*/
ref: RefObject<ImperativePanelHandle>;
/**
* The dynamically calculated minimum size of the panel.
*/
minSize: number;
/**
* The dynamically calculated default size of the panel.
*/
defaultSize: number;
/**
* Whether the panel is collapsed.
*/
isCollapsed: boolean;
/**
* The onCollapse callback. This is required to update the isCollapsed state.
* This should be passed to the panel as the onCollapse prop. Wrap it if additional logic is required.
*/
onCollapse: PanelOnCollapse;
/**
* The onExpand callback. This is required to update the isCollapsed state.
* This should be passed to the panel as the onExpand prop. Wrap it if additional logic is required.
*/
onExpand: PanelOnExpand;
/**
* Reset the panel to the minSize.
*/
reset: () => void;
/**
* Reset the panel to the minSize. If the panel is already at the minSize, collapse it.
* This should be passed to the `onDoubleClick` prop of the panel's nearest resize handle.
*/
onDoubleClickHandle: () => void;
/**
* Toggle the panel between collapsed and expanded.
*/
@ -101,6 +80,8 @@ export type UsePanelReturn = {
* Resize the panel to the given size in the same units as the minSize.
*/
resize: (size: number) => void;
panelProps: Partial<PanelProps & { ref: RefObject<ImperativePanelHandle> }>;
resizeHandleProps: Partial<PanelResizeHandleProps>;
};
export const usePanel = (arg: UsePanelOptions): UsePanelReturn => {
@ -128,12 +109,11 @@ export const usePanel = (arg: UsePanelOptions): UsePanelReturn => {
const minSizePct = getSizeAsPercentage(arg.minSize, arg.panelGroupRef, arg.panelGroupDirection);
_setMinSize(minSizePct);
const defaultSizePct = getSizeAsPercentage(
arg.defaultSize ?? arg.minSize,
arg.panelGroupRef,
arg.panelGroupDirection
);
if (arg.defaultSize && arg.defaultSize > minSizePct) {
_setDefaultSize(defaultSizePct);
} else {
_setDefaultSize(minSizePct);
}
if (!panelHandleRef.current.isCollapsed() && panelHandleRef.current.getSize() < minSizePct && minSizePct > 0) {
panelHandleRef.current.resize(minSizePct);
@ -144,11 +124,8 @@ export const usePanel = (arg: UsePanelOptions): UsePanelReturn => {
panelGroupHandleElements.forEach((el) => resizeObserver.observe(el));
// Resize the panel to the min size once on startup
const defaultSizePct = getSizeAsPercentage(
arg.defaultSize ?? arg.minSize,
arg.panelGroupRef,
arg.panelGroupDirection
);
const defaultSizePct =
arg.defaultSize ?? getSizeAsPercentage(arg.minSize, arg.panelGroupRef, arg.panelGroupDirection);
panelHandleRef.current?.resize(defaultSizePct);
return () => {
@ -160,11 +137,13 @@ export const usePanel = (arg: UsePanelOptions): UsePanelReturn => {
const onCollapse = useCallback<PanelOnCollapse>(() => {
setIsCollapsed(true);
}, []);
arg.onCollapse?.(true);
}, [arg]);
const onExpand = useCallback<PanelOnExpand>(() => {
setIsCollapsed(false);
}, []);
arg.onCollapse?.(false);
}, [arg]);
const toggle = useCallback(() => {
if (panelHandleRef.current?.isCollapsed()) {
@ -201,7 +180,7 @@ export const usePanel = (arg: UsePanelOptions): UsePanelReturn => {
panelHandleRef.current?.resize(_minSize);
}, [_minSize]);
const onDoubleClickHandle = useCallback(() => {
const cycleState = useCallback(() => {
// If the panel is really super close to the min size, collapse it
if (Math.abs((panelHandleRef.current?.getSize() ?? 0) - _defaultSize) < 0.01) {
collapse();
@ -213,18 +192,23 @@ export const usePanel = (arg: UsePanelOptions): UsePanelReturn => {
}, [_defaultSize, collapse]);
return {
ref: panelHandleRef,
minSize: _minSize,
isCollapsed,
onCollapse,
onExpand,
reset,
toggle,
expand,
collapse,
resize,
onDoubleClickHandle,
panelProps: {
id: arg.id,
defaultSize: _defaultSize,
onCollapse,
onExpand,
ref: panelHandleRef,
minSize: _minSize,
},
resizeHandleProps: {
onDoubleClick: cycleState,
},
};
};

View File

@ -2,6 +2,7 @@ import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { workflowLoadRequested } from 'features/nodes/store/actions';
import { atom } from 'nanostores';
import type { InvokeTabName } from './tabMap';
import type { UIState } from './uiTypes';
@ -77,3 +78,6 @@ export const uiPersistConfig: PersistConfig<UIState> = {
migrate: migrateUIState,
persistDenylist: ['shouldShowImageDetails'],
};
export const $isGalleryPanelOpen = atom(true);
export const $isParametersPanelOpen = atom(true);