tidy,docs(ui): focus region logic

This commit is contained in:
psychedelicious
2024-09-30 18:36:16 +10:00
parent fcdbb729d3
commit cd6ef3edb3
18 changed files with 198 additions and 159 deletions

View File

@@ -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';

View File

@@ -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';

View 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 });
};
}, []);
};

View File

@@ -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 });
};
}, []);
};

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';