diff --git a/packages/sanity/src/structure/comments/plugin/document-layout/CommentsDocumentLayout.tsx b/packages/sanity/src/structure/comments/plugin/document-layout/CommentsDocumentLayout.tsx index 17b15fcc4c09..ae10da9de3f9 100644 --- a/packages/sanity/src/structure/comments/plugin/document-layout/CommentsDocumentLayout.tsx +++ b/packages/sanity/src/structure/comments/plugin/document-layout/CommentsDocumentLayout.tsx @@ -4,6 +4,7 @@ import {type DocumentLayoutProps} from 'sanity' import {useDocumentPane} from '../../..' import {COMMENTS_INSPECTOR_NAME} from '../../../panes/document/constants' import { + CommentsAuthoringPathProvider, CommentsEnabledProvider, CommentsProvider, CommentsSelectedPathProvider, @@ -43,7 +44,9 @@ function CommentsDocumentLayoutInner(props: DocumentLayoutProps) { isCommentsOpen={inspector?.name === COMMENTS_INSPECTOR_NAME} onCommentsOpen={handleOpenCommentsInspector} > - {props.renderDefault(props)} + + {props.renderDefault(props)} + ) } diff --git a/packages/sanity/src/structure/comments/plugin/field/CommentsField.tsx b/packages/sanity/src/structure/comments/plugin/field/CommentsField.tsx index 5742f7aef98c..2b22642541a0 100644 --- a/packages/sanity/src/structure/comments/plugin/field/CommentsField.tsx +++ b/packages/sanity/src/structure/comments/plugin/field/CommentsField.tsx @@ -11,9 +11,11 @@ import styled, {css} from 'styled-components' import { applyCommentsFieldAttr, type CommentCreatePayload, + type CommentMessage, type CommentsUIMode, isTextSelectionComment, useComments, + useCommentsAuthoringPath, useCommentsEnabled, useCommentsScroll, useCommentsSelectedPath, @@ -22,6 +24,14 @@ import { import {COMMENTS_HIGHLIGHT_HUE_KEY} from '../../src/constants' import {CommentsFieldButton} from './CommentsFieldButton' +// When the form is temporarily set to `readOnly` while reconnecting, the form +// will be re-rendered and any comment that is being authored will be lost. +// To avoid this, we cache the comment message in a map and restore it when the +// field is re-rendered. +const messageCache = new Map() + +const EMPTY_ARRAY: [] = [] + const HIGHLIGHT_BLOCK_VARIANTS: Variants = { initial: { opacity: 0, @@ -74,8 +84,6 @@ function CommentFieldInner( }, ) { const {mode} = props - const [open, setOpen] = useState(false) - const [value, setValue] = useState(null) const currentUser = useCurrentUser() const {element: boundaryElement} = useBoundaryElement() @@ -94,18 +102,28 @@ function CommentFieldInner( } = useComments() const {upsellData, handleOpenDialog} = useCommentsUpsell() const {selectedPath, setSelectedPath} = useCommentsSelectedPath() + const {authoringPath, setAuthoringPath} = useCommentsAuthoringPath() const {scrollToGroup} = useCommentsScroll({ boundaryElement, }) const fieldTitle = useMemo(() => getSchemaTypeTitle(props.schemaType), [props.schemaType]) + const stringPath = useMemo(() => PathUtils.toString(props.path), [props.path]) + + // Use the cached value if it exists as the initial value + const cachedValue = messageCache.get(stringPath) || null + + const [value, setValue] = useState(cachedValue) + + // If the path of the field matches the authoring path, the comment input should be open. + const isOpen = useMemo(() => authoringPath === stringPath, [authoringPath, stringPath]) // Determine if the current field is selected const isSelected = useMemo(() => { if (!isCommentsOpen) return false if (selectedPath?.origin === 'form' || selectedPath?.origin === 'url') return false - return selectedPath?.fieldPath === PathUtils.toString(props.path) - }, [isCommentsOpen, props.path, selectedPath?.fieldPath, selectedPath?.origin]) + return selectedPath?.fieldPath === stringPath + }, [isCommentsOpen, selectedPath?.fieldPath, selectedPath?.origin, stringPath]) const isInlineCommentThread = useMemo(() => { return comments.data.open @@ -115,17 +133,21 @@ function CommentFieldInner( // Total number of comments for the current field const count = useMemo(() => { - const stringPath = PathUtils.toString(props.path) - const commentsCount = comments.data.open .map((c) => (c.fieldPath === stringPath ? c.commentsCount : 0)) .reduce((acc, val) => acc + val, 0) return commentsCount || 0 - }, [comments.data.open, props.path]) + }, [comments.data.open, stringPath]) const hasComments = Boolean(count > 0) + const resetMessageValue = useCallback(() => { + // Reset the value and remove the message from the cache + setValue(null) + messageCache.delete(stringPath) + }, [stringPath]) + const handleClick = useCallback(() => { // When clicking a comment button when the field has comments, we want to: if (hasComments) { @@ -134,8 +156,9 @@ function CommentFieldInner( setStatus('open') } - // 2. Close the comment input if it's open - setOpen(false) + // 2. Ensure that the authoring path is reset when clicking + // the comment button when the field has comments. + setAuthoringPath(null) // 3. Open the comments inspector onCommentsOpen?.() @@ -171,19 +194,24 @@ function CommentFieldInner( return } - // Else, toggle the comment input open/closed - setOpen((v) => !v) + // If the field is open (i.e. the authoring path is set to the current field) + // we close the field by resetting the authoring path. If the field is not open, + // we set the authoring path to the current field so that the comment form is opened. + setAuthoringPath(isOpen ? null : stringPath) }, [ comments.data.open, handleOpenDialog, hasComments, + isOpen, mode, onCommentsOpen, props.path, scrollToGroup, + setAuthoringPath, setSelectedPath, setStatus, status, + stringPath, upsellData, ]) @@ -200,7 +228,7 @@ function CommentFieldInner( status: 'open', threadId: newThreadId, // New comments have no reactions - reactions: [], + reactions: EMPTY_ARRAY, } // Execute the create mutation @@ -216,8 +244,7 @@ function CommentFieldInner( setStatus('open') } - // Reset the value - setValue(null) + resetMessageValue() // Scroll to the thread setSelectedPath({ @@ -232,6 +259,7 @@ function CommentFieldInner( onCommentsOpen, operation, props.path, + resetMessageValue, scrollToGroup, setSelectedPath, setStatus, @@ -239,7 +267,15 @@ function CommentFieldInner( value, ]) - const handleDiscard = useCallback(() => setValue(null), []) + const handleClose = useCallback(() => setAuthoringPath(null), [setAuthoringPath]) + + const handleOnChange = useCallback( + (nextValue: CommentMessage) => { + setValue(nextValue) + messageCache.set(stringPath, nextValue) + }, + [stringPath], + ) const internalComments: FieldProps['__internal_comments'] = useMemo( () => ({ @@ -250,29 +286,31 @@ function CommentFieldInner( fieldTitle={fieldTitle} isCreatingDataset={isCreatingDataset} mentionOptions={mentionOptions} - onChange={setValue} + onChange={handleOnChange} onClick={handleClick} + onClose={handleClose} onCommentAdd={handleCommentAdd} - onDiscard={handleDiscard} - open={open} - setOpen={setOpen} + onDiscard={resetMessageValue} + open={isOpen} value={value} /> ), hasComments, - isAddingComment: open, + isAddingComment: isOpen, }), [ currentUser, count, fieldTitle, + isCreatingDataset, mentionOptions, + handleOnChange, handleClick, + handleClose, handleCommentAdd, - handleDiscard, - open, + resetMessageValue, + isOpen, value, - isCreatingDataset, hasComments, ], ) diff --git a/packages/sanity/src/structure/comments/plugin/field/CommentsFieldButton.tsx b/packages/sanity/src/structure/comments/plugin/field/CommentsFieldButton.tsx index dd2bac4bdd85..e718e61c7434 100644 --- a/packages/sanity/src/structure/comments/plugin/field/CommentsFieldButton.tsx +++ b/packages/sanity/src/structure/comments/plugin/field/CommentsFieldButton.tsx @@ -39,11 +39,11 @@ interface CommentsFieldButtonProps { mentionOptions: UserListWithPermissionsHookValue onChange: (value: PortableTextBlock[]) => void onClick?: () => void + onClose: () => void onCommentAdd: () => void onDiscard: () => void onInputKeyDown?: (event: React.KeyboardEvent) => void open: boolean - setOpen: (open: boolean) => void value: CommentMessage } @@ -56,11 +56,11 @@ export function CommentsFieldButton(props: CommentsFieldButtonProps) { mentionOptions, onChange, onClick, + onClose, onCommentAdd, onDiscard, onInputKeyDown, open, - setOpen, value, } = props const {t} = useTranslation(commentsLocaleNamespace) @@ -73,9 +73,9 @@ export function CommentsFieldButton(props: CommentsFieldButtonProps) { const closePopover = useCallback(() => { if (!open) return - setOpen(false) + onClose() addCommentButtonElement?.focus() - }, [addCommentButtonElement, open, setOpen]) + }, [addCommentButtonElement, open, onClose]) const handleSubmit = useCallback(() => { onCommentAdd() diff --git a/packages/sanity/src/structure/comments/src/context/authoring-path/CommentsAuthoringPathContext.ts b/packages/sanity/src/structure/comments/src/context/authoring-path/CommentsAuthoringPathContext.ts new file mode 100644 index 000000000000..be967daf1dd0 --- /dev/null +++ b/packages/sanity/src/structure/comments/src/context/authoring-path/CommentsAuthoringPathContext.ts @@ -0,0 +1,11 @@ +import {createContext} from 'react' + +import {type CommentsAuthoringPathContextValue} from './types' + +/** + * @beta + * @hidden + */ +export const CommentsAuthoringPathContext = createContext( + null, +) diff --git a/packages/sanity/src/structure/comments/src/context/authoring-path/CommentsAuthoringPathProvider.tsx b/packages/sanity/src/structure/comments/src/context/authoring-path/CommentsAuthoringPathProvider.tsx new file mode 100644 index 000000000000..0136a337c364 --- /dev/null +++ b/packages/sanity/src/structure/comments/src/context/authoring-path/CommentsAuthoringPathProvider.tsx @@ -0,0 +1,41 @@ +import {type ReactNode, useCallback, useMemo, useState} from 'react' + +import {CommentsAuthoringPathContext} from './CommentsAuthoringPathContext' +import {type CommentsAuthoringPathContextValue} from './types' + +interface CommentsAuthoringPathProviderProps { + children: ReactNode +} + +/** + * @beta + * @hidden + * This provider keeps track of the path that the user is currently authoring a comment for. + * This is needed to make sure that we consistently keep the editor open when the user is + * authoring a comment. The state is kept in a context to make sure that it is preserved + * across re-renders. If this state was kept in a component, it would be reset every time + * the component re-renders, for example, when the form is temporarily set to `readOnly` + * while reconnecting. + */ +export function CommentsAuthoringPathProvider(props: CommentsAuthoringPathProviderProps) { + const {children} = props + const [authoringPath, setAuthoringPath] = useState(null) + + const handleSetAuthoringPath = useCallback((nextAuthoringPath: string | null) => { + setAuthoringPath(nextAuthoringPath) + }, []) + + const value = useMemo( + (): CommentsAuthoringPathContextValue => ({ + authoringPath, + setAuthoringPath: handleSetAuthoringPath, + }), + [authoringPath, handleSetAuthoringPath], + ) + + return ( + + {children} + + ) +} diff --git a/packages/sanity/src/structure/comments/src/context/authoring-path/index.ts b/packages/sanity/src/structure/comments/src/context/authoring-path/index.ts new file mode 100644 index 000000000000..e9ae4e57df38 --- /dev/null +++ b/packages/sanity/src/structure/comments/src/context/authoring-path/index.ts @@ -0,0 +1,3 @@ +export * from './CommentsAuthoringPathContext' +export * from './CommentsAuthoringPathProvider' +export * from './types' diff --git a/packages/sanity/src/structure/comments/src/context/authoring-path/types.ts b/packages/sanity/src/structure/comments/src/context/authoring-path/types.ts new file mode 100644 index 000000000000..fe25cc8e391d --- /dev/null +++ b/packages/sanity/src/structure/comments/src/context/authoring-path/types.ts @@ -0,0 +1,8 @@ +/** + * @beta + * @hidden + */ +export interface CommentsAuthoringPathContextValue { + setAuthoringPath: (nextAuthoringPath: string | null) => void + authoringPath: string | null +} diff --git a/packages/sanity/src/structure/comments/src/context/index.ts b/packages/sanity/src/structure/comments/src/context/index.ts index 999141977686..6bf11d1b0a87 100644 --- a/packages/sanity/src/structure/comments/src/context/index.ts +++ b/packages/sanity/src/structure/comments/src/context/index.ts @@ -1,3 +1,4 @@ +export * from './authoring-path' export * from './comments' export * from './enabled' export * from './intent' diff --git a/packages/sanity/src/structure/comments/src/hooks/index.ts b/packages/sanity/src/structure/comments/src/hooks/index.ts index df3bc2a4237b..cfbd1a70da9f 100644 --- a/packages/sanity/src/structure/comments/src/hooks/index.ts +++ b/packages/sanity/src/structure/comments/src/hooks/index.ts @@ -1,5 +1,6 @@ export * from './use-comment-operations' export * from './useComments' +export * from './useCommentsAuthoringPath' export * from './useCommentsEnabled' export * from './useCommentsIntent' export * from './useCommentsOnboarding' diff --git a/packages/sanity/src/structure/comments/src/hooks/useCommentsAuthoringPath.ts b/packages/sanity/src/structure/comments/src/hooks/useCommentsAuthoringPath.ts new file mode 100644 index 000000000000..3f6256ca974f --- /dev/null +++ b/packages/sanity/src/structure/comments/src/hooks/useCommentsAuthoringPath.ts @@ -0,0 +1,17 @@ +import {useContext} from 'react' + +import {CommentsAuthoringPathContext, type CommentsAuthoringPathContextValue} from '../context' + +/** + * @beta + * @hidden + */ +export function useCommentsAuthoringPath(): CommentsAuthoringPathContextValue { + const value = useContext(CommentsAuthoringPathContext) + + if (!value) { + throw new Error('useCommentsAuthoringPath: missing context value') + } + + return value +}