File size: 5,600 Bytes
8a37e0a |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 |
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 });
};
}, []);
};
|