mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-14 19:08:00 -05:00
tidy,docs(ui): focus region logic
This commit is contained in:
@@ -8,7 +8,7 @@ import { appStarted } from 'app/store/middleware/listenerMiddleware/listeners/ap
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import type { PartialAppConfig } from 'app/types/invokeai';
|
||||
import ImageUploadOverlay from 'common/components/ImageUploadOverlay';
|
||||
import { useFocusRegionWatcher } from 'common/hooks/interactionScopes';
|
||||
import { useFocusRegionWatcher } from 'common/hooks/focus';
|
||||
import { useClearStorage } from 'common/hooks/useClearStorage';
|
||||
import { useFullscreenDropzone } from 'common/hooks/useFullscreenDropzone';
|
||||
import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useIsRegionFocused } from 'common/hooks/interactionScopes';
|
||||
import { useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { useImageActions } from 'features/gallery/hooks/useImageActions';
|
||||
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
||||
|
||||
182
invokeai/frontend/web/src/common/hooks/focus.ts
Normal file
182
invokeai/frontend/web/src/common/hooks/focus.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
|
||||
import type { Atom } from 'nanostores';
|
||||
import { atom, computed } from 'nanostores';
|
||||
import type { RefObject } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { objectKeys } from 'tsafe';
|
||||
|
||||
/**
|
||||
* We need to manage focus regions to conditionally enable hotkeys:
|
||||
* - Some hotkeys should only be enabled when a specific region is focused.
|
||||
* - Some hotkeys may conflict with other regions, so we need to disable them when a specific region is focused. For
|
||||
* example, `esc` is used to clear the gallery selection, but it is also used to cancel a filter or transform on the
|
||||
* canvas.
|
||||
*
|
||||
* To manage focus regions, we use a system of hooks and stores:
|
||||
* - `useFocusRegion` is a hook that registers an element as part of a focus region. When that element is focused, by
|
||||
* click or any other action, that region is set as the focused region. Optionally, focus can be set on mount. This
|
||||
* is useful for components like the image viewer.
|
||||
* - `useIsRegionFocused` is a hook that returns a boolean indicating if a specific region is focused.
|
||||
* - `useFocusRegionWatcher` is a hook that listens for focus events on the window. When an element is focused, it
|
||||
* checks if it is part of a focus region and sets that region as the focused region.
|
||||
*/
|
||||
|
||||
//
|
||||
|
||||
const log = logger('system');
|
||||
|
||||
/**
|
||||
* The names of the focus regions.
|
||||
*/
|
||||
type FocusRegionName = 'gallery' | 'layers' | 'canvas' | 'workflows' | 'viewer';
|
||||
|
||||
/**
|
||||
* A map of focus regions to the elements that are part of that region.
|
||||
*/
|
||||
const REGION_TARGETS: Record<FocusRegionName, Set<HTMLElement>> = {
|
||||
gallery: new Set<HTMLElement>(),
|
||||
layers: new Set<HTMLElement>(),
|
||||
canvas: new Set<HTMLElement>(),
|
||||
workflows: new Set<HTMLElement>(),
|
||||
viewer: new Set<HTMLElement>(),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The currently-focused region or `null` if no region is focused.
|
||||
*/
|
||||
const $focusedRegion = atom<FocusRegionName | null>(null);
|
||||
|
||||
/**
|
||||
* A map of focus regions to atoms that indicate if that region is focused.
|
||||
*/
|
||||
const FOCUS_REGIONS = objectKeys(REGION_TARGETS).reduce(
|
||||
(acc, region) => {
|
||||
acc[`$${region}`] = computed($focusedRegion, (focusedRegion) => focusedRegion === region);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<`$${FocusRegionName}`, Atom<boolean>>
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the focused region, logging a trace level message.
|
||||
*/
|
||||
const setFocus = (region: FocusRegionName | null) => {
|
||||
$focusedRegion.set(region);
|
||||
log.trace(`Focus changed: ${region}`);
|
||||
};
|
||||
|
||||
type UseFocusRegionOptions = {
|
||||
focusOnMount?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers an element as part of a focus region. When that element is focused, by click or any other action, that
|
||||
* region is set as the focused region. Optionally, focus can be set on mount.
|
||||
*
|
||||
* On unmount, if the element is the last element in the region and the region is focused, the focused region is set to
|
||||
* `null`.
|
||||
*
|
||||
* @param region The focus region name.
|
||||
* @param ref The ref of the element to register.
|
||||
* @param options The options.
|
||||
*/
|
||||
export const useFocusRegion = (
|
||||
region: FocusRegionName,
|
||||
ref: RefObject<HTMLElement>,
|
||||
options?: UseFocusRegionOptions
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { focusOnMount = false } = { focusOnMount: false, ...options };
|
||||
|
||||
const element = ref.current;
|
||||
|
||||
REGION_TARGETS[region].add(element);
|
||||
|
||||
if (focusOnMount) {
|
||||
setFocus(region);
|
||||
}
|
||||
|
||||
return () => {
|
||||
REGION_TARGETS[region].delete(element);
|
||||
|
||||
if (REGION_TARGETS[region].size === 0 && $focusedRegion.get() === region) {
|
||||
setFocus(null);
|
||||
}
|
||||
};
|
||||
}, [options, ref, region]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a boolean indicating if a specific region is focused.
|
||||
* @param region The focus region name.
|
||||
*/
|
||||
export const useIsRegionFocused = (region: FocusRegionName) => {
|
||||
return useStore(FOCUS_REGIONS[`$${region}`]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Listens for focus events on the window. When an element is focused, it checks if it is part of a focus region and sets
|
||||
* that region as the focused region. The region corresponding to the deepest element is set.
|
||||
*/
|
||||
const onFocus = (_: FocusEvent) => {
|
||||
const activeElement = document.activeElement;
|
||||
if (!(activeElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const regionCandidates: { region: FocusRegionName; element: HTMLElement }[] = [];
|
||||
|
||||
for (const region of objectKeys(REGION_TARGETS)) {
|
||||
for (const element of REGION_TARGETS[region]) {
|
||||
if (element.contains(activeElement)) {
|
||||
regionCandidates.push({ region, element });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (regionCandidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by the shallowest element
|
||||
regionCandidates.sort((a, b) => {
|
||||
if (b.element.contains(a.element)) {
|
||||
return -1;
|
||||
}
|
||||
if (a.element.contains(b.element)) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Set the region of the deepest element
|
||||
const focusedRegion = regionCandidates[0]?.region;
|
||||
|
||||
if (!focusedRegion) {
|
||||
log.warn('No focused region found');
|
||||
return;
|
||||
}
|
||||
|
||||
setFocus(focusedRegion);
|
||||
};
|
||||
|
||||
/**
|
||||
* Listens for focus events on the window. When an element is focused, it checks if it is part of a focus region and sets
|
||||
* that region as the focused region. This is a singleton.
|
||||
*/
|
||||
export const useFocusRegionWatcher = () => {
|
||||
useAssertSingleton('useFocusRegionWatcher');
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('focus', onFocus, { capture: true });
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus, { capture: true });
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
@@ -1,143 +0,0 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
|
||||
import type { Atom } from 'nanostores';
|
||||
import { computed, deepMap } from 'nanostores';
|
||||
import type { RefObject } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const log = logger('system');
|
||||
|
||||
const REGION_NAMES = ['gallery', 'layers', 'canvas', 'workflows', 'viewer', 'settings'] as const;
|
||||
|
||||
type FocusRegionName = (typeof REGION_NAMES)[number];
|
||||
type FocusRegionData = { name: FocusRegionName; targets: Set<HTMLElement> };
|
||||
type FocusRegionState = {
|
||||
focusedRegion: FocusRegionName | null;
|
||||
regions: Record<FocusRegionName, FocusRegionData>;
|
||||
};
|
||||
|
||||
const initialData = REGION_NAMES.reduce(
|
||||
(state, region) => {
|
||||
state.regions[region] = { name: region, targets: new Set() };
|
||||
return state;
|
||||
},
|
||||
{
|
||||
focusedRegion: null,
|
||||
regions: {},
|
||||
} as FocusRegionState
|
||||
);
|
||||
|
||||
const $focusRegionState = deepMap<FocusRegionState>(initialData);
|
||||
export const $focusedRegion = computed($focusRegionState, (regions) => regions.focusedRegion);
|
||||
export const FOCUS_REGIONS = REGION_NAMES.reduce(
|
||||
(acc, region) => {
|
||||
acc[`$${region}`] = computed($focusRegionState, (state) => state.focusedRegion === region);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<`$${FocusRegionName}`, Atom<boolean>>
|
||||
);
|
||||
|
||||
const setFocus = (region: FocusRegionName | null) => {
|
||||
$focusRegionState.setKey('focusedRegion', region);
|
||||
log.trace(`Focus changed: ${region}`);
|
||||
};
|
||||
|
||||
type UseFocusRegionOptions = {
|
||||
focusOnMount?: boolean;
|
||||
};
|
||||
|
||||
export const useFocusRegion = (
|
||||
region: FocusRegionName,
|
||||
ref: RefObject<HTMLElement>,
|
||||
options?: UseFocusRegionOptions
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { focusOnMount = false } = { focusOnMount: false, ...options };
|
||||
|
||||
const element = ref.current;
|
||||
|
||||
const regionData = $focusRegionState.get().regions[region];
|
||||
|
||||
const targets = new Set(regionData.targets);
|
||||
targets.add(element);
|
||||
$focusRegionState.setKey(`regions.${region}.targets`, targets);
|
||||
|
||||
if (focusOnMount) {
|
||||
setFocus(region);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const regionData = $focusRegionState.get().regions[region];
|
||||
const targets = new Set(regionData.targets);
|
||||
targets.delete(element);
|
||||
$focusRegionState.setKey(`regions.${region}.targets`, targets);
|
||||
|
||||
if (targets.size === 0 && $focusRegionState.get().focusedRegion === region) {
|
||||
setFocus(null);
|
||||
}
|
||||
};
|
||||
}, [options, ref, region]);
|
||||
};
|
||||
|
||||
export const useIsRegionFocused = (region: FocusRegionName) => {
|
||||
return useStore(FOCUS_REGIONS[`$${region}`]);
|
||||
};
|
||||
|
||||
const onFocus = (_: FocusEvent) => {
|
||||
const activeElement = document.activeElement;
|
||||
if (!(activeElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const regionCandidates: { region: FocusRegionName; element: HTMLElement }[] = [];
|
||||
|
||||
const state = $focusRegionState.get();
|
||||
for (const regionData of Object.values(state.regions)) {
|
||||
for (const element of regionData.targets) {
|
||||
if (element.contains(activeElement)) {
|
||||
regionCandidates.push({ region: regionData.name, element });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (regionCandidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by the shallowest element
|
||||
regionCandidates.sort((a, b) => {
|
||||
if (b.element.contains(a.element)) {
|
||||
return -1;
|
||||
}
|
||||
if (a.element.contains(b.element)) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Set the region of the deepest element
|
||||
const focusedRegion = regionCandidates[0]?.region;
|
||||
|
||||
if (!focusedRegion) {
|
||||
log.warn('No focused region found');
|
||||
return;
|
||||
}
|
||||
|
||||
setFocus(focusedRegion);
|
||||
};
|
||||
|
||||
export const useFocusRegionWatcher = () => {
|
||||
useAssertSingleton('useFocusRegionWatcher');
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('focus', onFocus, { capture: true });
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus, { capture: true });
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Divider, Flex } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useFocusRegion } from 'common/hooks/interactionScopes';
|
||||
import { useFocusRegion } from 'common/hooks/focus';
|
||||
import { CanvasAddEntityButtons } from 'features/controlLayers/components/CanvasAddEntityButtons';
|
||||
import { CanvasEntityList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityList';
|
||||
import { EntityListSelectedEntityActionBar } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ContextMenu, Flex, MenuList } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useFocusRegion } from 'common/hooks/interactionScopes';
|
||||
import { useFocusRegion } from 'common/hooks/focus';
|
||||
import { CanvasAlertsPreserveMask } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask';
|
||||
import { CanvasAlertsSelectedEntityStatus } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus';
|
||||
import { CanvasAlertsSendingToGallery } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSendingTo';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button, ButtonGroup, Flex, FormControl, FormLabel, Heading, Spacer, Switch } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useIsRegionFocused } from 'common/hooks/interactionScopes';
|
||||
import { useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { FilterSettings } from 'features/controlLayers/components/Filters/FilterSettings';
|
||||
import { FilterTypeSelect } from 'features/controlLayers/components/Filters/FilterTypeSelect';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useIsRegionFocused } from 'common/hooks/interactionScopes';
|
||||
import { useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice';
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useIsRegionFocused } from 'common/hooks/interactionScopes';
|
||||
import { useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import {
|
||||
selectImageCount,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useIsRegionFocused } from 'common/hooks/interactionScopes';
|
||||
import { useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import {
|
||||
selectImageCount,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useIsRegionFocused } from 'common/hooks/interactionScopes';
|
||||
import { useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
|
||||
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button, ButtonGroup, Flex, FormControl, FormLabel, Heading, Spacer, Switch } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useIsRegionFocused } from 'common/hooks/interactionScopes';
|
||||
import { useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEntity/types';
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Button, Collapse, Divider, Flex, IconButton, useDisclosure } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useFocusRegion } from 'common/hooks/interactionScopes';
|
||||
import { useFocusRegion } from 'common/hooks/focus';
|
||||
import { GalleryHeader } from 'features/gallery/components/GalleryHeader';
|
||||
import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors';
|
||||
import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Tag, TagCloseButton, TagLabel } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useIsRegionFocused } from 'common/hooks/interactionScopes';
|
||||
import { useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
|
||||
import { selectionChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Flex, IconButton } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useFocusRegion } from 'common/hooks/interactionScopes';
|
||||
import { useFocusRegion } from 'common/hooks/focus';
|
||||
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
|
||||
import { CanvasAlertsSendingToCanvas } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSendingTo';
|
||||
import { CompareToolbar } from 'features/gallery/components/ImageViewer/CompareToolbar';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useIsRegionFocused } from 'common/hooks/interactionScopes';
|
||||
import { useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
|
||||
import { $canvasRightPanelTab } from 'features/controlLayers/store/ephemeral';
|
||||
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'reactflow/dist/style.css';
|
||||
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import { useFocusRegion } from 'common/hooks/interactionScopes';
|
||||
import { useFocusRegion } from 'common/hooks/focus';
|
||||
import { AddNodeCmdk } from 'features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk';
|
||||
import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel';
|
||||
import WorkflowEditorSettings from 'features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useGlobalMenuClose, useToken } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { useFocusRegion, useIsRegionFocused } from 'common/hooks/interactionScopes';
|
||||
import { useFocusRegion, useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { useConnection } from 'features/nodes/hooks/useConnection';
|
||||
import { useCopyPaste } from 'features/nodes/hooks/useCopyPaste';
|
||||
import { useSyncExecutionState } from 'features/nodes/hooks/useExecutionState';
|
||||
|
||||
Reference in New Issue
Block a user