From c7655ceca04650fcadf22e3fd187598efea77114 Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Tue, 2 Aug 2022 16:39:32 +0200 Subject: [PATCH] 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. --- apps/comments-ui/src/components/Form.js | 111 ++++++++++++------ apps/comments-ui/src/components/IFrame.js | 11 ++ .../components/modals/CommentContextMenu.js | 15 +++ .../src/components/modals/GenericDialog.js | 15 ++- 4 files changed, 116 insertions(+), 36 deletions(-) diff --git a/apps/comments-ui/src/components/Form.js b/apps/comments-ui/src/components/Form.js index 2ffb7d10d7..3795ef32f9 100644 --- a/apps/comments-ui/src/components/Form.js +++ b/apps/comments-ui/src/components/Form.js @@ -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) { diff --git a/apps/comments-ui/src/components/IFrame.js b/apps/comments-ui/src/components/IFrame.js index b49e6f41f4..201d223937 100644 --- a/apps/comments-ui/src/components/IFrame.js +++ b/apps/comments-ui/src/components/IFrame.js @@ -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) + ); + }); } } diff --git a/apps/comments-ui/src/components/modals/CommentContextMenu.js b/apps/comments-ui/src/components/modals/CommentContextMenu.js index 040d670a62..0d05bf5762 100644 --- a/apps/comments-ui/src/components/modals/CommentContextMenu.js +++ b/apps/comments-ui/src/components/modals/CommentContextMenu.js @@ -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(); diff --git a/apps/comments-ui/src/components/modals/GenericDialog.js b/apps/comments-ui/src/components/modals/GenericDialog.js index 7c5042b9f6..50b5e6a4c6 100644 --- a/apps/comments-ui/src/components/modals/GenericDialog.js +++ b/apps/comments-ui/src/components/modals/GenericDialog.js @@ -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 (