mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): revise app layout strategy, add interaction scopes for hotkeys
This commit is contained in:
parent
d6b3e6c07d
commit
f78f4ca25f
@ -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} />
|
||||
|
163
invokeai/frontend/web/src/common/hooks/interactionScopes.ts
Normal file
163
invokeai/frontend/web/src/common/hooks/interactionScopes.ts
Normal 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);
|
||||
};
|
||||
}, []);
|
||||
};
|
@ -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]
|
||||
);
|
||||
|
@ -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} />
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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';
|
||||
|
@ -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"
|
||||
|
@ -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, {
|
||||
preventDefault: true,
|
||||
});
|
||||
useHotkeys(
|
||||
['left'],
|
||||
onPrev,
|
||||
{
|
||||
preventDefault: true,
|
||||
enabled: isCanvasActive,
|
||||
},
|
||||
[isCanvasActive]
|
||||
);
|
||||
|
||||
useHotkeys(['right'], onNext, {
|
||||
preventDefault: true,
|
||||
});
|
||||
useHotkeys(
|
||||
['right'],
|
||||
onNext,
|
||||
{
|
||||
preventDefault: true,
|
||||
enabled: isCanvasActive,
|
||||
},
|
||||
[isCanvasActive]
|
||||
);
|
||||
|
||||
useHotkeys(['enter'], onAccept, {
|
||||
preventDefault: true,
|
||||
});
|
||||
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 (
|
||||
<>
|
||||
|
@ -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);
|
@ -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;
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -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')} />
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -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 };
|
||||
};
|
||||
|
@ -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]
|
||||
);
|
||||
};
|
||||
|
@ -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,37 +28,18 @@ const NodeEditor = () => {
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{data && (
|
||||
<motion.div initial={initial} animate={animate} exit={exit} style={isReadyMotionStyles}>
|
||||
<Flow />
|
||||
<AddNodePopover />
|
||||
<TopPanel />
|
||||
<BottomLeftPanel />
|
||||
<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>
|
||||
{data && (
|
||||
<>
|
||||
<Flow />
|
||||
<AddNodePopover />
|
||||
<TopPanel />
|
||||
<BottomLeftPanel />
|
||||
<MinimapPanel />
|
||||
<SaveWorkflowAsDialog />
|
||||
<LoadWorkflowFromGraphModal />
|
||||
</>
|
||||
)}
|
||||
{isLoading && <IAINoContentFallback label={t('nodes.loadingNodes')} icon={MdDeviceHub} />}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -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) => {
|
||||
|
@ -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 />
|
||||
|
181
invokeai/frontend/web/src/features/ui/components/AppContent.tsx
Normal file
181
invokeai/frontend/web/src/features/ui/components/AppContent.tsx
Normal 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';
|
@ -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';
|
@ -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';
|
@ -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';
|
@ -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';
|
@ -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';
|
@ -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">
|
||||
<ReactFlowProvider>
|
||||
<NodeEditor />
|
||||
</ReactFlowProvider>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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%)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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
|
||||
);
|
||||
_setDefaultSize(defaultSizePct);
|
||||
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,
|
||||
defaultSize: _defaultSize,
|
||||
panelProps: {
|
||||
id: arg.id,
|
||||
defaultSize: _defaultSize,
|
||||
onCollapse,
|
||||
onExpand,
|
||||
ref: panelHandleRef,
|
||||
minSize: _minSize,
|
||||
},
|
||||
resizeHandleProps: {
|
||||
onDoubleClick: cycleState,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user