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>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { Fragment, isValidElement } from 'react';
|
||||
import React, { forwardRef, Fragment, isValidElement } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Props } from './types';
|
||||
|
||||
@@ -53,7 +53,7 @@ const ButtonContents = ({ children, icon, tooltip, showTooltip }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Button: React.FC<Props> = (props) => {
|
||||
const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, Props>((props, ref) => {
|
||||
const {
|
||||
className,
|
||||
id,
|
||||
@@ -129,6 +129,7 @@ const Button: React.FC<Props> = (props) => {
|
||||
return (
|
||||
<a
|
||||
{...buttonProps}
|
||||
ref={ref as React.LegacyRef<HTMLAnchorElement>}
|
||||
href={url}
|
||||
>
|
||||
<ButtonContents
|
||||
@@ -147,6 +148,7 @@ const Button: React.FC<Props> = (props) => {
|
||||
return (
|
||||
<Tag
|
||||
type="submit"
|
||||
ref={ref}
|
||||
{...buttonProps}
|
||||
>
|
||||
<ButtonContents
|
||||
@@ -159,6 +161,6 @@ const Button: React.FC<Props> = (props) => {
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export default Button;
|
||||
|
||||
@@ -32,6 +32,9 @@ export const Control: React.FC<ControlProps<Option, any>> = (props) => {
|
||||
onKeyDown: (e) => {
|
||||
if (disableKeyDown) {
|
||||
e.stopPropagation();
|
||||
// Create event for keydown listeners which specifically want to bypass this stopPropagation
|
||||
const bypassEvent = new CustomEvent('bypassKeyDown', { detail: e });
|
||||
document.dispatchEvent(bypassEvent);
|
||||
}
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FormSubmit from '../../forms/Submit';
|
||||
import useHotkey from '../../../hooks/useHotkey';
|
||||
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
|
||||
import { useEditDepth } from '../../utilities/EditDepth';
|
||||
import { useForm } from '../../forms/Form/context';
|
||||
|
||||
export type CustomSaveButtonProps = React.ComponentType<DefaultSaveButtonProps & {
|
||||
@@ -11,12 +13,25 @@ type DefaultSaveButtonProps = {
|
||||
label: string;
|
||||
save: () => void;
|
||||
};
|
||||
|
||||
const DefaultSaveButton: React.FC<DefaultSaveButtonProps> = ({ label, save }) => {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const editDepth = useEditDepth();
|
||||
|
||||
useHotkey({ keyCodes: ['s'], cmdCtrlKey: true, editDepth }, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (ref?.current) {
|
||||
ref.current.click();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<FormSubmit
|
||||
type="button"
|
||||
buttonId="action-save"
|
||||
onClick={save}
|
||||
ref={ref}
|
||||
>
|
||||
{label}
|
||||
</FormSubmit>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useConfig } from '../../utilities/Config';
|
||||
import FormSubmit from '../../forms/Submit';
|
||||
import { useForm, useFormModified } from '../../forms/Form/context';
|
||||
import { useDocumentInfo } from '../../utilities/DocumentInfo';
|
||||
import { useLocale } from '../../utilities/Locale';
|
||||
import useHotkey from '../../../hooks/useHotkey';
|
||||
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
|
||||
import { useEditDepth } from '../../utilities/EditDepth';
|
||||
|
||||
|
||||
const baseClass = 'save-draft';
|
||||
@@ -19,6 +21,21 @@ export type DefaultSaveDraftButtonProps = {
|
||||
label: string;
|
||||
};
|
||||
const DefaultSaveDraftButton: React.FC<DefaultSaveDraftButtonProps> = ({ disabled, saveDraft, label }) => {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const editDepth = useEditDepth();
|
||||
|
||||
useHotkey({ keyCodes: ['s'], cmdCtrlKey: true, editDepth }, (e) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (ref?.current) {
|
||||
ref.current.click();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<FormSubmit
|
||||
className={baseClass}
|
||||
@@ -26,6 +43,7 @@ const DefaultSaveDraftButton: React.FC<DefaultSaveDraftButtonProps> = ({ disable
|
||||
buttonStyle="secondary"
|
||||
onClick={saveDraft}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
>
|
||||
{label}
|
||||
</FormSubmit>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { forwardRef } from 'react';
|
||||
import { useForm, useFormProcessing } from '../Form/context';
|
||||
import Button from '../../elements/Button';
|
||||
import { Props } from '../../elements/Button/types';
|
||||
@@ -7,23 +7,25 @@ import './index.scss';
|
||||
|
||||
const baseClass = 'form-submit';
|
||||
|
||||
const FormSubmit: React.FC<Props> = (props) => {
|
||||
const FormSubmit = forwardRef<HTMLButtonElement, Props>((props, ref) => {
|
||||
const { children, buttonId: id, disabled: disabledFromProps, type = 'submit' } = props;
|
||||
const processing = useFormProcessing();
|
||||
const { disabled } = useForm();
|
||||
const canSave = !(disabledFromProps || processing || disabled);
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<Button
|
||||
ref={ref}
|
||||
{...props}
|
||||
id={id}
|
||||
type={type}
|
||||
disabled={disabledFromProps || processing || disabled ? true : undefined}
|
||||
disabled={canSave ? undefined : true}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default FormSubmit;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Drawer } from '../../../../../../elements/Drawer';
|
||||
import Form from '../../../../../Form';
|
||||
@@ -8,6 +8,8 @@ import fieldTypes from '../../../..';
|
||||
import RenderFields from '../../../../../RenderFields';
|
||||
|
||||
import './index.scss';
|
||||
import useHotkey from '../../../../../../../hooks/useHotkey';
|
||||
import { useEditDepth } from '../../../../../../utilities/EditDepth';
|
||||
|
||||
const baseClass = 'rich-text-link-edit-modal';
|
||||
|
||||
@@ -35,10 +37,32 @@ export const LinkDrawer: React.FC<Props> = ({
|
||||
fieldSchema={fieldSchema}
|
||||
forceRender
|
||||
/>
|
||||
<FormSubmit>
|
||||
{t('general:submit')}
|
||||
</FormSubmit>
|
||||
<LinkSubmit />
|
||||
</Form>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const LinkSubmit: React.FC = () => {
|
||||
const { t } = useTranslation('fields');
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const editDepth = useEditDepth();
|
||||
|
||||
useHotkey({ keyCodes: ['s'], cmdCtrlKey: true, editDepth }, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (ref?.current) {
|
||||
ref.current.click();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<FormSubmit
|
||||
ref={ref}
|
||||
>
|
||||
{t('general:submit')}
|
||||
</FormSubmit>
|
||||
);
|
||||
};
|
||||
|
||||
117
src/admin/hooks/useHotkey.tsx
Normal file
117
src/admin/hooks/useHotkey.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/* eslint-disable no-shadow */
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useModal } from '@faceless-ui/modal';
|
||||
import { setsAreEqual } from '../utilities/setsAreEqual';
|
||||
|
||||
// Required to be outside of hook, else debounce would be necessary
|
||||
// and then one could not prevent the default behaviour.
|
||||
const pressedKeys = new Set<string>([]);
|
||||
|
||||
const map = {
|
||||
metaleft: 'meta',
|
||||
metaright: 'meta',
|
||||
osleft: 'meta',
|
||||
osright: 'meta',
|
||||
shiftleft: 'shift',
|
||||
shiftright: 'shift',
|
||||
ctrlleft: 'ctrl',
|
||||
ctrlright: 'ctrl',
|
||||
controlleft: 'ctrl',
|
||||
controlright: 'ctrl',
|
||||
altleft: 'alt',
|
||||
altright: 'alt',
|
||||
escape: 'esc',
|
||||
};
|
||||
|
||||
const stripKey = (key: string) => {
|
||||
return (map[key.toLowerCase()] || key).trim()
|
||||
.toLowerCase()
|
||||
.replace('key', '');
|
||||
};
|
||||
|
||||
const pushToKeys = (code: string) => {
|
||||
const key = stripKey(code);
|
||||
|
||||
// There is a weird bug with macos that if the keys are not cleared they remain in the
|
||||
// pressed keys set.
|
||||
if (key === 'meta') {
|
||||
pressedKeys.forEach((pressedKey) => pressedKey !== 'meta' && pressedKeys.delete(pressedKey));
|
||||
}
|
||||
|
||||
pressedKeys.add(key);
|
||||
};
|
||||
|
||||
const removeFromKeys = (code: string) => {
|
||||
const key = stripKey(code);
|
||||
// There is a weird bug with macos that if the keys are not cleared they remain in the
|
||||
// pressed keys set.
|
||||
if (key === 'meta') {
|
||||
pressedKeys.clear();
|
||||
}
|
||||
pressedKeys.delete(key);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook function to work with hotkeys.
|
||||
* @param param0.keyCode {string[]} The keys to listen for (`Event.code` without `'Key'` and lowercased)
|
||||
* @param param0.cmdCtrlKey {boolean} Whether Ctrl on windows or Cmd on mac must be pressed
|
||||
* @param param0.editDepth {boolean} This ensures that the hotkey is only triggered for the most top-level drawer in case there are nested drawers
|
||||
* @param func The callback function
|
||||
*/
|
||||
const useHotkey = (options: {
|
||||
keyCodes: string[]
|
||||
cmdCtrlKey: boolean
|
||||
editDepth: number
|
||||
}, func: (e: KeyboardEvent) => void): void => {
|
||||
const { keyCodes, cmdCtrlKey, editDepth } = options;
|
||||
|
||||
const { modalState } = useModal();
|
||||
|
||||
|
||||
const keydown = useCallback((event: KeyboardEvent | CustomEvent) => {
|
||||
const e: KeyboardEvent = event.detail?.key ? event.detail : event;
|
||||
if (e.key === undefined) {
|
||||
// Autofill events, or other synthetic events, can be ignored
|
||||
return;
|
||||
}
|
||||
if (e.code) pushToKeys(e.code);
|
||||
|
||||
// Check for Mac and iPad
|
||||
const hasCmd = window.navigator.userAgent.includes('Mac OS X');
|
||||
const pressedWithoutModifier = [...pressedKeys].filter((key) => !['meta', 'ctrl', 'alt', 'shift'].includes(key));
|
||||
|
||||
// Check whether arrays contain the same values (regardless of number of occurrences)
|
||||
if (
|
||||
setsAreEqual(new Set(pressedWithoutModifier), new Set(keyCodes))
|
||||
&& (!cmdCtrlKey || (hasCmd && pressedKeys.has('meta')) || (!hasCmd && e.ctrlKey))
|
||||
) {
|
||||
// get the maximum edit depth by counting the number of open drawers. modalState is and object which contains the state of all drawers.
|
||||
const maxEditDepth = Object.keys(modalState).filter((key) => modalState[key]?.isOpen)?.length + 1 ?? 1;
|
||||
|
||||
if (maxEditDepth !== editDepth) {
|
||||
// We only want to execute the hotkey from the most top-level drawer / edit depth.
|
||||
return;
|
||||
}
|
||||
// execute the function associated with the maximum edit depth
|
||||
func(e);
|
||||
}
|
||||
}, [keyCodes, cmdCtrlKey, editDepth, modalState, func]);
|
||||
|
||||
const keyup = useCallback((e: KeyboardEvent) => {
|
||||
if (e.code) removeFromKeys(e.code);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', keydown, false);
|
||||
document.addEventListener('bypassKeyDown', keydown, false); // this is called if the keydown event's propagation is stopped by react-select
|
||||
document.addEventListener('keyup', keyup, false);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', keydown);
|
||||
document.removeEventListener('bypassKeyDown', keydown);
|
||||
document.removeEventListener('keyup', keyup);
|
||||
};
|
||||
}, [keydown, keyup]);
|
||||
};
|
||||
|
||||
export default useHotkey;
|
||||
6
src/admin/utilities/setsAreEqual.ts
Normal file
6
src/admin/utilities/setsAreEqual.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Function to determine whether two sets are equal or not.
|
||||
*/
|
||||
export const setsAreEqual = <T>(lhs: Set<T>, rhs: Set<T>) => {
|
||||
return lhs.size === rhs.size && Array.from(lhs).every((value) => rhs.has(value));
|
||||
};
|
||||
Reference in New Issue
Block a user