diff --git a/packages/sanity/playwright-ct/tests/comments/CommentInput.spec.tsx b/packages/sanity/playwright-ct/tests/comments/CommentInput.spec.tsx index 199a1e4d899..1b34489d901 100644 --- a/packages/sanity/playwright-ct/tests/comments/CommentInput.spec.tsx +++ b/packages/sanity/playwright-ct/tests/comments/CommentInput.spec.tsx @@ -23,9 +23,27 @@ test.describe('Comments', () => { test('Should bring up mentions menu when typing @', async ({mount, page}) => { await mount() const $editable = page.getByTestId('comment-input-editable') - await expect($editable).toBeEditable() - await page.keyboard.type(`@`) + await $editable.waitFor({state: 'visible'}) + await page.keyboard.type('@') await expect(page.getByTestId('comments-mentions-menu')).toBeVisible() }) + + test('Should be able to submit', async ({mount, page}) => { + const {insertPortableText} = testHelpers({page}) + let submitted = false + const onSubmit = () => { + submitted = true + } + await mount() + const $editable = page.getByTestId('comment-input-editable') + await expect($editable).toBeEditable() + // Test that blank comments can't be submitted + await page.keyboard.press('Enter') + expect(submitted).toBe(false) + await insertPortableText('This is a comment!', $editable) + await expect($editable).toHaveText('This is a comment!') + await page.keyboard.press('Enter') + expect(submitted).toBe(true) + }) }) }) diff --git a/packages/sanity/playwright-ct/tests/comments/CommentInputStory.tsx b/packages/sanity/playwright-ct/tests/comments/CommentInputStory.tsx index 28f356b81d1..a7ff572789c 100644 --- a/packages/sanity/playwright-ct/tests/comments/CommentInputStory.tsx +++ b/packages/sanity/playwright-ct/tests/comments/CommentInputStory.tsx @@ -16,9 +16,18 @@ const currentUser: CurrentUser = { const SCHEMA_TYPES: [] = [] -export function CommentsInputStory() { - const [value, setValue] = useState(null) - +export function CommentsInputStory({ + onDiscardCancel = noop, + onDiscardConfirm = noop, + onSubmit = noop, + value = null, +}: { + onDiscardCancel?: () => void + onDiscardConfirm?: () => void + onSubmit?: () => void + value?: PortableTextBlock[] | null +}) { + const [valueState, setValueState] = useState(value) return ( ) 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 e7ad15b2114..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, @@ -123,8 +125,6 @@ export const CommentsListItem = React.memo(function CommentsListItem(props: Comm onReply?.(nextComment) setValue(EMPTY_ARRAY) - - replyInputRef.current?.focus() }, [ onReply, parentComment._id, @@ -143,13 +143,38 @@ 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() setValue(EMPTY_ARRAY) + replyInputRef.current?.discardDialogController.close() + replyInputRef.current?.focus() }, []) const handleThreadRootClick = useCallback(() => { @@ -185,6 +210,40 @@ export const CommentsListItem = React.memo(function CommentsListItem(props: Comm } }, [replies]) + const renderedReplies = useMemo( + () => + splicedReplies.map((reply) => ( + + + + )), + [ + currentUser, + handleInputKeyDown, + mentionOptions, + onCopyLink, + onCreateRetry, + onDelete, + onEdit, + readOnly, + splicedReplies, + ], + ) + return ( @@ -236,25 +296,7 @@ export const CommentsListItem = React.memo(function CommentsListItem(props: Comm )} - {splicedReplies.map((reply) => ( - - - - ))} - + {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 be680801be7..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 @@ -34,31 +34,43 @@ export function CreateNewThreadInput(props: CreateNewThreadInputProps) { const handleSubmit = useCallback(() => { onNewThreadCreate?.(value) setValue(EMPTY_ARRAY) - commentInputHandle.current?.focus() }, [onNewThreadCreate, value]) const hasValue = useMemo(() => hasCommentMessageValue(value), [value]) const startDiscard = useCallback(() => { if (!hasValue) { - onEditDiscard?.() - commentInputHandle.current?.focus() 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(() => { - commentInputHandle.current?.discardDialogController.close() setValue(EMPTY_ARRAY) - onEditDiscard?.() + commentInputHandle.current?.discardDialogController.close() commentInputHandle.current?.focus() - }, [onEditDiscard]) + }, []) const cancelDiscard = useCallback(() => { commentInputHandle.current?.discardDialogController.close() - commentInputHandle.current?.focus() }, []) const placeholder = ( @@ -76,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 de78064160d..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 @@ -1,5 +1,5 @@ import React, {forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react' -import {EditorChange, PortableTextEditor} from '@sanity/portable-text-editor' +import {EditorChange, PortableTextEditor, keyGenerator} from '@sanity/portable-text-editor' import {CurrentUser, PortableTextBlock} from '@sanity/types' import FocusLock from 'react-focus-lock' import {Stack} from '@sanity/ui' @@ -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 @@ -47,6 +47,7 @@ export interface CommentInputHandle { discardDialogController: CommentDiscardDialogController focus: () => void scrollTo: () => void + reset: () => void } /** @@ -65,8 +66,8 @@ export const CommentInput = forwardRef( onChange, onDiscardCancel, onDiscardConfirm, - onEscapeKeyDown, onFocus, + onKeyDown, onMentionMenuOpenChange, onSubmit, placeholder, @@ -79,15 +80,26 @@ export const CommentInput = forwardRef( const editorContainerRef = useRef(null) const [showDiscardDialog, setShowDiscardDialog] = useState(false) + // A unique (React) key for the editor instance. + const [editorInstanceKey, setEditorInstanceKey] = useState(keyGenerator()) + + const requestFocus = useCallback(() => { + requestAnimationFrame(() => { + if (!editorRef.current) return + PortableTextEditor.focus(editorRef.current) + }) + }, []) + + const resetEditorInstance = useCallback(() => { + setEditorInstanceKey(keyGenerator()) + }, []) + const handleChange = useCallback( (change: EditorChange) => { // Focus the editor when ready if focusOnMount is true if (change.type === 'ready') { - if (focusOnMount && editorRef.current) { - requestAnimationFrame(() => { - if (!editorRef.current) return - PortableTextEditor.focus(editorRef.current) - }) + if (focusOnMount) { + requestFocus() } } if (change.type === 'focus') { @@ -101,70 +113,71 @@ export const CommentInput = forwardRef( // Update the comment value whenever the comment is edited by the user. if (change.type === 'patch' && editorRef.current) { const editorStateValue = PortableTextEditor.getValue(editorRef.current) - if (editorStateValue) { - onChange(editorStateValue) - } + onChange(editorStateValue || EMPTY_ARRAY) } }, - [focusOnMount, onChange], + [focusOnMount, onChange, requestFocus], ) const scrollToEditor = useCallback(() => { editorContainerRef.current?.scrollIntoView(SCROLL_INTO_VIEW_OPTIONS) }, []) + const handleSubmit = useCallback(() => { + onSubmit() + resetEditorInstance() + requestFocus() + 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. const discardDialogController = useMemo(() => { return { open: () => { - if (editorRef?.current) { - PortableTextEditor.blur(editorRef.current) - } - setShowDiscardDialog(true) }, close: () => { setShowDiscardDialog(false) - - if (editorRef?.current) { - PortableTextEditor.focus(editorRef.current) - } + requestFocus() }, } satisfies CommentDiscardDialogController - }, []) + }, [requestFocus]) useImperativeHandle( ref, () => { return { - focus() { - if (editorRef?.current) { - PortableTextEditor.focus(editorRef.current) - } - }, + focus: requestFocus, blur() { - if (editorRef?.current) { + if (editorRef.current) { PortableTextEditor.blur(editorRef.current) } }, scrollTo: scrollToEditor, + reset: resetEditorInstance, discardDialogController, } }, - [discardDialogController, scrollToEditor], + [discardDialogController, requestFocus, resetEditorInstance, scrollToEditor], ) return ( <> {showDiscardDialog && ( - + )} ( currentUser={currentUser} focusLock={focusLock} onBlur={onBlur} - onEscapeKeyDown={onEscapeKeyDown} onFocus={onFocus} - onSubmit={onSubmit} + 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 9e3f34f609f..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 cfa65bf039f..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. @@ -200,10 +198,8 @@ export function CommentInputProvider(props: CommentInputProviderProps) { } } } - - closeMentions() }, - [closeMentions, editor, selectionAtMentionInsert], + [editor, selectionAtMentionInsert], ) const ctxValue = useMemo( 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 f1724db8f40..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,8 @@ interface EditableProps { focusLock?: boolean onBlur?: (e: React.FormEvent) => void onFocus?: (e: React.FormEvent) => void + onKeyDown?: (e: React.KeyboardEvent) => void + onSubmit?: () => void placeholder?: React.ReactNode } @@ -61,7 +63,14 @@ export interface EditableHandle { } export function Editable(props: EditableProps) { - const {focusLock, placeholder = 'Create a new comment', onFocus, onBlur} = 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) @@ -69,8 +78,8 @@ export function Editable(props: EditableProps) { const selection = usePortableTextEditorSelection() const { + canSubmit, closeMentions, - focusEditor, insertMention, mentionOptions, mentionsMenuOpen, @@ -116,26 +125,45 @@ export function Editable(props: EditableProps) { const handleKeyDown = useCallback( (event: KeyboardEvent) => { - switch (event.code) { + switch (event.key) { case 'Enter': + // Shift enter is used to insert a new line, + // keep the default behavior + if (event.shiftKey) { + break + } + // Enter is being used both to select something from the mentionsMenu + // or to submit the comment. Prevent the default behavior. + event.preventDefault() + event.stopPropagation() + + // If the mention menu is open close it, but don't submit. if (mentionsMenuOpen) { - // Stop the event from creating a new block in the editor here - event.preventDefault() - event.stopPropagation() + closeMentions() + break + } + + // Submit the comment if eligible for submission + if (onSubmit && canSubmit) { + onSubmit() } break case 'Escape': 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) }, - [closeMentions, focusEditor, mentionsMenuOpen], + [canSubmit, closeMentions, mentionsMenuOpen, onKeyDown, onSubmit], ) const initialSelectionAtEndOfContent: EditorSelection | undefined = useMemo(() => {