From 9a2390b715c772a0448ea779fe24642bcc38e66d Mon Sep 17 00:00:00 2001 From: Per-Kristian Nordnes Date: Thu, 26 Oct 2023 14:33:35 +0200 Subject: [PATCH] refactor(comments): refactor key event handling in the comment input and consumers This will let comment input key events propagate to the parent components for handling spesific functionality for that parent. This also reduces the need for additional key handlers in the parent components, and makes it easier to compose functionality as there is only one originating source of those events. --- .../plugin/field/CommentFieldButton.tsx | 28 ++++++++++--- .../src/components/list/CommentsListItem.tsx | 35 ++++++++++++++-- .../list/CommentsListItemLayout.tsx | 28 ++++++++++--- .../components/list/CreateNewThreadInput.tsx | 32 ++++++++++---- .../pte/comment-input/CommentInput.tsx | 19 +++++---- .../pte/comment-input/CommentInputInner.tsx | 42 ++++--------------- .../comment-input/CommentInputProvider.tsx | 6 +-- .../components/pte/comment-input/Editable.tsx | 19 +++++++-- 8 files changed, 133 insertions(+), 76 deletions(-) diff --git a/packages/sanity/src/desk/comments/plugin/field/CommentFieldButton.tsx b/packages/sanity/src/desk/comments/plugin/field/CommentFieldButton.tsx index 078d3e66cd6..74b078d546f 100644 --- a/packages/sanity/src/desk/comments/plugin/field/CommentFieldButton.tsx +++ b/packages/sanity/src/desk/comments/plugin/field/CommentFieldButton.tsx @@ -58,6 +58,7 @@ interface CommentFieldButtonProps { onClick?: () => void onCommentAdd: () => void onDiscard: () => void + onInputKeyDown?: (event: React.KeyboardEvent) => void open: boolean setOpen: (open: boolean) => void value: CommentMessage @@ -74,11 +75,11 @@ export function CommentFieldButton(props: CommentFieldButtonProps) { onClick, onCommentAdd, onDiscard, + onInputKeyDown, open, setOpen, value, } = props - const [mentionMenuOpen, setMentionMenuOpen] = useState(false) const [popoverElement, setPopoverElement] = useState(null) const commentInputHandle = useRef(null) const hasComments = Boolean(count > 0) @@ -93,15 +94,31 @@ export function CommentFieldButton(props: CommentFieldButtonProps) { const hasValue = useMemo(() => hasCommentMessageValue(value), [value]) const startDiscard = useCallback(() => { - if (mentionMenuOpen) return - if (!hasValue) { closePopover() return } commentInputHandle.current?.discardDialogController.open() - }, [closePopover, hasValue, mentionMenuOpen]) + }, [closePopover, hasValue]) + + const handleInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // Don't act if the input already prevented this event + if (event.isDefaultPrevented()) { + return + } + // Discard the input text + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + startDiscard() + } + // Call parent handler + if (onInputKeyDown) onInputKeyDown(event) + }, + [onInputKeyDown, startDiscard], + ) const handleDiscardCancel = useCallback(() => { commentInputHandle.current?.discardDialogController.close() @@ -132,8 +149,7 @@ export function CommentFieldButton(props: CommentFieldButtonProps) { onChange={onChange} onDiscardCancel={handleDiscardCancel} onDiscardConfirm={handleDiscardConfirm} - onEscapeKeyDown={startDiscard} - onMentionMenuOpenChange={setMentionMenuOpen} + onKeyDown={handleInputKeyDown} onSubmit={handleSubmit} placeholder={placeholder} readOnly={isRunningSetup} diff --git a/packages/sanity/src/desk/comments/src/components/list/CommentsListItem.tsx b/packages/sanity/src/desk/comments/src/components/list/CommentsListItem.tsx index 8f78bafd829..5a40d7f9416 100644 --- a/packages/sanity/src/desk/comments/src/components/list/CommentsListItem.tsx +++ b/packages/sanity/src/desk/comments/src/components/list/CommentsListItem.tsx @@ -77,6 +77,7 @@ interface CommentsListItemProps { onCreateRetry: (id: string) => void onDelete: (id: string) => void onEdit: (id: string, payload: CommentEditPayload) => void + onKeyDown?: (event: React.KeyboardEvent) => void onPathSelect?: (path: Path) => void onReply: (payload: CommentCreatePayload) => void onStatusChange?: (id: string, status: CommentStatus) => void @@ -95,6 +96,7 @@ export const CommentsListItem = React.memo(function CommentsListItem(props: Comm onCreateRetry, onDelete, onEdit, + onKeyDown, onPathSelect, onReply, onStatusChange, @@ -141,14 +143,37 @@ export const CommentsListItem = React.memo(function CommentsListItem(props: Comm replyInputRef.current?.discardDialogController.open() }, [hasValue]) + const handleInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // Don't act if the input already prevented this event + if (event.isDefaultPrevented()) { + return + } + // Discard input text with Escape + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + startDiscard() + } + // TODO: this would be cool + // Edit last comment if current user is the owner and pressing arrowUp + // if (event.key === 'ArrowUp') { + // const lastReply = replies.splice(-1)[0] + // if (lastReply?.authorId === currentUser.id && !hasValue) { + // + // } + // } + }, + [startDiscard], + ) + const cancelDiscard = useCallback(() => { replyInputRef.current?.discardDialogController.close() }, []) const confirmDiscard = useCallback(() => { - replyInputRef.current?.discardDialogController.close() - replyInputRef.current?.reset() setValue(EMPTY_ARRAY) + replyInputRef.current?.discardDialogController.close() replyInputRef.current?.focus() }, []) @@ -197,6 +222,7 @@ export const CommentsListItem = React.memo(function CommentsListItem(props: Comm hasError={reply._state?.type === 'createError'} isRetrying={reply._state?.type === 'createRetrying'} mentionOptions={mentionOptions} + onInputKeyDown={handleInputKeyDown} onCopyLink={onCopyLink} onCreateRetry={onCreateRetry} onDelete={onDelete} @@ -207,6 +233,7 @@ export const CommentsListItem = React.memo(function CommentsListItem(props: Comm )), [ currentUser, + handleInputKeyDown, mentionOptions, onCopyLink, onCreateRetry, @@ -247,6 +274,7 @@ export const CommentsListItem = React.memo(function CommentsListItem(props: Comm onCreateRetry={onCreateRetry} onDelete={onDelete} onEdit={onEdit} + onInputKeyDown={onKeyDown} onStatusChange={onStatusChange} readOnly={readOnly} /> @@ -269,7 +297,6 @@ export const CommentsListItem = React.memo(function CommentsListItem(props: Comm )} {renderedReplies} - {canReply && ( void onDelete: (id: string) => void onEdit: (id: string, message: CommentEditPayload) => void + onInputKeyDown?: (event: React.KeyboardEvent) => void onStatusChange?: (id: string, status: CommentStatus) => void readOnly?: boolean } @@ -137,6 +138,7 @@ export function CommentsListItemLayout(props: CommentsListItemLayoutProps) { onCreateRetry, onDelete, onEdit, + onInputKeyDown, onStatusChange, readOnly, } = props @@ -146,7 +148,6 @@ export function CommentsListItemLayout(props: CommentsListItemLayoutProps) { const [value, setValue] = useState(message) const [isEditing, setIsEditing] = useState(false) const [rootElement, setRootElement] = useState(null) - const [mentionMenuOpen, setMentionMenuOpen] = useState(false) const startMessage = useRef(message) const [menuOpen, setMenuOpen] = useState(false) @@ -184,10 +185,27 @@ export function CommentsListItemLayout(props: CommentsListItemLayoutProps) { cancelEdit() return } - commentInputRef.current?.discardDialogController.open() }, [cancelEdit, hasChanges, hasValue]) + const handleInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // Don't act if the input already prevented this event + if (event.isDefaultPrevented()) { + return + } + // Discard the input text + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + startDiscard() + } + // Call parent handler + if (onInputKeyDown) onInputKeyDown(event) + }, + [onInputKeyDown, startDiscard], + ) + const cancelDiscard = useCallback(() => { commentInputRef.current?.discardDialogController.close() }, []) @@ -215,7 +233,7 @@ export function CommentsListItemLayout(props: CommentsListItemLayoutProps) { }) useGlobalKeyDown((event) => { - if (event.key === 'Escape' && !mentionMenuOpen && !hasChanges) { + if (event.key === 'Escape' && !hasChanges) { cancelEdit() } }) @@ -223,7 +241,6 @@ export function CommentsListItemLayout(props: CommentsListItemLayoutProps) { useClickOutside(() => { if (!hasChanges) { cancelEdit() - commentInputRef.current?.blur() } }, [rootElement]) @@ -294,8 +311,7 @@ export function CommentsListItemLayout(props: CommentsListItemLayoutProps) { onChange={setValue} onDiscardCancel={cancelDiscard} onDiscardConfirm={confirmDiscard} - onEscapeKeyDown={startDiscard} - onMentionMenuOpenChange={setMentionMenuOpen} + onKeyDown={handleInputKeyDown} onSubmit={handleEditSubmit} readOnly={readOnly} ref={commentInputRef} diff --git a/packages/sanity/src/desk/comments/src/components/list/CreateNewThreadInput.tsx b/packages/sanity/src/desk/comments/src/components/list/CreateNewThreadInput.tsx index 5c7ae788853..44cb74133e0 100644 --- a/packages/sanity/src/desk/comments/src/components/list/CreateNewThreadInput.tsx +++ b/packages/sanity/src/desk/comments/src/components/list/CreateNewThreadInput.tsx @@ -10,8 +10,8 @@ interface CreateNewThreadInputProps { fieldName: string mentionOptions: MentionOptionsHookValue onBlur?: CommentInputProps['onBlur'] - onEditDiscard?: () => void onFocus?: CommentInputProps['onFocus'] + onKeyDown?: (event: React.KeyboardEvent) => void onNewThreadCreate: (payload: CommentMessage) => void readOnly?: boolean } @@ -22,8 +22,8 @@ export function CreateNewThreadInput(props: CreateNewThreadInputProps) { fieldName, mentionOptions, onBlur, - onEditDiscard, onFocus, + onKeyDown, onNewThreadCreate, readOnly, } = props @@ -40,20 +40,34 @@ export function CreateNewThreadInput(props: CreateNewThreadInputProps) { const startDiscard = useCallback(() => { if (!hasValue) { - onEditDiscard?.() return } - commentInputHandle.current?.discardDialogController.open() - }, [hasValue, onEditDiscard]) + }, [hasValue]) + + const handleInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // Don't act if the input already prevented this event + if (event.isDefaultPrevented()) { + return + } + // Discard the input text + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + startDiscard() + } + // Call parent handler + if (onKeyDown) onKeyDown(event) + }, + [onKeyDown, startDiscard], + ) const confirmDiscard = useCallback(() => { setValue(EMPTY_ARRAY) - commentInputHandle.current?.reset() commentInputHandle.current?.discardDialogController.close() commentInputHandle.current?.focus() - onEditDiscard?.() - }, [onEditDiscard]) + }, []) const cancelDiscard = useCallback(() => { commentInputHandle.current?.discardDialogController.close() @@ -74,7 +88,7 @@ export function CreateNewThreadInput(props: CreateNewThreadInputProps) { onChange={setValue} onDiscardCancel={cancelDiscard} onDiscardConfirm={confirmDiscard} - onEscapeKeyDown={startDiscard} + onKeyDown={handleInputKeyDown} onFocus={onFocus} onSubmit={handleSubmit} placeholder={placeholder} diff --git a/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInput.tsx b/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInput.tsx index 844d6e0a1d4..6714fc76134 100644 --- a/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInput.tsx +++ b/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInput.tsx @@ -27,8 +27,8 @@ export interface CommentInputProps { onChange: (value: PortableTextBlock[]) => void onDiscardCancel: () => void onDiscardConfirm: () => void - onEscapeKeyDown?: () => void onFocus?: (e: React.FormEvent) => void + onKeyDown?: (e: React.KeyboardEvent) => void onMentionMenuOpenChange?: (open: boolean) => void onSubmit: () => void placeholder?: React.ReactNode @@ -66,8 +66,8 @@ export const CommentInput = forwardRef( onChange, onDiscardCancel, onDiscardConfirm, - onEscapeKeyDown, onFocus, + onKeyDown, onMentionMenuOpenChange, onSubmit, placeholder, @@ -130,6 +130,11 @@ export const CommentInput = forwardRef( scrollToEditor() }, [onSubmit, requestFocus, resetEditorInstance, scrollToEditor]) + const handleDiscardConfirm = useCallback(() => { + onDiscardConfirm() + resetEditorInstance() + }, [onDiscardConfirm, resetEditorInstance]) + // The way a user a comment can be discarded varies from the context it is used in. // This controller is used to take care of the main logic of the discard process, while // specific behavior is handled by the consumer. @@ -156,20 +161,18 @@ export const CommentInput = forwardRef( } }, scrollTo: scrollToEditor, - reset: () => { - setEditorInstanceKey(keyGenerator()) - }, + reset: resetEditorInstance, discardDialogController, } }, - [discardDialogController, requestFocus, scrollToEditor], + [discardDialogController, requestFocus, resetEditorInstance, scrollToEditor], ) return ( <> {showDiscardDialog && ( - + )} @@ -199,8 +202,8 @@ export const CommentInput = forwardRef( currentUser={currentUser} focusLock={focusLock} onBlur={onBlur} - onEscapeKeyDown={onEscapeKeyDown} onFocus={onFocus} + onKeyDown={onKeyDown} onSubmit={handleSubmit} placeholder={placeholder} withAvatar={withAvatar} diff --git a/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputInner.tsx b/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputInner.tsx index 7ccc344e926..04fcd29288b 100644 --- a/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputInner.tsx +++ b/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputInner.tsx @@ -77,36 +77,20 @@ interface CommentInputInnerProps { currentUser: CurrentUser focusLock?: boolean onBlur?: (e: React.FormEvent) => void - onEscapeKeyDown?: () => void onFocus?: (e: React.FormEvent) => void + onKeyDown?: (e: React.KeyboardEvent) => void onSubmit: () => void placeholder?: React.ReactNode withAvatar?: boolean } export function CommentInputInner(props: CommentInputInnerProps) { - const { - currentUser, - focusLock, - onBlur, - onEscapeKeyDown, - onFocus, - onSubmit, - placeholder, - withAvatar, - } = props + const {currentUser, focusLock, onBlur, onFocus, onKeyDown, onSubmit, placeholder, withAvatar} = + props const [user] = useUser(currentUser.id) - const { - canSubmit, - expandOnFocus, - focused, - hasChanges, - insertAtChar, - mentionsMenuOpen, - openMentions, - readOnly, - } = useCommentInput() + const {canSubmit, expandOnFocus, focused, hasChanges, insertAtChar, openMentions, readOnly} = + useCommentInput() const avatar = withAvatar ? : null @@ -119,21 +103,8 @@ export function CommentInputInner(props: CommentInputInnerProps) { [insertAtChar, openMentions], ) - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - e.stopPropagation() - e.preventDefault() - if (mentionsMenuOpen) return - - onEscapeKeyDown?.() - } - }, - [mentionsMenuOpen, onEscapeKeyDown], - ) - return ( - + {avatar} diff --git a/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputProvider.tsx b/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputProvider.tsx index f2e7b4f7c1e..a150b45754e 100644 --- a/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputProvider.tsx +++ b/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputProvider.tsx @@ -73,16 +73,14 @@ export function CommentInputProvider(props: CommentInputProviderProps) { setMentionsMenuOpen(false) setMentionsSearchTerm('') setSelectionAtMentionInsert(null) - focusEditor() - }, [focusEditor]) + }, []) const openMentions = useCallback(() => { setMentionsMenuOpen(true) setMentionsSearchTerm('') setMentionsMenuOpen(true) setSelectionAtMentionInsert(PortableTextEditor.getSelection(editor)) - focusEditor() - }, [focusEditor, editor]) + }, [editor]) // This function activates or deactivates the mentions menu and updates // the mention search term when the user types into the Portable Text Editor. diff --git a/packages/sanity/src/desk/comments/src/components/pte/comment-input/Editable.tsx b/packages/sanity/src/desk/comments/src/components/pte/comment-input/Editable.tsx index 3f587eab15c..0688f4c4f44 100644 --- a/packages/sanity/src/desk/comments/src/components/pte/comment-input/Editable.tsx +++ b/packages/sanity/src/desk/comments/src/components/pte/comment-input/Editable.tsx @@ -53,6 +53,7 @@ interface EditableProps { focusLock?: boolean onBlur?: (e: React.FormEvent) => void onFocus?: (e: React.FormEvent) => void + onKeyDown?: (e: React.KeyboardEvent) => void onSubmit?: () => void placeholder?: React.ReactNode } @@ -62,7 +63,14 @@ export interface EditableHandle { } export function Editable(props: EditableProps) { - const {focusLock, placeholder = 'Create a new comment', onFocus, onBlur, onSubmit} = props + const { + focusLock, + placeholder = 'Create a new comment', + onFocus, + onBlur, + onKeyDown, + onSubmit, + } = props const [popoverElement, setPopoverElement] = useState(null) const rootElementRef = useRef(null) const editableRef = useRef(null) @@ -72,7 +80,6 @@ export function Editable(props: EditableProps) { const { canSubmit, closeMentions, - focusEditor, insertMention, mentionOptions, mentionsMenuOpen, @@ -145,14 +152,18 @@ export function Editable(props: EditableProps) { case 'ArrowLeft': case 'ArrowRight': if (mentionsMenuOpen) { + // stop these events if the menu is open + event.preventDefault() + event.stopPropagation() closeMentions() - focusEditor() } break default: } + // Call parent key handler + if (onKeyDown) onKeyDown(event) }, - [canSubmit, closeMentions, focusEditor, mentionsMenuOpen, onSubmit], + [canSubmit, closeMentions, mentionsMenuOpen, onKeyDown, onSubmit], ) const initialSelectionAtEndOfContent: EditorSelection | undefined = useMemo(() => {