Added keyboard shortcuts

fixes https://github.com/TryGhost/Team/issues/1725

- ESC to close/blur forms
- CMD + ESC to submit forms
- C to focus and scroll to main comment form
- ESC to close any modals (context menus or dialogs)
- keydown events are passed down from iframes to the main window to prevent having to listen on all iframes + window every time we need these events.
This commit is contained in:
Simon Backx 2022-08-02 16:39:32 +02:00
parent 551e12000d
commit c7655ceca0
4 changed files with 116 additions and 36 deletions

View File

@ -9,7 +9,7 @@ import {formatRelativeTime} from '../utils/helpers';
import {ReactComponent as SpinnerIcon} from '../images/icons/spinner.svg';
const Form = (props) => {
const {member, postId, dispatchAction, onAction, avatarSaturation} = useContext(AppContext);
const {member, postId, dispatchAction, avatarSaturation} = useContext(AppContext);
const [isFormOpen, setFormOpen] = useState(props.isReply || props.isEdit ? true : false);
const formEl = useRef(null);
const [progress, setProgress] = useState('default');
@ -92,7 +92,9 @@ const Form = (props) => {
if (succeeded) {
editor.commands.focus();
} else {
props.close();
if (props.close) {
props.close();
}
}
setPreventClosing(false);
}
@ -100,7 +102,7 @@ const Form = (props) => {
} else {
setFormOpen(true);
}
}, [editor, dispatchAction, memberName, props.isEdit]);
}, [editor, dispatchAction, memberName, props]);
// Set the cursor position at the end of the form, instead of the beginning (= when using autofocus)
useEffect(() => {
@ -149,36 +151,7 @@ const Form = (props) => {
};
}, [editor, props]);
useEffect(() => {
if (!editor) {
return;
}
editor.on('focus', () => {
onFormFocus();
});
editor.on('blur', () => {
if (editor?.isEmpty) {
setFormOpen(false);
if (props.isReply && props.close && !preventClosing) {
// TODO: we cannot toggle the form when this happens, because when the member doesn't have a name we'll always loose focus to input the name...
// Need to find a different way for this behaviour
props.close();
}
}
});
return () => {
// Remove previous events
editor?.off('focus');
editor?.off('blur');
};
}, [editor, props, onFormFocus, preventClosing]);
const submitForm = async (event) => {
event.preventDefault();
const submitForm = useCallback(async () => {
if (editor.isEmpty) {
return;
}
@ -221,7 +194,7 @@ const Form = (props) => {
} else {
try {
// Send comment to server
await onAction('addComment', {
await dispatchAction('addComment', {
post_id: postId,
status: 'published',
html: editor.getHTML()
@ -238,7 +211,75 @@ const Form = (props) => {
}
return false;
};
}, [editor, props, dispatchAction, postId]);
useEffect(() => {
if (!editor) {
return;
}
editor.on('focus', () => {
onFormFocus();
});
editor.on('blur', () => {
if (editor?.isEmpty) {
setFormOpen(false);
if (props.isReply && props.close && !preventClosing) {
// TODO: we cannot toggle the form when this happens, because when the member doesn't have a name we'll always loose focus to input the name...
// Need to find a different way for this behaviour
props.close();
}
}
});
// Add some basic keyboard shortcuts
// ESC to blur the editor
const keyDownListener = (event) => {
if (event.metaKey) {
// CMD on MacOS
if (event.key === 'Escape' && editor?.isFocused) {
// Try submit
submitForm();
}
return;
}
if (event.key === 'Escape') {
if (editor?.isFocused && !preventClosing) {
if (props.close) {
props.close();
} else {
editor?.commands.blur();
}
}
return;
}
if (event.key === 'c' && !props.isEdit && !props.isReply && !editor?.isFocused) {
editor?.commands.focus();
window.scrollTo({
top: getScrollToPosition(),
left: 0,
behavior: 'smooth'
});
return;
}
};
// Note: normally we would need to attach this listener to the window + the iframe window. But we made listener
// in the Iframe component that passes down all the keydown events to the main window to prevent that
window.addEventListener('keydown', keyDownListener, {passive: true});
return () => {
window.removeEventListener('keydown', keyDownListener, {passive: true});
// Remove previous events
editor?.off('focus');
editor?.off('blur');
};
}, [editor, props, onFormFocus, preventClosing, submitForm]);
const preventIfFocused = (event) => {
if (editor.isFocused) {

View File

@ -24,6 +24,17 @@ export default class IFrame extends Component {
if (this.props.onResize) {
(new ResizeObserver(_ => this.props.onResize(this.iframeRoot)))?.observe?.(this.iframeRoot);
}
// This is a bit hacky, but prevents us to need to attach even listeners to all the iframes we have
// because when we want to listen for keydown events, those are only send in the window of iframe that is focused
// To get around this, we pass down the keydown events to the main window
// No need to detach, because the iframe would get removed
this.node.contentWindow.addEventListener('keydown', (e) => {
// dispatch a new event
window.dispatchEvent(
new KeyboardEvent('keydown', e)
);
});
}
}

View File

@ -32,6 +32,21 @@ const CommentContextMenu = (props) => {
};
}, [props]);
useEffect(() => {
const listener = (event) => {
if (event.key === 'Escape') {
props.close();
}
};
// For keydown, we only need to listen to the main window, because we pass the events
// manually in the Iframe component
window.addEventListener('keydown', listener, {passive: true});
return () => {
window.removeEventListener('keydown', listener, {passive: true});
};
});
// Prevent closing the context menu when clicking inside of it
const stopPropagation = (event) => {
event.stopPropagation();

View File

@ -1,4 +1,4 @@
import React, {useContext} from 'react';
import React, {useContext, useEffect} from 'react';
import {Transition} from '@headlessui/react';
import Frame from '../Frame';
import AppContext from '../../AppContext';
@ -18,6 +18,19 @@ const GenericDialog = (props) => {
event.stopPropagation();
};
useEffect(() => {
const listener = (event) => {
if (event.key === 'Escape') {
close();
}
};
window.addEventListener('keydown', listener, {passive: true});
return () => {
window.removeEventListener('keydown', listener, {passive: true});
};
});
return (
<Transition show={props.show} appear={true}>
<Frame type="fixed">