Skip to content

Commit

Permalink
tidy,docs(ui): focus region logic
Browse files Browse the repository at this point in the history
  • Loading branch information
psychedelicious authored and hipsterusername committed Sep 30, 2024
1 parent 483a27f commit 0580e87
Show file tree
Hide file tree
Showing 18 changed files with 198 additions and 159 deletions.
2 changes: 1 addition & 1 deletion invokeai/frontend/web/src/app/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
182 changes: 182 additions & 0 deletions invokeai/frontend/web/src/common/hooks/focus.ts
Original file line number Diff line number Diff line change
@@ -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 });
};
}, []);
};
143 changes: 0 additions & 143 deletions invokeai/frontend/web/src/common/hooks/interactionScopes.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading

0 comments on commit 0580e87

Please # to comment.