Skip to content

Commit 942cfec

Browse files
rpfaefflejacobsfletchAlessioGrjessrynkar
authored
feat: add support for hotkeys (#1821)
Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com> Co-authored-by: Alessio Gravili <70709113+AlessioGr@users.noreply.github.com> Co-authored-by: Alessio Gravili <alessio@gravili.de> Co-authored-by: Jessica Boezwinkle <jessica@trbl.design>
1 parent 35dfaab commit 942cfec

File tree

11 files changed

+280
-19
lines changed

11 files changed

+280
-19
lines changed

src/admin/components/elements/Button/index.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { Fragment, isValidElement } from 'react';
1+
import React, { forwardRef, Fragment, isValidElement } from 'react';
22
import { Link } from 'react-router-dom';
33
import { Props } from './types';
44

@@ -53,7 +53,7 @@ const ButtonContents = ({ children, icon, tooltip, showTooltip }) => {
5353
);
5454
};
5555

56-
const Button: React.FC<Props> = (props) => {
56+
const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, Props>((props, ref) => {
5757
const {
5858
className,
5959
id,
@@ -129,6 +129,7 @@ const Button: React.FC<Props> = (props) => {
129129
return (
130130
<a
131131
{...buttonProps}
132+
ref={ref as React.LegacyRef<HTMLAnchorElement>}
132133
href={url}
133134
>
134135
<ButtonContents
@@ -147,6 +148,7 @@ const Button: React.FC<Props> = (props) => {
147148
return (
148149
<Tag
149150
type="submit"
151+
ref={ref}
150152
{...buttonProps}
151153
>
152154
<ButtonContents
@@ -159,6 +161,6 @@ const Button: React.FC<Props> = (props) => {
159161
</Tag>
160162
);
161163
}
162-
};
164+
});
163165

164166
export default Button;

src/admin/components/elements/ReactSelect/Control/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export const Control: React.FC<ControlProps<Option, any>> = (props) => {
3232
onKeyDown: (e) => {
3333
if (disableKeyDown) {
3434
e.stopPropagation();
35+
// Create event for keydown listeners which specifically want to bypass this stopPropagation
36+
const bypassEvent = new CustomEvent('bypassKeyDown', { detail: e });
37+
document.dispatchEvent(bypassEvent);
3538
}
3639
},
3740
}}

src/admin/components/elements/Save/index.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import React from 'react';
1+
import React, { useRef } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import FormSubmit from '../../forms/Submit';
4+
import useHotkey from '../../../hooks/useHotkey';
45
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
6+
import { useEditDepth } from '../../utilities/EditDepth';
57
import { useForm } from '../../forms/Form/context';
68

79
export type CustomSaveButtonProps = React.ComponentType<DefaultSaveButtonProps & {
@@ -11,12 +13,25 @@ type DefaultSaveButtonProps = {
1113
label: string;
1214
save: () => void;
1315
};
16+
1417
const DefaultSaveButton: React.FC<DefaultSaveButtonProps> = ({ label, save }) => {
18+
const ref = useRef<HTMLButtonElement>(null);
19+
const editDepth = useEditDepth();
20+
21+
useHotkey({ keyCodes: ['s'], cmdCtrlKey: true, editDepth }, (e) => {
22+
e.preventDefault();
23+
e.stopPropagation();
24+
if (ref?.current) {
25+
ref.current.click();
26+
}
27+
});
28+
1529
return (
1630
<FormSubmit
1731
type="button"
1832
buttonId="action-save"
1933
onClick={save}
34+
ref={ref}
2035
>
2136
{label}
2237
</FormSubmit>

src/admin/components/elements/SaveDraft/index.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import React, { useCallback } from 'react';
1+
import React, { useCallback, useRef } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import { useConfig } from '../../utilities/Config';
44
import FormSubmit from '../../forms/Submit';
55
import { useForm, useFormModified } from '../../forms/Form/context';
66
import { useDocumentInfo } from '../../utilities/DocumentInfo';
77
import { useLocale } from '../../utilities/Locale';
8+
import useHotkey from '../../../hooks/useHotkey';
89
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
10+
import { useEditDepth } from '../../utilities/EditDepth';
911

1012

1113
const baseClass = 'save-draft';
@@ -19,13 +21,29 @@ export type DefaultSaveDraftButtonProps = {
1921
label: string;
2022
};
2123
const DefaultSaveDraftButton: React.FC<DefaultSaveDraftButtonProps> = ({ disabled, saveDraft, label }) => {
24+
const ref = useRef<HTMLButtonElement>(null);
25+
const editDepth = useEditDepth();
26+
27+
useHotkey({ keyCodes: ['s'], cmdCtrlKey: true, editDepth }, (e) => {
28+
if (disabled) {
29+
return;
30+
}
31+
32+
e.preventDefault();
33+
e.stopPropagation();
34+
if (ref?.current) {
35+
ref.current.click();
36+
}
37+
});
38+
2239
return (
2340
<FormSubmit
2441
className={baseClass}
2542
type="button"
2643
buttonStyle="secondary"
2744
onClick={saveDraft}
2845
disabled={disabled}
46+
ref={ref}
2947
>
3048
{label}
3149
</FormSubmit>
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react';
1+
import React, { forwardRef } from 'react';
22
import { useForm, useFormProcessing } from '../Form/context';
33
import Button from '../../elements/Button';
44
import { Props } from '../../elements/Button/types';
@@ -7,23 +7,25 @@ import './index.scss';
77

88
const baseClass = 'form-submit';
99

10-
const FormSubmit: React.FC<Props> = (props) => {
10+
const FormSubmit = forwardRef<HTMLButtonElement, Props>((props, ref) => {
1111
const { children, buttonId: id, disabled: disabledFromProps, type = 'submit' } = props;
1212
const processing = useFormProcessing();
1313
const { disabled } = useForm();
14+
const canSave = !(disabledFromProps || processing || disabled);
1415

1516
return (
1617
<div className={baseClass}>
1718
<Button
19+
ref={ref}
1820
{...props}
1921
id={id}
2022
type={type}
21-
disabled={disabledFromProps || processing || disabled ? true : undefined}
23+
disabled={canSave ? undefined : true}
2224
>
2325
{children}
2426
</Button>
2527
</div>
2628
);
27-
};
29+
});
2830

2931
export default FormSubmit;

src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/index.tsx

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react';
1+
import React, { useRef } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import { Drawer } from '../../../../../../elements/Drawer';
44
import Form from '../../../../../Form';
@@ -8,6 +8,8 @@ import fieldTypes from '../../../..';
88
import RenderFields from '../../../../../RenderFields';
99

1010
import './index.scss';
11+
import useHotkey from '../../../../../../../hooks/useHotkey';
12+
import { useEditDepth } from '../../../../../../utilities/EditDepth';
1113

1214
const baseClass = 'rich-text-link-edit-modal';
1315

@@ -35,10 +37,32 @@ export const LinkDrawer: React.FC<Props> = ({
3537
fieldSchema={fieldSchema}
3638
forceRender
3739
/>
38-
<FormSubmit>
39-
{t('general:submit')}
40-
</FormSubmit>
40+
<LinkSubmit />
4141
</Form>
4242
</Drawer>
4343
);
4444
};
45+
46+
47+
const LinkSubmit: React.FC = () => {
48+
const { t } = useTranslation('fields');
49+
const ref = useRef<HTMLButtonElement>(null);
50+
const editDepth = useEditDepth();
51+
52+
useHotkey({ keyCodes: ['s'], cmdCtrlKey: true, editDepth }, (e) => {
53+
e.preventDefault();
54+
e.stopPropagation();
55+
if (ref?.current) {
56+
ref.current.click();
57+
}
58+
});
59+
60+
61+
return (
62+
<FormSubmit
63+
ref={ref}
64+
>
65+
{t('general:submit')}
66+
</FormSubmit>
67+
);
68+
};

src/admin/hooks/useHotkey.tsx

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/* eslint-disable no-shadow */
2+
import { useCallback, useEffect } from 'react';
3+
import { useModal } from '@faceless-ui/modal';
4+
import { setsAreEqual } from '../utilities/setsAreEqual';
5+
6+
// Required to be outside of hook, else debounce would be necessary
7+
// and then one could not prevent the default behaviour.
8+
const pressedKeys = new Set<string>([]);
9+
10+
const map = {
11+
metaleft: 'meta',
12+
metaright: 'meta',
13+
osleft: 'meta',
14+
osright: 'meta',
15+
shiftleft: 'shift',
16+
shiftright: 'shift',
17+
ctrlleft: 'ctrl',
18+
ctrlright: 'ctrl',
19+
controlleft: 'ctrl',
20+
controlright: 'ctrl',
21+
altleft: 'alt',
22+
altright: 'alt',
23+
escape: 'esc',
24+
};
25+
26+
const stripKey = (key: string) => {
27+
return (map[key.toLowerCase()] || key).trim()
28+
.toLowerCase()
29+
.replace('key', '');
30+
};
31+
32+
const pushToKeys = (code: string) => {
33+
const key = stripKey(code);
34+
35+
// There is a weird bug with macos that if the keys are not cleared they remain in the
36+
// pressed keys set.
37+
if (key === 'meta') {
38+
pressedKeys.forEach((pressedKey) => pressedKey !== 'meta' && pressedKeys.delete(pressedKey));
39+
}
40+
41+
pressedKeys.add(key);
42+
};
43+
44+
const removeFromKeys = (code: string) => {
45+
const key = stripKey(code);
46+
// There is a weird bug with macos that if the keys are not cleared they remain in the
47+
// pressed keys set.
48+
if (key === 'meta') {
49+
pressedKeys.clear();
50+
}
51+
pressedKeys.delete(key);
52+
};
53+
54+
/**
55+
* Hook function to work with hotkeys.
56+
* @param param0.keyCode {string[]} The keys to listen for (`Event.code` without `'Key'` and lowercased)
57+
* @param param0.cmdCtrlKey {boolean} Whether Ctrl on windows or Cmd on mac must be pressed
58+
* @param param0.editDepth {boolean} This ensures that the hotkey is only triggered for the most top-level drawer in case there are nested drawers
59+
* @param func The callback function
60+
*/
61+
const useHotkey = (options: {
62+
keyCodes: string[]
63+
cmdCtrlKey: boolean
64+
editDepth: number
65+
}, func: (e: KeyboardEvent) => void): void => {
66+
const { keyCodes, cmdCtrlKey, editDepth } = options;
67+
68+
const { modalState } = useModal();
69+
70+
71+
const keydown = useCallback((event: KeyboardEvent | CustomEvent) => {
72+
const e: KeyboardEvent = event.detail?.key ? event.detail : event;
73+
if (e.key === undefined) {
74+
// Autofill events, or other synthetic events, can be ignored
75+
return;
76+
}
77+
if (e.code) pushToKeys(e.code);
78+
79+
// Check for Mac and iPad
80+
const hasCmd = window.navigator.userAgent.includes('Mac OS X');
81+
const pressedWithoutModifier = [...pressedKeys].filter((key) => !['meta', 'ctrl', 'alt', 'shift'].includes(key));
82+
83+
// Check whether arrays contain the same values (regardless of number of occurrences)
84+
if (
85+
setsAreEqual(new Set(pressedWithoutModifier), new Set(keyCodes))
86+
&& (!cmdCtrlKey || (hasCmd && pressedKeys.has('meta')) || (!hasCmd && e.ctrlKey))
87+
) {
88+
// get the maximum edit depth by counting the number of open drawers. modalState is and object which contains the state of all drawers.
89+
const maxEditDepth = Object.keys(modalState).filter((key) => modalState[key]?.isOpen)?.length + 1 ?? 1;
90+
91+
if (maxEditDepth !== editDepth) {
92+
// We only want to execute the hotkey from the most top-level drawer / edit depth.
93+
return;
94+
}
95+
// execute the function associated with the maximum edit depth
96+
func(e);
97+
}
98+
}, [keyCodes, cmdCtrlKey, editDepth, modalState, func]);
99+
100+
const keyup = useCallback((e: KeyboardEvent) => {
101+
if (e.code) removeFromKeys(e.code);
102+
}, []);
103+
104+
useEffect(() => {
105+
document.addEventListener('keydown', keydown, false);
106+
document.addEventListener('bypassKeyDown', keydown, false); // this is called if the keydown event's propagation is stopped by react-select
107+
document.addEventListener('keyup', keyup, false);
108+
109+
return () => {
110+
document.removeEventListener('keydown', keydown);
111+
document.removeEventListener('bypassKeyDown', keydown);
112+
document.removeEventListener('keyup', keyup);
113+
};
114+
}, [keydown, keyup]);
115+
};
116+
117+
export default useHotkey;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Function to determine whether two sets are equal or not.
3+
*/
4+
export const setsAreEqual = <T>(lhs: Set<T>, rhs: Set<T>) => {
5+
return lhs.size === rhs.size && Array.from(lhs).every((value) => rhs.has(value));
6+
};

test/admin/e2e.spec.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
33
import payload from '../../src';
44
import { AdminUrlUtil } from '../helpers/adminUrlUtil';
55
import { initPayloadE2E } from '../helpers/configHelpers';
6-
import { saveDocAndAssert } from '../helpers';
6+
import { saveDocAndAssert, saveDocHotkeyAndAssert } from '../helpers';
77
import type { Post } from './config';
88
import { globalSlug, slug } from './shared';
99
import { mapAsync } from '../../src/utilities/mapAsync';
@@ -168,6 +168,18 @@ describe('admin', () => {
168168
await expect(page.locator('#field-description')).toHaveValue(newDesc);
169169
});
170170

171+
test('should save using hotkey', async () => {
172+
const { id } = await createPost();
173+
await page.goto(url.edit(id));
174+
175+
const newTitle = 'new title';
176+
await page.locator('#field-title').fill(newTitle);
177+
178+
await saveDocHotkeyAndAssert(page);
179+
180+
await expect(page.locator('#field-title')).toHaveValue(newTitle);
181+
});
182+
171183
test('should delete existing', async () => {
172184
const { id, ...post } = await createPost();
173185

0 commit comments

Comments
 (0)