Feat/better hotkeys scope (#526)

* Working version

* fix

* Fixed console log

* Fix lint

* wip

* Fix

* Fix

* consolelog

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2023-07-08 03:53:05 +02:00
committed by GitHub
parent 611cda1f41
commit 66dcc9b2e1
77 changed files with 1240 additions and 454 deletions

View File

@ -0,0 +1,103 @@
import { useHotkeysContext } from 'react-hotkeys-hook';
import { useRecoilCallback } from 'recoil';
import { internalHotkeysEnabledScopesState } from '@/hotkeys/states/internal/internalHotkeysEnabledScopesState';
export function useHotkeysScope() {
const { disableScope, enableScope } = useHotkeysContext();
const disableAllHotkeysScopes = useRecoilCallback(
({ set, snapshot }) => {
return async () => {
const enabledScopes = await snapshot.getPromise(
internalHotkeysEnabledScopesState,
);
for (const enabledScope of enabledScopes) {
disableScope(enabledScope);
}
set(internalHotkeysEnabledScopesState, []);
};
},
[disableScope],
);
const enableHotkeysScope = useRecoilCallback(
({ set, snapshot }) => {
return async (scopeToEnable: string) => {
const enabledScopes = await snapshot.getPromise(
internalHotkeysEnabledScopesState,
);
if (!enabledScopes.includes(scopeToEnable)) {
enableScope(scopeToEnable);
set(internalHotkeysEnabledScopesState, [
...enabledScopes,
scopeToEnable,
]);
}
};
},
[enableScope],
);
const disableHotkeysScope = useRecoilCallback(
({ set, snapshot }) => {
return async (scopeToDisable: string) => {
const enabledScopes = await snapshot.getPromise(
internalHotkeysEnabledScopesState,
);
const scopeToRemoveIndex = enabledScopes.findIndex(
(scope) => scope === scopeToDisable,
);
if (scopeToRemoveIndex > -1) {
disableScope(scopeToDisable);
enabledScopes.splice(scopeToRemoveIndex);
set(internalHotkeysEnabledScopesState, enabledScopes);
}
};
},
[disableScope],
);
const setHotkeysScopes = useRecoilCallback(
({ set, snapshot }) => {
return async (scopesToSet: string[]) => {
const enabledScopes = await snapshot.getPromise(
internalHotkeysEnabledScopesState,
);
const scopesToDisable = enabledScopes.filter(
(enabledScope) => !scopesToSet.includes(enabledScope),
);
const scopesToEnable = scopesToSet.filter(
(scopeToSet) => !enabledScopes.includes(scopeToSet),
);
for (const scopeToDisable of scopesToDisable) {
disableScope(scopeToDisable);
}
for (const scopeToEnable of scopesToEnable) {
enableScope(scopeToEnable);
}
set(internalHotkeysEnabledScopesState, scopesToSet);
};
},
[disableScope, enableScope],
);
return {
disableAllHotkeysScopes,
enableHotkeysScope,
disableHotkeysScope,
setHotkeysScopes,
};
}

View File

@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { customHotkeysScopesState } from '@/hotkeys/states/internal/customHotkeysScopesState';
import { hotkeysScopeStackState } from '@/hotkeys/states/internal/hotkeysScopeStackState';
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
import { useHotkeysScope } from './useHotkeysScope';
export function useHotkeysScopeStackAutoSync() {
const { setHotkeysScopes } = useHotkeysScope();
const hotkeysScopeStack = useRecoilValue(hotkeysScopeStackState);
const customHotkeysScopes = useRecoilValue(customHotkeysScopesState);
useEffect(() => {
if (hotkeysScopeStack.length === 0) {
return;
}
const scopesToSet: string[] = [];
const currentHotkeysScope = hotkeysScopeStack[hotkeysScopeStack.length - 1];
if (currentHotkeysScope.customScopes?.['command-menu']) {
scopesToSet.push(InternalHotkeysScope.CommandMenu);
}
if (currentHotkeysScope?.customScopes?.goto) {
scopesToSet.push(InternalHotkeysScope.Goto);
}
scopesToSet.push(currentHotkeysScope.scope);
setHotkeysScopes(scopesToSet);
}, [setHotkeysScopes, customHotkeysScopes, hotkeysScopeStack]);
}

View File

@ -0,0 +1,48 @@
import { produce } from 'immer';
import { useRecoilCallback } from 'recoil';
import { hotkeysScopeStackState } from '../states/internal/hotkeysScopeStackState';
import { HotkeysScopeStackItem } from '../types/internal/HotkeysScopeStackItems';
export function useAddToHotkeysScopeStack() {
return useRecoilCallback(
({ snapshot, set }) =>
async ({
scope,
customScopes = {
'command-menu': true,
goto: false,
},
ancestorScope,
}: HotkeysScopeStackItem) => {
const hotkeysScopeStack = await snapshot.getPromise(
hotkeysScopeStackState,
);
const currentHotkeysScope =
hotkeysScopeStack.length > 0
? hotkeysScopeStack[hotkeysScopeStack.length - 1]
: null;
const previousHotkeysScope =
hotkeysScopeStack.length > 1
? hotkeysScopeStack[hotkeysScopeStack.length - 2]
: null;
if (
scope === currentHotkeysScope?.scope ||
scope === previousHotkeysScope?.scope
) {
return;
}
set(
hotkeysScopeStackState,
produce(hotkeysScopeStack, (draft) => {
draft.push({ scope, customScopes, ancestorScope });
}),
);
},
[],
);
}

View File

@ -0,0 +1,16 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { hotkeysScopeStackState } from '../states/internal/hotkeysScopeStackState';
export function useCurrentHotkeysScope() {
const hotkeysScopeStack = useRecoilValue(hotkeysScopeStackState);
return useMemo(() => {
if (hotkeysScopeStack.length === 0) {
return null;
} else {
return hotkeysScopeStack[hotkeysScopeStack.length - 1];
}
}, [hotkeysScopeStack]);
}

View File

@ -1,11 +1,19 @@
import { Keys } from 'react-hotkeys-hook/dist/types';
import { useNavigate } from 'react-router-dom';
import { useSequenceHotkeys } from './useSequenceHotkeys';
import { InternalHotkeysScope } from '../types/internal/InternalHotkeysScope';
export function useGoToHotkeys(key: string, location: string) {
import { useSequenceHotkeys } from './useSequenceScopedHotkeys';
export function useGoToHotkeys(key: Keys, location: string) {
const navigate = useNavigate();
useSequenceHotkeys('g', key, () => {
navigate(location);
});
useSequenceHotkeys(
'g',
key,
() => {
navigate(location);
},
InternalHotkeysScope.Goto,
);
}

View File

@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { HotkeysScopeStackItem } from '../types/internal/HotkeysScopeStackItems';
import { useAddToHotkeysScopeStack } from './useAddToHotkeysScopeStack';
import { useRemoveFromHotkeysScopeStack } from './useRemoveFromHotkeysScopeStack';
export function useHotkeysScopeOnBooleanState(
hotkeysScopeStackItem: HotkeysScopeStackItem,
booleanState: boolean,
) {
const addToHotkeysScopeStack = useAddToHotkeysScopeStack();
const removeFromHoteysScopeStack = useRemoveFromHotkeysScopeStack();
useEffect(() => {
if (booleanState) {
addToHotkeysScopeStack(hotkeysScopeStackItem);
} else {
removeFromHoteysScopeStack(hotkeysScopeStackItem.scope);
}
}, [
hotkeysScopeStackItem,
removeFromHoteysScopeStack,
addToHotkeysScopeStack,
booleanState,
]);
}

View File

@ -0,0 +1,32 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import { hotkeysScopeStackState } from '../states/internal/hotkeysScopeStackState';
import { HotkeysScopeStackItem } from '../types/internal/HotkeysScopeStackItems';
import { useAddToHotkeysScopeStack } from './useAddToHotkeysScopeStack';
export function useHotkeysScopeOnMountOnly(
hotkeysScopeStackItem: HotkeysScopeStackItem,
enabled = true,
) {
const addToHotkeysScopeStack = useAddToHotkeysScopeStack();
const [hotkeysScopeStack] = useRecoilState(hotkeysScopeStackState);
const hotkeysScopeAlreadyInStack = hotkeysScopeStack.some(
(hotkeysScopeStackItemToFind) =>
hotkeysScopeStackItemToFind.scope === hotkeysScopeStackItem.scope,
);
useEffect(() => {
if (!hotkeysScopeAlreadyInStack && enabled) {
addToHotkeysScopeStack(hotkeysScopeStackItem);
}
}, [
enabled,
addToHotkeysScopeStack,
hotkeysScopeStackItem,
hotkeysScopeAlreadyInStack,
]);
}

View File

@ -0,0 +1,54 @@
import { produce } from 'immer';
import { useRecoilCallback } from 'recoil';
import { DEFAULT_HOTKEYS_SCOPE_STACK_ITEM } from '../constants';
import { hotkeysScopeStackState } from '../states/internal/hotkeysScopeStackState';
import { InternalHotkeysScope } from '../types/internal/InternalHotkeysScope';
export function useRemoveFromHotkeysScopeStack() {
return useRecoilCallback(
({ snapshot, set }) =>
async (hotkeysScopeToRemove: string) => {
const hotkeysScopeStack = await snapshot.getPromise(
hotkeysScopeStackState,
);
if (hotkeysScopeStack.length < 1) {
set(hotkeysScopeStackState, [DEFAULT_HOTKEYS_SCOPE_STACK_ITEM]);
return;
}
const currentHotkeysScope =
hotkeysScopeStack[hotkeysScopeStack.length - 1];
if (hotkeysScopeStack.length === 1) {
if (currentHotkeysScope?.scope !== InternalHotkeysScope.App) {
set(hotkeysScopeStackState, [DEFAULT_HOTKEYS_SCOPE_STACK_ITEM]);
}
return;
}
const previousHotkeysScope =
hotkeysScopeStack[hotkeysScopeStack.length - 2];
if (
previousHotkeysScope.scope === hotkeysScopeToRemove ||
currentHotkeysScope.scope !== hotkeysScopeToRemove
) {
return;
}
set(
hotkeysScopeStackState,
produce(hotkeysScopeStack, (draft) => {
return draft.filter(
(hotkeysScope) => hotkeysScope.scope !== hotkeysScopeToRemove,
);
}),
);
},
[],
);
}

View File

@ -0,0 +1,42 @@
import { produce } from 'immer';
import { useRecoilCallback } from 'recoil';
import { DEFAULT_HOTKEYS_SCOPE_STACK_ITEM } from '../constants';
import { hotkeysScopeStackState } from '../states/internal/hotkeysScopeStackState';
import { InternalHotkeysScope } from '../types/internal/InternalHotkeysScope';
export function useRemoveHighestHotkeysScopeStackItem() {
return useRecoilCallback(
({ snapshot, set }) =>
async () => {
const hotkeysScopeStack = await snapshot.getPromise(
hotkeysScopeStackState,
);
if (hotkeysScopeStack.length < 1) {
set(hotkeysScopeStackState, [DEFAULT_HOTKEYS_SCOPE_STACK_ITEM]);
return;
}
const currentHotkeysScope =
hotkeysScopeStack[hotkeysScopeStack.length - 1];
if (hotkeysScopeStack.length === 1) {
if (currentHotkeysScope?.scope !== InternalHotkeysScope.App) {
set(hotkeysScopeStackState, [DEFAULT_HOTKEYS_SCOPE_STACK_ITEM]);
}
return;
}
set(
hotkeysScopeStackState,
produce(hotkeysScopeStack, (draft) => {
draft.pop();
}),
);
},
[],
);
}

View File

@ -0,0 +1,18 @@
import { useResetRecoilState } from 'recoil';
import { hotkeysScopeStackState } from '../states/internal/hotkeysScopeStackState';
import { useAddToHotkeysScopeStack } from './useAddToHotkeysScopeStack';
export function useResetHotkeysScopeStack() {
const resetHotkeysScopeStack = useResetRecoilState(hotkeysScopeStackState);
const addHotkeysScopedStack = useAddToHotkeysScopeStack();
return function reset(toFirstScope?: string) {
resetHotkeysScopeStack();
if (toFirstScope) {
addHotkeysScopedStack({ scope: toFirstScope });
}
};
}

View File

@ -2,16 +2,24 @@ import { useHotkeys } from 'react-hotkeys-hook';
import {
Hotkey,
HotkeyCallback,
Keys,
Options,
OptionsOrDependencyArray,
} from 'react-hotkeys-hook/dist/types';
import { useRecoilState } from 'recoil';
import { pendingHotkeyState } from '../states/pendingHotkeysState';
import { pendingHotkeyState } from '../states/internal/pendingHotkeysState';
export function useDirectHotkeys(
keys: string,
export function useScopedHotkeys(
keys: Keys,
callback: HotkeyCallback,
scope: string,
dependencies?: OptionsOrDependencyArray,
options: Options = {
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
) {
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
@ -26,5 +34,10 @@ export function useDirectHotkeys(
setPendingHotkey(null);
}
useHotkeys(keys, callbackIfDirectKey, dependencies);
return useHotkeys(
keys,
callbackIfDirectKey,
{ ...options, scopes: [scope] },
dependencies,
);
}

View File

@ -1,12 +1,19 @@
import { useHotkeys } from 'react-hotkeys-hook';
import { Options, useHotkeys } from 'react-hotkeys-hook';
import { Keys } from 'react-hotkeys-hook/dist/types';
import { useRecoilState } from 'recoil';
import { pendingHotkeyState } from '../states/pendingHotkeysState';
import { pendingHotkeyState } from '../states/internal/pendingHotkeysState';
export function useSequenceHotkeys(
firstKey: string,
secondKey: string,
firstKey: Keys,
secondKey: Keys,
callback: () => void,
scope: string,
options: Options = {
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
) {
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
@ -15,6 +22,7 @@ export function useSequenceHotkeys(
() => {
setPendingHotkey(firstKey);
},
{ ...options, scopes: [scope] },
[pendingHotkey],
);
@ -27,6 +35,7 @@ export function useSequenceHotkeys(
setPendingHotkey(null);
callback();
},
{ ...options, scopes: [scope] },
[pendingHotkey, setPendingHotkey],
);
}

View File

@ -1,31 +0,0 @@
import { useHotkeys } from 'react-hotkeys-hook';
import { HotkeyCallback } from 'react-hotkeys-hook/dist/types';
import { OptionsOrDependencyArray } from 'react-hotkeys-hook/dist/types';
export function useUpDownHotkeys(
upArrowCallBack: HotkeyCallback,
downArrownCallback: HotkeyCallback,
dependencies?: OptionsOrDependencyArray,
) {
useHotkeys(
'up',
upArrowCallBack,
{
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
dependencies,
);
useHotkeys(
'down',
downArrownCallback,
{
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
dependencies,
);
}