Skip to content

Commit 0580e87

Browse files
psychedelicioushipsterusername
authored andcommitted
tidy,docs(ui): focus region logic
1 parent 483a27f commit 0580e87

File tree

18 files changed

+198
-159
lines changed

18 files changed

+198
-159
lines changed

invokeai/frontend/web/src/app/components/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { appStarted } from 'app/store/middleware/listenerMiddleware/listeners/ap
88
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
99
import type { PartialAppConfig } from 'app/types/invokeai';
1010
import ImageUploadOverlay from 'common/components/ImageUploadOverlay';
11-
import { useFocusRegionWatcher } from 'common/hooks/interactionScopes';
11+
import { useFocusRegionWatcher } from 'common/hooks/focus';
1212
import { useClearStorage } from 'common/hooks/useClearStorage';
1313
import { useFullscreenDropzone } from 'common/hooks/useFullscreenDropzone';
1414
import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';

invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { skipToken } from '@reduxjs/toolkit/query';
22
import { useAppSelector } from 'app/store/storeHooks';
3-
import { useIsRegionFocused } from 'common/hooks/interactionScopes';
3+
import { useIsRegionFocused } from 'common/hooks/focus';
44
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
55
import { useImageActions } from 'features/gallery/hooks/useImageActions';
66
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { useStore } from '@nanostores/react';
2+
import { logger } from 'app/logging/logger';
3+
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
4+
import type { Atom } from 'nanostores';
5+
import { atom, computed } from 'nanostores';
6+
import type { RefObject } from 'react';
7+
import { useEffect } from 'react';
8+
import { objectKeys } from 'tsafe';
9+
10+
/**
11+
* We need to manage focus regions to conditionally enable hotkeys:
12+
* - Some hotkeys should only be enabled when a specific region is focused.
13+
* - Some hotkeys may conflict with other regions, so we need to disable them when a specific region is focused. For
14+
* example, `esc` is used to clear the gallery selection, but it is also used to cancel a filter or transform on the
15+
* canvas.
16+
*
17+
* To manage focus regions, we use a system of hooks and stores:
18+
* - `useFocusRegion` is a hook that registers an element as part of a focus region. When that element is focused, by
19+
* click or any other action, that region is set as the focused region. Optionally, focus can be set on mount. This
20+
* is useful for components like the image viewer.
21+
* - `useIsRegionFocused` is a hook that returns a boolean indicating if a specific region is focused.
22+
* - `useFocusRegionWatcher` is a hook that listens for focus events on the window. When an element is focused, it
23+
* checks if it is part of a focus region and sets that region as the focused region.
24+
*/
25+
26+
//
27+
28+
const log = logger('system');
29+
30+
/**
31+
* The names of the focus regions.
32+
*/
33+
type FocusRegionName = 'gallery' | 'layers' | 'canvas' | 'workflows' | 'viewer';
34+
35+
/**
36+
* A map of focus regions to the elements that are part of that region.
37+
*/
38+
const REGION_TARGETS: Record<FocusRegionName, Set<HTMLElement>> = {
39+
gallery: new Set<HTMLElement>(),
40+
layers: new Set<HTMLElement>(),
41+
canvas: new Set<HTMLElement>(),
42+
workflows: new Set<HTMLElement>(),
43+
viewer: new Set<HTMLElement>(),
44+
} as const;
45+
46+
/**
47+
* The currently-focused region or `null` if no region is focused.
48+
*/
49+
const $focusedRegion = atom<FocusRegionName | null>(null);
50+
51+
/**
52+
* A map of focus regions to atoms that indicate if that region is focused.
53+
*/
54+
const FOCUS_REGIONS = objectKeys(REGION_TARGETS).reduce(
55+
(acc, region) => {
56+
acc[`$${region}`] = computed($focusedRegion, (focusedRegion) => focusedRegion === region);
57+
return acc;
58+
},
59+
{} as Record<`$${FocusRegionName}`, Atom<boolean>>
60+
);
61+
62+
/**
63+
* Sets the focused region, logging a trace level message.
64+
*/
65+
const setFocus = (region: FocusRegionName | null) => {
66+
$focusedRegion.set(region);
67+
log.trace(`Focus changed: ${region}`);
68+
};
69+
70+
type UseFocusRegionOptions = {
71+
focusOnMount?: boolean;
72+
};
73+
74+
/**
75+
* Registers an element as part of a focus region. When that element is focused, by click or any other action, that
76+
* region is set as the focused region. Optionally, focus can be set on mount.
77+
*
78+
* On unmount, if the element is the last element in the region and the region is focused, the focused region is set to
79+
* `null`.
80+
*
81+
* @param region The focus region name.
82+
* @param ref The ref of the element to register.
83+
* @param options The options.
84+
*/
85+
export const useFocusRegion = (
86+
region: FocusRegionName,
87+
ref: RefObject<HTMLElement>,
88+
options?: UseFocusRegionOptions
89+
) => {
90+
useEffect(() => {
91+
if (!ref.current) {
92+
return;
93+
}
94+
95+
const { focusOnMount = false } = { focusOnMount: false, ...options };
96+
97+
const element = ref.current;
98+
99+
REGION_TARGETS[region].add(element);
100+
101+
if (focusOnMount) {
102+
setFocus(region);
103+
}
104+
105+
return () => {
106+
REGION_TARGETS[region].delete(element);
107+
108+
if (REGION_TARGETS[region].size === 0 && $focusedRegion.get() === region) {
109+
setFocus(null);
110+
}
111+
};
112+
}, [options, ref, region]);
113+
};
114+
115+
/**
116+
* Returns a boolean indicating if a specific region is focused.
117+
* @param region The focus region name.
118+
*/
119+
export const useIsRegionFocused = (region: FocusRegionName) => {
120+
return useStore(FOCUS_REGIONS[`$${region}`]);
121+
};
122+
123+
/**
124+
* Listens for focus events on the window. When an element is focused, it checks if it is part of a focus region and sets
125+
* that region as the focused region. The region corresponding to the deepest element is set.
126+
*/
127+
const onFocus = (_: FocusEvent) => {
128+
const activeElement = document.activeElement;
129+
if (!(activeElement instanceof HTMLElement)) {
130+
return;
131+
}
132+
133+
const regionCandidates: { region: FocusRegionName; element: HTMLElement }[] = [];
134+
135+
for (const region of objectKeys(REGION_TARGETS)) {
136+
for (const element of REGION_TARGETS[region]) {
137+
if (element.contains(activeElement)) {
138+
regionCandidates.push({ region, element });
139+
}
140+
}
141+
}
142+
143+
if (regionCandidates.length === 0) {
144+
return;
145+
}
146+
147+
// Sort by the shallowest element
148+
regionCandidates.sort((a, b) => {
149+
if (b.element.contains(a.element)) {
150+
return -1;
151+
}
152+
if (a.element.contains(b.element)) {
153+
return 1;
154+
}
155+
return 0;
156+
});
157+
158+
// Set the region of the deepest element
159+
const focusedRegion = regionCandidates[0]?.region;
160+
161+
if (!focusedRegion) {
162+
log.warn('No focused region found');
163+
return;
164+
}
165+
166+
setFocus(focusedRegion);
167+
};
168+
169+
/**
170+
* Listens for focus events on the window. When an element is focused, it checks if it is part of a focus region and sets
171+
* that region as the focused region. This is a singleton.
172+
*/
173+
export const useFocusRegionWatcher = () => {
174+
useAssertSingleton('useFocusRegionWatcher');
175+
176+
useEffect(() => {
177+
window.addEventListener('focus', onFocus, { capture: true });
178+
return () => {
179+
window.removeEventListener('focus', onFocus, { capture: true });
180+
};
181+
}, []);
182+
};

invokeai/frontend/web/src/common/hooks/interactionScopes.ts

Lines changed: 0 additions & 143 deletions
This file was deleted.

invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Divider, Flex } from '@invoke-ai/ui-library';
22
import { useAppSelector } from 'app/store/storeHooks';
3-
import { useFocusRegion } from 'common/hooks/interactionScopes';
3+
import { useFocusRegion } from 'common/hooks/focus';
44
import { CanvasAddEntityButtons } from 'features/controlLayers/components/CanvasAddEntityButtons';
55
import { CanvasEntityList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityList';
66
import { EntityListSelectedEntityActionBar } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar';

invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ContextMenu, Flex, MenuList } from '@invoke-ai/ui-library';
22
import { useAppSelector } from 'app/store/storeHooks';
3-
import { useFocusRegion } from 'common/hooks/interactionScopes';
3+
import { useFocusRegion } from 'common/hooks/focus';
44
import { CanvasAlertsPreserveMask } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask';
55
import { CanvasAlertsSelectedEntityStatus } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus';
66
import { CanvasAlertsSendingToGallery } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSendingTo';

invokeai/frontend/web/src/features/controlLayers/components/Filters/Filter.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Button, ButtonGroup, Flex, FormControl, FormLabel, Heading, Spacer, Switch } from '@invoke-ai/ui-library';
22
import { useStore } from '@nanostores/react';
33
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
4-
import { useIsRegionFocused } from 'common/hooks/interactionScopes';
4+
import { useIsRegionFocused } from 'common/hooks/focus';
55
import { FilterSettings } from 'features/controlLayers/components/Filters/FilterSettings';
66
import { FilterTypeSelect } from 'features/controlLayers/components/Filters/FilterTypeSelect';
77
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';

invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { IconButton } from '@invoke-ai/ui-library';
22
import { useStore } from '@nanostores/react';
33
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
4-
import { useIsRegionFocused } from 'common/hooks/interactionScopes';
4+
import { useIsRegionFocused } from 'common/hooks/focus';
55
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
66
import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice';
77
import {

invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { IconButton } from '@invoke-ai/ui-library';
22
import { useStore } from '@nanostores/react';
33
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
4-
import { useIsRegionFocused } from 'common/hooks/interactionScopes';
4+
import { useIsRegionFocused } from 'common/hooks/focus';
55
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
66
import {
77
selectImageCount,

invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { IconButton } from '@invoke-ai/ui-library';
22
import { useStore } from '@nanostores/react';
33
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
4-
import { useIsRegionFocused } from 'common/hooks/interactionScopes';
4+
import { useIsRegionFocused } from 'common/hooks/focus';
55
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
66
import {
77
selectImageCount,

0 commit comments

Comments
 (0)