diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index 41f3d97051..d7b1acd839 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -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 ( @@ -105,7 +107,7 @@ const App = ({ config = DEFAULT_CONFIG, selectedImage, selectedWorkflowId, desti {...dropzone.getRootProps()} > - + {dropzone.isDragActive && isHandlingUpload && ( diff --git a/invokeai/frontend/web/src/common/hooks/interactionScopes.ts b/invokeai/frontend/web/src/common/hooks/interactionScopes.ts new file mode 100644 index 0000000000..0e4aa906a4 --- /dev/null +++ b/invokeai/frontend/web/src/common/hooks/interactionScopes.ts @@ -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>(new Set()); + +type InteractionScopeData = { + targets: Set; + $isActive: Atom; +}; + +export const INTERACTION_SCOPES: Record = _INTERACTION_SCOPES.reduce( + (acc, region) => { + acc[region] = { + targets: new Set(), + $isActive: computed($activeScopes, (activeScopes) => activeScopes.has(region)), + }; + return acc; + }, + {} as Record +); + +const formatScopes = (interactionScopes: Set) => { + 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) => { + 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(); + + 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); + }; + }, []); +}; diff --git a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts index 487622f9b7..9ed33fc26c 100644 --- a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts +++ b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts @@ -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] ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx index b5a1c636a5..5c90c82a6f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx @@ -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 ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasResetViewButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasResetViewButton.tsx index 5df90004fe..585f208803 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasResetViewButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasResetViewButton.tsx @@ -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 ( = { +const meta: Meta = { title: 'Feature/ControlLayers', tags: ['autodocs'], - component: ControlLayersEditor, + component: CanvasEditor, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; const Component = () => { return ( - + ); }; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersEditor.tsx index 71e35263b5..d10af98f7a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersEditor.tsx @@ -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(null); + useScopeOnFocus('canvas', ref); -export const ControlLayersEditor = memo(() => { return ( { - {/* - - */} ); }); -ControlLayersEditor.displayName = 'ControlLayersEditor'; +CanvasEditor.displayName = 'CanvasEditor'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx index 9870e3c72d..5fb20b6fd7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StageComponent.tsx @@ -80,7 +80,7 @@ export const StageComponent = memo(({ asPreview = false }: Props) => { ); return ( - + {canvasBackgroundStyle === 'checkerboard' && ( { 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 ( <> diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryPanelContent.tsx similarity index 85% rename from invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx rename to invokeai/frontend/web/src/features/gallery/components/GalleryPanelContent.tsx index e8f42ae65e..30765f20d9 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryPanelContent.tsx @@ -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(null); + const ref = useRef(null); + useScopeOnFocus('gallery', ref); const boardsListPanelOptions = useMemo( () => ({ + 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 ( - + @@ -90,15 +93,7 @@ const ImageGalleryContent = () => { - + @@ -109,11 +104,7 @@ const ImageGalleryContent = () => { - + @@ -122,4 +113,4 @@ const ImageGalleryContent = () => { ); }; -export default memo(ImageGalleryContent); +export default memo(GalleryPanelContent); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx index 6e111e59c0..439388e2c5 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx @@ -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; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx index 9ccd69b898..65131dd3e5 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx @@ -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 ( <> diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx index 3678c920c0..2998c7d725 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx @@ -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( () => ({ @@ -29,14 +22,6 @@ export const ImageComparisonDroppable = memo(() => { [firstImage?.image_name, secondImage?.image_name] ); - if (!imageViewer.isOpen) { - return ( - - - - ); - } - return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx index 530431fc4c..f8657d7811 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx @@ -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(); + const ref = useRef(null); + useScopeOnFocus('imageViewer', ref); + useScopeOnMount('imageViewer'); return ( { alignItems="center" justifyContent="center" > - {imageViewer.isComparing && } - {!imageViewer.isComparing && } + {isComparing && } + {!isComparing && } - {!imageViewer.isComparing && } - {imageViewer.isComparing && } + {!isComparing && } + {isComparing && } + ); }); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts index 1e1567e70a..dfbc05107b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts @@ -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 }; }; diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts index 8199d8f63e..81a5f34987 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts @@ -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] ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx index 737adb52e7..27e2006b08 100644 --- a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx @@ -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" > - - {data && ( - - - - - - - - - - )} - - - {isLoading && ( - - - - - - )} - + {data && ( + <> + + + + + + + + + )} + {isLoading && } ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopover.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopover.tsx index 6da87f4e98..923c44b527 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopover.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopover.tsx @@ -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]); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx index 727dad9617..a6a5cb7981 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx @@ -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( - () => ({ - borderRadius, - }), - [borderRadius] - ); + const flowStyles = useMemo(() => ({ 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) => { diff --git a/invokeai/frontend/web/src/features/queue/components/QueueTabContent.tsx b/invokeai/frontend/web/src/features/queue/components/QueueTabContent.tsx index 2dae5e6ebe..c2f4c75c7a 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueTabContent.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueTabContent.tsx @@ -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 ( - +