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:
rpfaeffle
2023-08-14 21:36:49 +02:00
committed by GitHub
parent 35dfaab7c2
commit 942cfec286
11 changed files with 280 additions and 19 deletions

View File

@@ -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;

View File

@@ -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);
}
},
}}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>
);
};

View 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;

View 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));
};