Introduce focus stack to handle hotkeys (#12166)

# Introduce focus stack to handle hotkeys

This PR introduces a focus stack to track the order in which the
elements are focused:
- Each focused element has a unique focus id
- When an element is focused, it is pushed on top of the stack
- When an element loses focus, we remove it from the stack 

This focus stack is then used to determine which hotkeys are available.
The previous implementation lead to many regressions because of race
conditions, of wrong order of open and close operations and by
overwriting previous states. This implementation should be way more
robust than the previous one.

The new api can be incrementally implemented since it preserves
backwards compatibility by writing to the old hotkey scopes states.
For now, it has been implemented on the modal components.

To test this PR, verify that the shortcuts still work correctly,
especially for the modal components.
This commit is contained in:
Raphaël Bosi
2025-05-21 15:52:40 +02:00
committed by GitHub
parent 7f1d6f5c7f
commit c982bcdb52
69 changed files with 1233 additions and 267 deletions

View File

@ -0,0 +1,90 @@
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
const renderHooks = () => {
const { result } = renderHook(
() => {
const pushFocusItem = usePushFocusItemToFocusStack();
const focusStack = useRecoilValue(focusStackState);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
return {
pushFocusItem,
focusStack,
currentFocusId,
};
},
{
wrapper: RecoilRoot,
},
);
return { result };
};
describe('usePushFocusItemToFocusStack', () => {
it('should push focus item to the stack', async () => {
const { result } = renderHooks();
expect(result.current.focusStack).toEqual([]);
const focusItem = {
focusId: 'test-focus-id',
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: 'test-instance-id',
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
};
await act(async () => {
result.current.pushFocusItem({
focusId: focusItem.focusId,
component: {
type: focusItem.componentInstance.componentType,
instanceId: focusItem.componentInstance.componentInstanceId,
},
hotkeyScope: { scope: 'test-scope' },
memoizeKey: 'global',
});
});
expect(result.current.focusStack).toEqual([focusItem]);
expect(result.current.currentFocusId).toEqual(focusItem.focusId);
const anotherFocusItem = {
focusId: 'another-focus-id',
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: 'another-instance-id',
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
};
await act(async () => {
result.current.pushFocusItem({
focusId: anotherFocusItem.focusId,
component: {
type: anotherFocusItem.componentInstance.componentType,
instanceId: anotherFocusItem.componentInstance.componentInstanceId,
},
hotkeyScope: { scope: 'test-scope' },
memoizeKey: 'global',
});
});
expect(result.current.focusStack).toEqual([focusItem, anotherFocusItem]);
expect(result.current.currentFocusId).toEqual(anotherFocusItem.focusId);
});
});

View File

@ -0,0 +1,101 @@
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusIdFromFocusStack } from '@/ui/utilities/focus/hooks/useRemoveFocusIdFromFocusStack';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
const renderHooks = () => {
const { result } = renderHook(
() => {
const pushFocusItem = usePushFocusItemToFocusStack();
const removeFocusId = useRemoveFocusIdFromFocusStack();
const focusStack = useRecoilValue(focusStackState);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
return {
pushFocusItem,
removeFocusId,
focusStack,
currentFocusId,
};
},
{
wrapper: RecoilRoot,
},
);
return { result };
};
describe('useRemoveFocusIdFromFocusStack', () => {
it('should remove focus id from the stack', async () => {
const { result } = renderHooks();
const firstFocusItem = {
focusId: 'first-focus-id',
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: 'first-instance-id',
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
};
const secondFocusItem = {
focusId: 'second-focus-id',
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: 'second-instance-id',
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
};
await act(async () => {
result.current.pushFocusItem({
focusId: firstFocusItem.focusId,
component: {
type: firstFocusItem.componentInstance.componentType,
instanceId: firstFocusItem.componentInstance.componentInstanceId,
},
hotkeyScope: { scope: 'test-scope' },
memoizeKey: 'global',
});
});
await act(async () => {
result.current.pushFocusItem({
focusId: secondFocusItem.focusId,
component: {
type: secondFocusItem.componentInstance.componentType,
instanceId: secondFocusItem.componentInstance.componentInstanceId,
},
hotkeyScope: { scope: 'test-scope' },
memoizeKey: 'global',
});
});
expect(result.current.focusStack).toEqual([
firstFocusItem,
secondFocusItem,
]);
expect(result.current.currentFocusId).toEqual(secondFocusItem.focusId);
await act(async () => {
result.current.removeFocusId({
focusId: firstFocusItem.focusId,
memoizeKey: 'global',
});
});
expect(result.current.focusStack).toEqual([secondFocusItem]);
expect(result.current.currentFocusId).toEqual(secondFocusItem.focusId);
});
});

View File

@ -0,0 +1,71 @@
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useResetFocusStack } from '@/ui/utilities/focus/hooks/useResetFocusStack';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
const renderHooks = () => {
const { result } = renderHook(
() => {
const pushFocusItem = usePushFocusItemToFocusStack();
const resetFocusStack = useResetFocusStack();
const focusStack = useRecoilValue(focusStackState);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
return {
pushFocusItem,
resetFocusStack,
focusStack,
currentFocusId,
};
},
{
wrapper: RecoilRoot,
},
);
return { result };
};
describe('useResetFocusStack', () => {
it('should reset the focus stack', async () => {
const { result } = renderHooks();
const focusItem = {
focusId: 'test-focus-id',
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: 'test-instance-id',
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
};
await act(async () => {
result.current.pushFocusItem({
focusId: focusItem.focusId,
component: {
type: focusItem.componentInstance.componentType,
instanceId: focusItem.componentInstance.componentInstanceId,
},
hotkeyScope: { scope: 'test-scope' },
memoizeKey: 'global',
});
});
expect(result.current.focusStack).toEqual([focusItem]);
expect(result.current.currentFocusId).toEqual(focusItem.focusId);
await act(async () => {
result.current.resetFocusStack();
});
expect(result.current.focusStack).toEqual([]);
expect(result.current.currentFocusId).toEqual(undefined);
});
});

View File

@ -0,0 +1,102 @@
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useResetFocusStackToFocusItem';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
const renderHooks = () => {
const { result } = renderHook(
() => {
const pushFocusItem = usePushFocusItemToFocusStack();
const resetFocusStackToFocusItem = useResetFocusStackToFocusItem();
const focusStack = useRecoilValue(focusStackState);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
return {
pushFocusItem,
resetFocusStackToFocusItem,
focusStack,
currentFocusId,
};
},
{
wrapper: RecoilRoot,
},
);
return { result };
};
describe('useResetFocusStackToFocusItem', () => {
it('should reset the focus stack to a specific focus item', async () => {
const { result } = renderHooks();
const firstFocusItem = {
focusId: 'first-focus-id',
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: 'first-instance-id',
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
};
const secondFocusItem = {
focusId: 'second-focus-id',
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: 'second-instance-id',
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
};
await act(async () => {
result.current.pushFocusItem({
focusId: firstFocusItem.focusId,
component: {
type: firstFocusItem.componentInstance.componentType,
instanceId: firstFocusItem.componentInstance.componentInstanceId,
},
hotkeyScope: { scope: 'test-scope' },
memoizeKey: 'global',
});
});
await act(async () => {
result.current.pushFocusItem({
focusId: secondFocusItem.focusId,
component: {
type: secondFocusItem.componentInstance.componentType,
instanceId: secondFocusItem.componentInstance.componentInstanceId,
},
hotkeyScope: { scope: 'test-scope' },
memoizeKey: 'global',
});
});
expect(result.current.focusStack).toEqual([
firstFocusItem,
secondFocusItem,
]);
expect(result.current.currentFocusId).toEqual(secondFocusItem.focusId);
await act(async () => {
result.current.resetFocusStackToFocusItem({
focusStackItem: firstFocusItem,
hotkeyScope: { scope: 'test-scope' },
memoizeKey: 'global',
});
});
expect(result.current.focusStack).toEqual([firstFocusItem]);
expect(result.current.currentFocusId).toEqual(firstFocusItem.focusId);
});
});

View File

@ -0,0 +1,71 @@
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilCallback } from 'recoil';
export const usePushFocusItemToFocusStack = () => {
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
const addOrMoveItemToTheTopOfTheStack = useRecoilCallback(
({ set }) =>
(focusStackItem: FocusStackItem) => {
set(focusStackState, (currentFocusStack) => [
...currentFocusStack.filter(
(currentFocusStackItem) =>
currentFocusStackItem.focusId !== focusStackItem.focusId,
),
focusStackItem,
]);
},
[],
);
return useRecoilCallback(
() =>
({
focusId,
component,
hotkeyScope,
memoizeKey = 'global',
globalHotkeysConfig,
}: {
focusId: string;
component: {
type: FocusComponentType;
instanceId: string;
};
globalHotkeysConfig?: Partial<GlobalHotkeysConfig>;
// TODO: Remove this once we've migrated hotkey scopes to the new api
hotkeyScope: HotkeyScope;
memoizeKey: string;
}) => {
const focusStackItem: FocusStackItem = {
focusId,
componentInstance: {
componentType: component.type,
componentInstanceId: component.instanceId,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers:
globalHotkeysConfig?.enableGlobalHotkeysWithModifiers ?? true,
enableGlobalHotkeysConflictingWithKeyboard:
globalHotkeysConfig?.enableGlobalHotkeysConflictingWithKeyboard ??
true,
},
};
addOrMoveItemToTheTopOfTheStack(focusStackItem);
// TODO: Remove this once we've migrated hotkey scopes to the new api
setHotkeyScopeAndMemorizePreviousScope({
scope: hotkeyScope.scope,
customScopes: hotkeyScope.customScopes,
memoizeKey,
});
},
[setHotkeyScopeAndMemorizePreviousScope, addOrMoveItemToTheTopOfTheStack],
);
};

View File

@ -0,0 +1,22 @@
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useRecoilCallback } from 'recoil';
export const useRemoveFocusIdFromFocusStack = () => {
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
return useRecoilCallback(
({ set }) =>
({ focusId, memoizeKey }: { focusId: string; memoizeKey: string }) => {
set(focusStackState, (previousFocusStack) =>
previousFocusStack.filter(
(focusStackItem) => focusStackItem.focusId !== focusId,
),
);
// TODO: Remove this once we've migrated hotkey scopes to the new api
goBackToPreviousHotkeyScope(memoizeKey);
},
[goBackToPreviousHotkeyScope],
);
};

View File

@ -0,0 +1,18 @@
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { previousHotkeyScopeFamilyState } from '@/ui/utilities/hotkey/states/internal/previousHotkeyScopeFamilyState';
import { useRecoilCallback } from 'recoil';
export const useResetFocusStack = () => {
return useRecoilCallback(
({ reset }) =>
(memoizeKey = 'global') => {
reset(focusStackState);
// TODO: Remove this once we've migrated hotkey scopes to the new api
reset(previousHotkeyScopeFamilyState(memoizeKey as string));
reset(currentHotkeyScopeState);
},
[],
);
};

View File

@ -0,0 +1,28 @@
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem';
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { previousHotkeyScopeFamilyState } from '@/ui/utilities/hotkey/states/internal/previousHotkeyScopeFamilyState';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilCallback } from 'recoil';
export const useResetFocusStackToFocusItem = () => {
return useRecoilCallback(
({ set }) =>
({
focusStackItem,
hotkeyScope,
memoizeKey,
}: {
focusStackItem: FocusStackItem;
hotkeyScope: HotkeyScope;
memoizeKey: string;
}) => {
set(focusStackState, [focusStackItem]);
// TODO: Remove this once we've migrated hotkey scopes to the new api
set(previousHotkeyScopeFamilyState(memoizeKey), null);
set(currentHotkeyScopeState, hotkeyScope);
},
[],
);
};

View File

@ -0,0 +1,11 @@
import { selector } from 'recoil';
import { focusStackState } from './focusStackState';
export const currentFocusIdSelector = selector<string | undefined>({
key: 'currentFocusIdSelector',
get: ({ get }) => {
const focusStack = get(focusStackState);
return focusStack.at(-1)?.focusId;
},
});

View File

@ -0,0 +1,21 @@
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { DEFAULT_GLOBAL_HOTKEYS_CONFIG } from '@/ui/utilities/hotkey/constants/DefaultGlobalHotkeysConfig';
import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
import { selector } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const currentGlobalHotkeysConfigSelector = selector<GlobalHotkeysConfig>(
{
key: 'currentGlobalHotkeysConfigSelector',
get: ({ get }) => {
const focusStack = get(focusStackState);
const lastFocusStackItem = focusStack.at(-1);
if (!isDefined(lastFocusStackItem)) {
return DEFAULT_GLOBAL_HOTKEYS_CONFIG;
}
return lastFocusStackItem.globalHotkeysConfig;
},
},
);

View File

@ -0,0 +1,7 @@
import { FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem';
import { createState } from 'twenty-ui/utilities';
export const focusStackState = createState<FocusStackItem[]>({
key: 'focusStackState',
defaultValue: [],
});

View File

@ -0,0 +1,6 @@
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
export type FocusComponentInstance = {
componentType: FocusComponentType;
componentInstanceId: string;
};

View File

@ -0,0 +1,3 @@
export enum FocusComponentType {
MODAL = 'modal',
}

View File

@ -0,0 +1,8 @@
import { FocusComponentInstance } from '@/ui/utilities/focus/types/FocusComponentInstance';
import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
export type FocusStackItem = {
focusId: string;
componentInstance: FocusComponentInstance;
globalHotkeysConfig: GlobalHotkeysConfig;
};

View File

@ -0,0 +1 @@
export const DEBUG_HOTKEY_SCOPE = false;

View File

@ -0,0 +1,6 @@
import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
export const DEFAULT_GLOBAL_HOTKEYS_CONFIG: GlobalHotkeysConfig = {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
};

View File

@ -0,0 +1,74 @@
import { useGlobalHotkeysCallback } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeysCallback';
import { pendingHotkeyState } from '@/ui/utilities/hotkey/states/internal/pendingHotkeysState';
import { useHotkeys } from 'react-hotkeys-hook';
import { HotkeyCallback, Keys, Options } from 'react-hotkeys-hook/dist/types';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
type UseHotkeysOptionsWithoutBuggyOptions = Omit<Options, 'enabled'>;
export const useGlobalHotkeys = (
keys: Keys,
callback: HotkeyCallback,
containsModifier: boolean,
// TODO: Remove this once we've migrated hotkey scopes to the new api
scope: string,
dependencies?: unknown[],
options?: UseHotkeysOptionsWithoutBuggyOptions,
) => {
const callGlobalHotkeysCallback = useGlobalHotkeysCallback(dependencies);
const enableOnContentEditable = isDefined(options?.enableOnContentEditable)
? options.enableOnContentEditable
: true;
const enableOnFormTags = isDefined(options?.enableOnFormTags)
? options.enableOnFormTags
: true;
const preventDefault = isDefined(options?.preventDefault)
? options.preventDefault === true
: true;
const ignoreModifiers = isDefined(options?.ignoreModifiers)
? options.ignoreModifiers === true
: false;
const handleCallback = useRecoilCallback(
({ snapshot, set }) =>
async (keyboardEvent: KeyboardEvent, hotkeysEvent: any) => {
const pendingHotkey = snapshot
.getLoadable(pendingHotkeyState)
.getValue();
if (!pendingHotkey) {
callback(keyboardEvent, hotkeysEvent);
}
set(pendingHotkeyState, null);
},
[callback],
);
return useHotkeys(
keys,
(keyboardEvent, hotkeysEvent) => {
callGlobalHotkeysCallback({
keyboardEvent,
hotkeysEvent,
callback: () => {
handleCallback(keyboardEvent, hotkeysEvent);
},
scope,
preventDefault,
containsModifier,
});
},
{
enableOnContentEditable,
enableOnFormTags,
ignoreModifiers,
},
dependencies,
);
};

View File

@ -0,0 +1,119 @@
import { currentGlobalHotkeysConfigSelector } from '@/ui/utilities/focus/states/currentGlobalHotkeysConfigSelector';
import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState';
import {
Hotkey,
OptionsOrDependencyArray,
} from 'react-hotkeys-hook/dist/types';
import { useRecoilCallback } from 'recoil';
import { logDebug } from '~/utils/logDebug';
import { DEBUG_HOTKEY_SCOPE } from '../constants/DebugHotkeyScope';
export const useGlobalHotkeysCallback = (
dependencies?: OptionsOrDependencyArray,
) => {
const dependencyArray = Array.isArray(dependencies) ? dependencies : [];
return useRecoilCallback(
({ snapshot }) =>
({
callback,
containsModifier,
hotkeysEvent,
keyboardEvent,
preventDefault,
scope,
}: {
keyboardEvent: KeyboardEvent;
hotkeysEvent: Hotkey;
containsModifier: boolean;
callback: (keyboardEvent: KeyboardEvent, hotkeysEvent: Hotkey) => void;
preventDefault?: boolean;
scope: string;
}) => {
// TODO: Remove this once we've migrated hotkey scopes to the new api
const currentHotkeyScopes = snapshot
.getLoadable(internalHotkeysEnabledScopesState)
.getValue();
const currentGlobalHotkeysConfig = snapshot
.getLoadable(currentGlobalHotkeysConfigSelector)
.getValue();
if (
containsModifier &&
!currentGlobalHotkeysConfig.enableGlobalHotkeysWithModifiers
) {
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI can't call hotkey (${
hotkeysEvent.keys
}) because global hotkeys with modifiers are disabled`,
'color: gray; ',
);
}
return;
}
if (
!containsModifier &&
!currentGlobalHotkeysConfig.enableGlobalHotkeysConflictingWithKeyboard
) {
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI can't call hotkey (${
hotkeysEvent.keys
}) because global hotkeys conflicting with keyboard are disabled`,
'color: gray; ',
);
}
return;
}
// TODO: Remove this once we've migrated hotkey scopes to the new api
if (!currentHotkeyScopes.includes(scope)) {
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI can't call hotkey (${
hotkeysEvent.keys
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
', ',
)}]`,
'color: gray; ',
);
}
return;
}
// TODO: Remove this once we've migrated hotkey scopes to the new api
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI can call hotkey (${
hotkeysEvent.keys
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
', ',
)}]`,
'color: green;',
);
}
if (preventDefault === true) {
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI prevent default for hotkey (${hotkeysEvent.keys})`,
'color: gray;',
);
}
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
keyboardEvent.stopImmediatePropagation();
}
return callback(keyboardEvent, hotkeysEvent);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
dependencyArray,
);
};

View File

@ -4,10 +4,10 @@ import { useRecoilState } from 'recoil';
import { pendingHotkeyState } from '../states/internal/pendingHotkeysState';
import { useScopedHotkeyCallback } from './useScopedHotkeyCallback';
import { useGlobalHotkeysCallback } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeysCallback';
import { isDefined } from 'twenty-shared/utils';
export const useSequenceHotkeys = (
export const useGlobalHotkeysSequence = (
firstKey: Keys,
secondKey: Keys,
sequenceCallback: () => void,
@ -21,14 +21,15 @@ export const useSequenceHotkeys = (
) => {
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
const callScopedHotkeyCallback = useScopedHotkeyCallback();
const callGlobalHotkeysCallback = useGlobalHotkeysCallback();
useHotkeys(
firstKey,
(keyboardEvent, hotkeysEvent) => {
callScopedHotkeyCallback({
callGlobalHotkeysCallback({
keyboardEvent,
hotkeysEvent,
containsModifier: false,
callback: () => {
setPendingHotkey(firstKey);
},
@ -46,9 +47,10 @@ export const useSequenceHotkeys = (
useHotkeys(
secondKey,
(keyboardEvent, hotkeysEvent) => {
callScopedHotkeyCallback({
callGlobalHotkeysCallback({
keyboardEvent,
hotkeysEvent,
containsModifier: false,
callback: () => {
if (pendingHotkey !== firstKey) {
return;

View File

@ -1,10 +1,9 @@
import { Keys } from 'react-hotkeys-hook/dist/types';
import { useNavigate } from 'react-router-dom';
import { useGlobalHotkeysSequence } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeysSequence';
import { AppHotkeyScope } from '../types/AppHotkeyScope';
import { useSequenceHotkeys } from './useSequenceScopedHotkeys';
type GoToHotkeysProps = {
key: Keys;
location: string;
@ -18,7 +17,7 @@ export const useGoToHotkeys = ({
}: GoToHotkeysProps) => {
const navigate = useNavigate();
useSequenceHotkeys(
useGlobalHotkeysSequence(
'g',
key,
() => {

View File

@ -0,0 +1,73 @@
import { useHotkeysOnFocusedElementCallback } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElementCallback';
import { pendingHotkeyState } from '@/ui/utilities/hotkey/states/internal/pendingHotkeysState';
import { useHotkeys } from 'react-hotkeys-hook';
import { HotkeyCallback, Keys, Options } from 'react-hotkeys-hook/dist/types';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
type UseHotkeysOptionsWithoutBuggyOptions = Omit<Options, 'enabled'>;
export const useHotkeysOnFocusedElement = ({
keys,
callback,
focusId,
// TODO: Remove this once we've migrated hotkey scopes to the new api
scope,
dependencies,
options,
}: {
keys: Keys;
callback: HotkeyCallback;
focusId: string;
// TODO: Remove this once we've migrated hotkey scopes to the new api
scope: string;
dependencies?: unknown[];
options?: UseHotkeysOptionsWithoutBuggyOptions;
}) => {
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
const callScopedHotkeyCallback =
useHotkeysOnFocusedElementCallback(dependencies);
const enableOnContentEditable = isDefined(options?.enableOnContentEditable)
? options.enableOnContentEditable
: true;
const enableOnFormTags = isDefined(options?.enableOnFormTags)
? options.enableOnFormTags
: true;
const preventDefault = isDefined(options?.preventDefault)
? options.preventDefault === true
: true;
const ignoreModifiers = isDefined(options?.ignoreModifiers)
? options.ignoreModifiers === true
: false;
return useHotkeys(
keys,
(keyboardEvent, hotkeysEvent) => {
callScopedHotkeyCallback({
keyboardEvent,
hotkeysEvent,
callback: () => {
if (!pendingHotkey) {
callback(keyboardEvent, hotkeysEvent);
return;
}
setPendingHotkey(null);
},
focusId,
scope,
preventDefault,
});
},
{
enableOnContentEditable,
enableOnFormTags,
ignoreModifiers,
},
dependencies,
);
};

View File

@ -0,0 +1,89 @@
import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState';
import {
Hotkey,
OptionsOrDependencyArray,
} from 'react-hotkeys-hook/dist/types';
import { useRecoilCallback } from 'recoil';
import { logDebug } from '~/utils/logDebug';
import { currentFocusIdSelector } from '../../focus/states/currentFocusIdSelector';
import { DEBUG_HOTKEY_SCOPE } from '../constants/DebugHotkeyScope';
export const useHotkeysOnFocusedElementCallback = (
dependencies?: OptionsOrDependencyArray,
) => {
const dependencyArray = Array.isArray(dependencies) ? dependencies : [];
return useRecoilCallback(
({ snapshot }) =>
({
callback,
hotkeysEvent,
keyboardEvent,
focusId,
scope,
preventDefault,
}: {
keyboardEvent: KeyboardEvent;
hotkeysEvent: Hotkey;
callback: (keyboardEvent: KeyboardEvent, hotkeysEvent: Hotkey) => void;
focusId: string;
scope: string;
preventDefault?: boolean;
}) => {
const currentFocusId = snapshot
.getLoadable(currentFocusIdSelector)
.getValue();
// TODO: Remove this once we've migrated hotkey scopes to the new api
const currentHotkeyScopes = snapshot
.getLoadable(internalHotkeysEnabledScopesState)
.getValue();
if (
currentFocusId !== focusId ||
!currentHotkeyScopes.includes(scope)
) {
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI can't call hotkey (${
hotkeysEvent.keys
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
', ',
)}] and the current focus identifier is [${focusId}]`,
'color: gray; ',
);
}
return;
}
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI can call hotkey (${
hotkeysEvent.keys
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
', ',
)}], and the current focus identifier is [${focusId}]`,
'color: green;',
);
}
if (preventDefault === true) {
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI prevent default for hotkey (${hotkeysEvent.keys})`,
'color: gray;',
);
}
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
keyboardEvent.stopImmediatePropagation();
}
return callback(keyboardEvent, hotkeysEvent);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
dependencyArray,
);
};

View File

@ -1,7 +1,7 @@
import { useRecoilCallback } from 'recoil';
import { DEBUG_HOTKEY_SCOPE } from '@/ui/utilities/hotkey/hooks/useScopedHotkeyCallback';
import { logDebug } from '~/utils/logDebug';
import { DEBUG_HOTKEY_SCOPE } from '../constants/DebugHotkeyScope';
import { currentHotkeyScopeState } from '../states/internal/currentHotkeyScopeState';
import { previousHotkeyScopeFamilyState } from '../states/internal/previousHotkeyScopeFamilyState';
@ -9,14 +9,14 @@ import { CustomHotkeyScopes } from '../types/CustomHotkeyScope';
import { useSetHotkeyScope } from './useSetHotkeyScope';
export const usePreviousHotkeyScope = (memoizeKey = 'global') => {
export const usePreviousHotkeyScope = () => {
const setHotkeyScope = useSetHotkeyScope();
const goBackToPreviousHotkeyScope = useRecoilCallback(
({ snapshot, set }) =>
() => {
(memoizeKey = 'global') => {
const previousHotkeyScope = snapshot
.getLoadable(previousHotkeyScopeFamilyState(memoizeKey))
.getLoadable(previousHotkeyScopeFamilyState(memoizeKey as string))
.getValue();
if (!previousHotkeyScope) {
@ -39,14 +39,22 @@ export const usePreviousHotkeyScope = (memoizeKey = 'global') => {
previousHotkeyScope.customScopes,
);
set(previousHotkeyScopeFamilyState(memoizeKey), null);
set(previousHotkeyScopeFamilyState(memoizeKey as string), null);
},
[setHotkeyScope, memoizeKey],
[setHotkeyScope],
);
const setHotkeyScopeAndMemorizePreviousScope = useRecoilCallback(
({ snapshot, set }) =>
(scope: string, customScopes?: CustomHotkeyScopes) => {
({
scope,
customScopes,
memoizeKey = 'global',
}: {
scope: string;
customScopes?: CustomHotkeyScopes;
memoizeKey?: string;
}) => {
const currentHotkeyScope = snapshot
.getLoadable(currentHotkeyScopeState)
.getValue();
@ -63,7 +71,7 @@ export const usePreviousHotkeyScope = (memoizeKey = 'global') => {
set(previousHotkeyScopeFamilyState(memoizeKey), currentHotkeyScope);
},
[setHotkeyScope, memoizeKey],
[setHotkeyScope],
);
return {

View File

@ -6,10 +6,9 @@ import { useRecoilCallback } from 'recoil';
import { logDebug } from '~/utils/logDebug';
import { DEBUG_HOTKEY_SCOPE } from '../constants/DebugHotkeyScope';
import { internalHotkeysEnabledScopesState } from '../states/internal/internalHotkeysEnabledScopesState';
export const DEBUG_HOTKEY_SCOPE = false;
export const useScopedHotkeyCallback = (
dependencies?: OptionsOrDependencyArray,
) => {

View File

@ -2,9 +2,9 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { HotkeyCallback, Keys, Options } from 'react-hotkeys-hook/dist/types';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { pendingHotkeyState } from '../states/internal/pendingHotkeysState';
import { useScopedHotkeyCallback } from './useScopedHotkeyCallback';
import { isDefined } from 'twenty-shared/utils';
type UseHotkeysOptionsWithoutBuggyOptions = Omit<Options, 'enabled'>;

View File

@ -1,6 +1,6 @@
import { useRecoilCallback } from 'recoil';
import { DEBUG_HOTKEY_SCOPE } from '@/ui/utilities/hotkey/hooks/useScopedHotkeyCallback';
import { DEBUG_HOTKEY_SCOPE } from '../constants/DebugHotkeyScope';
import { isDefined } from 'twenty-shared/utils';
import { logDebug } from '~/utils/logDebug';
@ -11,7 +11,7 @@ import { AppHotkeyScope } from '../types/AppHotkeyScope';
import { CustomHotkeyScopes } from '../types/CustomHotkeyScope';
import { HotkeyScope } from '../types/HotkeyScope';
const isCustomScopesEqual = (
const areCustomScopesEqual = (
customScopesA: CustomHotkeyScopes | undefined,
customScopesB: CustomHotkeyScopes | undefined,
) => {
@ -34,7 +34,7 @@ export const useSetHotkeyScope = () =>
if (currentHotkeyScope.scope === hotkeyScopeToSet) {
if (!isDefined(customScopes)) {
if (
isCustomScopesEqual(
areCustomScopesEqual(
currentHotkeyScope?.customScopes,
DEFAULT_HOTKEYS_SCOPE_CUSTOM_SCOPES,
)
@ -43,7 +43,7 @@ export const useSetHotkeyScope = () =>
}
} else {
if (
isCustomScopesEqual(
areCustomScopesEqual(
currentHotkeyScope?.customScopes,
customScopes,
)

View File

@ -0,0 +1,4 @@
export type GlobalHotkeysConfig = {
enableGlobalHotkeysWithModifiers: boolean;
enableGlobalHotkeysConflictingWithKeyboard: boolean;
};