Skip to content

Commit a1693fc

Browse files
refactor(ui): focus/hotkey handling for new layout
1 parent 4077ffe commit a1693fc

File tree

17 files changed

+453
-152
lines changed

17 files changed

+453
-152
lines changed

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { $whatsNew } from 'app/store/nanostores/whatsNew';
2323
import { createStore } from 'app/store/store';
2424
import type { PartialAppConfig } from 'app/types/invokeai';
2525
import Loading from 'common/components/Loading/Loading';
26+
import { FocusApiProvider } from 'common/hooks/focus2';
2627
import type { WorkflowSortOption, WorkflowTagCategory } from 'features/nodes/store/workflowLibrarySlice';
2728
import {
2829
$workflowLibraryCategoriesOptions,
@@ -331,7 +332,9 @@ const InvokeAIUI = ({
331332
<Provider store={store}>
332333
<React.Suspense fallback={<Loading />}>
333334
<ThemeLocaleProvider>
334-
<App config={config} studioInitAction={studioInitAction} />
335+
<FocusApiProvider>
336+
<App config={config} studioInitAction={studioInitAction} />
337+
</FocusApiProvider>
335338
</ThemeLocaleProvider>
336339
</React.Suspense>
337340
</Provider>

invokeai/frontend/web/src/common/components/FocusRegionWrapper.tsx

Lines changed: 25 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,48 +9,44 @@ interface FocusRegionWrapperProps extends BoxProps {
99
focusOnMount?: boolean;
1010
}
1111

12-
const FOCUS_REGION_STYLES: SystemStyleObject = {
12+
const BASE_FOCUS_REGION_STYLES: SystemStyleObject = {
1313
position: 'relative',
1414
'&[data-highlighted="true"]::after': {
15-
borderColor: 'blue.700',
15+
borderColor: 'blue.300',
1616
},
1717
'&::after': {
1818
content: '""',
1919
position: 'absolute',
20-
inset: 0,
20+
inset: '2px',
2121
zIndex: 1,
2222
borderRadius: 'base',
23-
border: '2px solid',
23+
border: '1px solid',
2424
borderColor: 'transparent',
2525
pointerEvents: 'none',
2626
transition: 'border-color 0.1s ease-in-out',
2727
},
2828
};
2929

30-
export const FocusRegionWrapper = memo(
31-
({ region, focusOnMount = false, sx, children, ...boxProps }: FocusRegionWrapperProps) => {
32-
const shouldHighlightFocusedRegions = useAppSelector(selectSystemShouldEnableHighlightFocusedRegions);
33-
34-
const ref = useRef<HTMLDivElement>(null);
35-
36-
const options = useMemo(() => ({ focusOnMount }), [focusOnMount]);
37-
38-
useFocusRegion(region, ref, options);
39-
const isFocused = useIsRegionFocused(region);
40-
const isHighlighted = isFocused && shouldHighlightFocusedRegions;
41-
42-
return (
43-
<Box
44-
ref={ref}
45-
tabIndex={-1}
46-
sx={useMemo(() => ({ ...FOCUS_REGION_STYLES, ...sx }), [sx])}
47-
data-highlighted={isHighlighted}
48-
{...boxProps}
49-
>
50-
{children}
51-
</Box>
52-
);
53-
}
54-
);
30+
export const FocusRegionWrapper = memo((props: FocusRegionWrapperProps) => {
31+
const { region, focusOnMount = false, sx: _sx, children, ...boxProps } = props;
32+
33+
const ref = useRef<HTMLDivElement>(null);
34+
35+
const sx = useMemo(() => ({ ...BASE_FOCUS_REGION_STYLES, ..._sx }), [_sx]);
36+
37+
const shouldHighlightFocusedRegions = useAppSelector(selectSystemShouldEnableHighlightFocusedRegions);
38+
39+
const options = useMemo(() => ({ focusOnMount }), [focusOnMount]);
40+
41+
useFocusRegion(region, ref, options);
42+
const isFocused = useIsRegionFocused(region);
43+
const isHighlighted = isFocused && shouldHighlightFocusedRegions;
44+
45+
return (
46+
<Box ref={ref} tabIndex={-1} sx={sx} data-highlighted={isHighlighted} {...boxProps}>
47+
{children}
48+
</Box>
49+
);
50+
});
5551

5652
FocusRegionWrapper.displayName = 'FocusRegionWrapper';

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

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { atom, computed } from 'nanostores';
66
import type { RefObject } from 'react';
77
import { useEffect } from 'react';
88
import { objectKeys } from 'tsafe';
9+
import z from 'zod/v4';
910

1011
/**
1112
* We need to manage focus regions to conditionally enable hotkeys:
@@ -30,18 +31,29 @@ const log = logger('system');
3031
/**
3132
* The names of the focus regions.
3233
*/
33-
export type FocusRegionName = 'gallery' | 'layers' | 'canvas' | 'workflows' | 'viewer';
34+
const zFocusRegionName = z.enum([
35+
'launchpad',
36+
'viewer',
37+
'gallery',
38+
'boards',
39+
'layers',
40+
'canvas',
41+
'workflows',
42+
'progress',
43+
'settings',
44+
]);
45+
export type FocusRegionName = z.infer<typeof zFocusRegionName>;
3446

3547
/**
3648
* A map of focus regions to the elements that are part of that region.
3749
*/
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;
50+
const REGION_TARGETS: Record<FocusRegionName, Set<HTMLElement>> = zFocusRegionName.options.values().reduce(
51+
(acc, region) => {
52+
acc[region] = new Set<HTMLElement>();
53+
return acc;
54+
},
55+
{} as Record<FocusRegionName, Set<HTMLElement>>
56+
);
4557

4658
/**
4759
* The currently-focused region or `null` if no region is focused.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { useAppStore } from 'app/store/storeHooks';
2+
import { selectActiveTab } from 'features/ui/store/uiSelectors';
3+
import type { TabName } from 'features/ui/store/uiTypes';
4+
import type { Atom } from 'nanostores';
5+
import { atom } from 'nanostores';
6+
import type { PropsWithChildren } from 'react';
7+
import { createContext, memo, useContext, useEffect } from 'react';
8+
9+
type FocusState = {
10+
tab: TabName;
11+
panels: Record<TabName, string | null>;
12+
};
13+
14+
const $focusState = atom<FocusState>({
15+
tab: 'generate',
16+
panels: {
17+
canvas: null,
18+
generate: null,
19+
upscaling: null,
20+
workflows: null,
21+
models: null,
22+
queue: null,
23+
},
24+
});
25+
26+
type FocusedTabAndPanel = {
27+
tab: TabName;
28+
panelId: string | null;
29+
};
30+
31+
const getFocusedTabAndPanel = (focusState: FocusState): FocusedTabAndPanel => {
32+
const { tab, panels } = focusState;
33+
return { tab, panelId: panels[tab] || null };
34+
};
35+
36+
const $focusedTabAndPanel = atom<FocusedTabAndPanel>(getFocusedTabAndPanel($focusState.get()));
37+
38+
$focusState.listen((focusState) => {
39+
const prev = $focusedTabAndPanel.get();
40+
const next = getFocusedTabAndPanel(focusState);
41+
if (prev.tab === next.tab && prev.panelId === next.panelId) {
42+
return;
43+
}
44+
$focusedTabAndPanel.set(next);
45+
});
46+
47+
const _setFocusedTab = (tab: TabName) => {
48+
const state = $focusState.get();
49+
if (state.tab === tab) {
50+
return;
51+
}
52+
$focusState.set({ ...state, tab });
53+
};
54+
const setFocusedPanel = (tab: TabName, panelId: string) => {
55+
const state = $focusState.get();
56+
if (state === null) {
57+
return;
58+
}
59+
if (state.panels[tab] === panelId) {
60+
return;
61+
}
62+
$focusState.set({ ...state, panels: { ...state.panels, [tab]: panelId } });
63+
};
64+
65+
export type FocusApi = {
66+
$focusState: Atom<FocusState>;
67+
$focusedTabAndPanel: Atom<FocusedTabAndPanel>;
68+
setFocusedPanel: (tab: TabName, panelId: string) => void;
69+
};
70+
71+
export const focusApi: FocusApi = { $focusState, $focusedTabAndPanel, setFocusedPanel };
72+
73+
const FocusApiContext = createContext<FocusApi | null>(null);
74+
75+
export const FocusApiProvider = memo((props: PropsWithChildren) => {
76+
const store = useAppStore();
77+
useEffect(() => {
78+
const cb = () => {
79+
const tab = selectActiveTab(store.getState());
80+
_setFocusedTab(tab);
81+
};
82+
const unsubscribe = store.subscribe(cb);
83+
cb();
84+
return () => {
85+
unsubscribe();
86+
};
87+
}, [store]);
88+
return <FocusApiContext.Provider value={focusApi}>{props.children}</FocusApiContext.Provider>;
89+
});
90+
FocusApiProvider.displayName = 'FocusApiProvider';
91+
92+
export const useFocusApi = () => {
93+
const context = useContext(FocusApiContext);
94+
if (context === null) {
95+
throw new Error('useFocusApi must be used within a FocusApiProvider');
96+
}
97+
return context;
98+
};
99+
100+
$focusedTabAndPanel.subscribe((state) => {
101+
console.log(state);
102+
});

invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Alert, Button, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library';
22
import { useAppDispatch } from 'app/store/storeHooks';
3+
import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper';
34
import { InitialStateMainModelPicker } from 'features/controlLayers/components/SimpleSession/InitialStateMainModelPicker';
45
import { LaunchpadAddStyleReference } from 'features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference';
56
import { setActiveTab } from 'features/ui/store/uiSlice';
@@ -14,7 +15,15 @@ export const GenerateLaunchpadPanel = memo(() => {
1415
}, [dispatch]);
1516

1617
return (
17-
<Flex flexDir="column" h="full" w="full" alignItems="center" gap={2}>
18+
<FocusRegionWrapper
19+
region="launchpad"
20+
display="flex"
21+
flexDir="column"
22+
h="full"
23+
w="full"
24+
alignItems="center"
25+
gap={2}
26+
>
1827
<Flex flexDir="column" w="full" gap={4} px={14} maxW={768} pt="20vh">
1928
<Heading mb={4}>Generate images from text prompts.</Heading>
2029
<Flex flexDir="column" gap={8}>
@@ -41,7 +50,7 @@ export const GenerateLaunchpadPanel = memo(() => {
4150
</Alert>
4251
</Flex>
4352
</Flex>
44-
</Flex>
53+
</FocusRegionWrapper>
4554
);
4655
});
4756
GenerateLaunchpadPanel.displayName = 'GenerateLaunchpad';

invokeai/frontend/web/src/features/gallery/components/BoardsListPanelContent.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Box, Button, Collapse, Divider, Flex, IconButton } from '@invoke-ai/ui-library';
22
import { useStore } from '@nanostores/react';
33
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
4+
import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper';
45
import { useDisclosure } from 'common/hooks/useBoolean';
56
import { BoardsListWrapper } from 'features/gallery/components/Boards/BoardsList/BoardsListWrapper';
67
import { BoardsSearch } from 'features/gallery/components/Boards/BoardsList/BoardsSearch';
@@ -45,7 +46,7 @@ export const BoardsPanel = memo(() => {
4546
}, [boardSearchText.length, searchDisclosure, collapsibleApi, dispatch]);
4647

4748
return (
48-
<Flex flexDir="column" w="full" h="full" p={2}>
49+
<FocusRegionWrapper region="boards" display="flex" flexDir="column" w="full" h="full" p={2}>
4950
<Flex alignItems="center" justifyContent="space-between" w="full">
5051
<Flex flexGrow={1} flexBasis={0}>
5152
<Button
@@ -81,7 +82,7 @@ export const BoardsPanel = memo(() => {
8182
</Collapse>
8283
<Divider pt={2} />
8384
<BoardsListWrapper />
84-
</Flex>
85+
</FocusRegionWrapper>
8586
);
8687
});
8788
BoardsPanel.displayName = 'BoardsPanel';

invokeai/frontend/web/src/features/gallery/components/Gallery.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Box, Button, ButtonGroup, Collapse, Divider, Flex, IconButton, Spacer }
22
import { useStore } from '@nanostores/react';
33
import { createSelector } from '@reduxjs/toolkit';
44
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
5+
import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper';
56
import { useDisclosure } from 'common/hooks/useBoolean';
67
import { useGallerySearchTerm } from 'features/gallery/components/ImageGrid/useGallerySearchTerm';
78
import { selectSelectedBoardId } from 'features/gallery/store/gallerySelectors';
@@ -67,7 +68,17 @@ export const GalleryPanel = memo(() => {
6768
const boardName = useBoardName(selectedBoardId);
6869

6970
return (
70-
<Flex flexDirection="column" alignItems="center" justifyContent="space-between" h="full" w="full" minH={0} p={2}>
71+
<FocusRegionWrapper
72+
region="gallery"
73+
display="flex"
74+
flexDirection="column"
75+
alignItems="center"
76+
justifyContent="space-between"
77+
h="full"
78+
w="full"
79+
minH={0}
80+
p={2}
81+
>
7182
<Flex gap={2} fontSize="sm" alignItems="center" w="full">
7283
<Button
7384
size="sm"
@@ -123,7 +134,7 @@ export const GalleryPanel = memo(() => {
123134
<Flex w="full" h="full" pt={2}>
124135
<NewGallery />
125136
</Flex>
126-
</Flex>
137+
</FocusRegionWrapper>
127138
);
128139
});
129140
GalleryPanel.displayName = 'Gallery';
Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
1-
import { Flex } from '@invoke-ai/ui-library';
1+
import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper';
22
import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage';
33
import { memo } from 'react';
44

55
import { ProgressIndicator } from './ProgressIndicator';
66

77
export const GenerationProgressPanel = memo(() => (
8-
<Flex position="relative" flexDir="column" w="full" h="full" overflow="hidden" p={2}>
8+
<FocusRegionWrapper
9+
region="progress"
10+
display="flex"
11+
position="relative"
12+
flexDir="column"
13+
w="full"
14+
h="full"
15+
overflow="hidden"
16+
p={2}
17+
>
918
<ProgressImage />
1019
<ProgressIndicator position="absolute" top={6} right={6} size={8} />
11-
</Flex>
20+
</FocusRegionWrapper>
1221
));
1322
GenerationProgressPanel.displayName = 'GenerationProgressPanel';

invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Divider, Flex } from '@invoke-ai/ui-library';
1+
import { Divider } from '@invoke-ai/ui-library';
2+
import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper';
23
import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
34
import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar';
45
import { memo } from 'react';
@@ -8,11 +9,20 @@ import { ImageViewerContextProvider } from './context';
89
export const ImageViewerPanel = memo(() => {
910
return (
1011
<ImageViewerContextProvider>
11-
<Flex flexDir="column" w="full" h="full" overflow="hidden" p={2} gap={2}>
12+
<FocusRegionWrapper
13+
region="viewer"
14+
display="flex"
15+
flexDir="column"
16+
w="full"
17+
h="full"
18+
overflow="hidden"
19+
p={2}
20+
gap={2}
21+
>
1222
<ViewerToolbar />
1323
<Divider />
1424
<ImageViewer />
15-
</Flex>
25+
</FocusRegionWrapper>
1626
</ImageViewerContextProvider>
1727
);
1828
});

0 commit comments

Comments
 (0)