diff --git a/packages/sanity/src/core/comments/components/mentions/MentionsMenu.tsx b/packages/sanity/src/core/comments/components/mentions/MentionsMenu.tsx index 3f0454e29e73..77150e5a05a5 100644 --- a/packages/sanity/src/core/comments/components/mentions/MentionsMenu.tsx +++ b/packages/sanity/src/core/comments/components/mentions/MentionsMenu.tsx @@ -1,9 +1,8 @@ -import {Box, Flex, Spinner, Stack, Text, TextInput} from '@sanity/ui' +import {Box, Flex, Spinner, Stack, Text} from '@sanity/ui' import styled from 'styled-components' -import React, {useCallback, useEffect, useMemo, useState} from 'react' -import {SearchIcon} from '@sanity/icons' +import React, {useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react' import {MentionOptionUser} from '../../types' -import {CommandList} from '../../../components' +import {CommandList, CommandListHandle} from '../../../components' import {MentionsMenuItem} from './MentionsMenuItem' const EMPTY_ARRAY: MentionOptionUser[] = [] @@ -12,11 +11,6 @@ const Root = styled(Stack)({ maxWidth: '220px', // todo: improve }) -const HeaderBox = styled(Box)({ - borderBottom: '1px solid var(--card-border-color)', - minHeight: 'max-content', -}) - const ITEM_HEIGHT = 41 const LIST_PADDING = 4 const MAX_ITEMS = 7 @@ -25,23 +19,35 @@ const FlexWrap = styled(Flex)({ maxHeight: ITEM_HEIGHT * MAX_ITEMS + LIST_PADDING * 2 + ITEM_HEIGHT / 2, }) +export interface MentionsMenuHandle { + setSearchTerm: (term: string) => void +} interface MentionsMenuProps { loading: boolean + inputElement?: HTMLDivElement | null onSelect: (userId: string) => void options: MentionOptionUser[] | null } export const MentionsMenu = React.forwardRef(function MentionsMenu( props: MentionsMenuProps, - ref: React.Ref, + ref: React.Ref, ) { - const {loading, onSelect, options = []} = props - const [inputElement, setInputElement] = useState(null) + const {loading, onSelect, options = [], inputElement} = props const [searchTerm, setSearchTerm] = useState('') - - const handleInputChange = useCallback((event: React.ChangeEvent) => { - setSearchTerm(event.target.value) - }, []) + const commandListRef = useRef(null) + + useImperativeHandle( + ref, + () => { + return { + setSearchTerm(term: string) { + setSearchTerm(term) + }, + } + }, + [], + ) const renderItem = useCallback( (itemProps: MentionOptionUser) => { @@ -67,14 +73,6 @@ export const MentionsMenu = React.forwardRef(function MentionsMenu( return filtered || EMPTY_ARRAY }, [options, searchTerm]) - useEffect(() => { - const timeout = setTimeout(() => inputElement?.focus(), 0) - - return () => { - clearTimeout(timeout) - } - }, [inputElement]) - if (loading) { return ( @@ -85,19 +83,12 @@ export const MentionsMenu = React.forwardRef(function MentionsMenu( ) } - return ( - - - - + // In this case the input element is the actual content editable HTMLDivElement from the PTE. + // Typecast it to an input element to make the CommandList component happy. + const _inputElement = inputElement ? (inputElement as HTMLInputElement) : undefined + return ( + {filteredOptions.length === 0 && ( @@ -111,13 +102,13 @@ export const MentionsMenu = React.forwardRef(function MentionsMenu( diff --git a/packages/sanity/src/core/comments/components/pte/comment-input/CommentInputInner.tsx b/packages/sanity/src/core/comments/components/pte/comment-input/CommentInputInner.tsx index 673db7d0d3d8..f03ab2af6034 100644 --- a/packages/sanity/src/core/comments/components/pte/comment-input/CommentInputInner.tsx +++ b/packages/sanity/src/core/comments/components/pte/comment-input/CommentInputInner.tsx @@ -87,7 +87,8 @@ export function CommentInputInner(props: CommentInputInnerProps) { const [discardButtonElement, setDiscardButtonElement] = useState(null) const [user] = useUser(currentUser.id) - const {openMentions, focused, expandOnFocus, canSubmit, hasChanges} = useCommentInput() + const {openMentions, focused, expandOnFocus, canSubmit, hasChanges, insertAtChar} = + useCommentInput() const avatar = withAvatar ? : null @@ -96,6 +97,11 @@ export function CommentInputInner(props: CommentInputInnerProps) { discardButtonElement?.blur() }, [discardButtonElement, onEditDiscard]) + const handleMentionButtonClicked = useCallback(() => { + insertAtChar() + openMentions() + }, [insertAtChar, openMentions]) + return ( {avatar} @@ -121,7 +127,7 @@ export function CommentInputInner(props: CommentInputInnerProps) { aria-label="Mention user" icon={MentionIcon} mode="bleed" - onClick={openMentions} + onClick={handleMentionButtonClicked} /> diff --git a/packages/sanity/src/core/comments/components/pte/comment-input/CommentInputProvider.tsx b/packages/sanity/src/core/comments/components/pte/comment-input/CommentInputProvider.tsx index cb14124ed133..e66d4ecfd9da 100644 --- a/packages/sanity/src/core/comments/components/pte/comment-input/CommentInputProvider.tsx +++ b/packages/sanity/src/core/comments/components/pte/comment-input/CommentInputProvider.tsx @@ -1,21 +1,16 @@ import { EditorSelection, - OnBeforeInputFn, PortableTextEditor, usePortableTextEditor, } from '@sanity/portable-text-editor' -import React, {FormEventHandler, startTransition, useCallback, useMemo, useState} from 'react' -import {Path, isPortableTextSpan, isPortableTextTextBlock} from '@sanity/types' +import React, {useCallback, useMemo, useState} from 'react' +import {Path, isKeySegment, isPortableTextSpan, isPortableTextTextBlock} from '@sanity/types' import {CommentMessage} from '../../../types' import {useDidUpdate} from '../../../../form' import {useCommentHasChanged} from '../../../helpers' import {MentionOptionsHookValue} from '../../../hooks' -import {getCaretElement} from './getCaretElement' - -type FIXME = any export interface CommentInputContextValue { - activeCaretElement?: HTMLElement | null canSubmit?: boolean closeMentions: () => void editor: PortableTextEditor @@ -25,11 +20,13 @@ export interface CommentInputContextValue { focusEditor: () => void focusOnMount?: boolean hasChanges: boolean + insertAtChar: () => void insertMention: (userId: string) => void mentionOptions: MentionOptionsHookValue mentionsMenuOpen: boolean - onBeforeInput: OnBeforeInputFn & FormEventHandler + onBeforeInput: (event: InputEvent) => void openMentions: () => void + mentionsSearchTerm: string value: CommentMessage } @@ -60,124 +57,154 @@ export function CommentInputProvider(props: CommentInputProviderProps) { const editor = usePortableTextEditor() - const [activeCaretElement, setActiveCaretElement] = useState(null) const [mentionsMenuOpen, setMentionsMenuOpen] = useState(false) + const [mentionsSearchTerm, setMentionsSearchTerm] = useState('') const [selectionAtMentionInsert, setSelectionAtMentionInsert] = useState(null) - // todo const canSubmit = useMemo(() => { if (!value) return false return value?.some( - (block: FIXME) => (block?.children || [])?.some((c: FIXME) => c.text || c.userId), + (block) => + isPortableTextTextBlock(block) && + (block?.children || [])?.some((c) => (isPortableTextSpan(c) ? c.text : c.userId)), ) }, [value]) const hasChanges = useCommentHasChanged(value) - const focusLastBlock = useCallback(() => { - const block = PortableTextEditor.focusBlock(editor) + const focusEditor = useCallback(() => PortableTextEditor.focus(editor), [editor]) - try { - PortableTextEditor.focus(editor) - } catch (_) { - // ... - } + const closeMentions = useCallback(() => { + setMentionsMenuOpen(false) + setMentionsSearchTerm('') + setSelectionAtMentionInsert(null) + focusEditor() + }, [focusEditor]) - if (block && isPortableTextTextBlock(block)) { - const lastChildKey = block.children.slice(-1)[0]?._key + const openMentions = useCallback(() => { + setMentionsMenuOpen(true) + setMentionsSearchTerm('') + setMentionsMenuOpen(true) + setSelectionAtMentionInsert(PortableTextEditor.getSelection(editor)) + focusEditor() + }, [focusEditor, editor]) - if (lastChildKey) { - const path: Path = [{_key: block._key}, 'children', {_key: lastChildKey}] - const sel: EditorSelection = { - anchor: {path, offset: 0}, - focus: {path, offset: 0}, - } - PortableTextEditor.select(editor, sel) - } - } - }, [editor]) + // This function activates or deactivates the mentions menu and updates + // the mention search term when the user types into the Portable Text Editor. + const onBeforeInput = useCallback( + (event: InputEvent): void => { + const selection = PortableTextEditor.getSelection(editor) + const cursorOffset = selection ? selection.focus.offset : 0 + const focusChild = PortableTextEditor.focusChild(editor) + const focusSpan = (isPortableTextSpan(focusChild) && focusChild) || undefined - const focusEditor = useCallback(() => setTimeout(() => focusLastBlock(), 0), [focusLastBlock]) + const isInsertText = event.inputType === 'insertText' + const isDeleteText = event.inputType === 'deleteContentBackward' + const isInsertingAtChar = isInsertText && event.data === '@' - const onBeforeInput = useCallback( - (event: FIXME): void => { - if (event.inputType === 'insertText' && event.data === '@') { - const element = getCaretElement(event.target) - setActiveCaretElement(element) - startTransition(() => setMentionsMenuOpen(true)) + const lastIndexOfAt = focusSpan?.text.lastIndexOf('@') || 0 + + const isWhitespaceCharBeforeCursorPosition = + focusSpan?.text.substring(cursorOffset - 1, cursorOffset) === ' ' + + const filterStartsWithSpaceChar = isInsertText && event.data === ' ' && !mentionsSearchTerm + + // If we are inserting a '@' character - open the mentions menu and reset the search term. + // Only do this if it is in the start of the text, or if '@' is inserted when following a whitespace char. + if (isInsertingAtChar && (cursorOffset < 1 || isWhitespaceCharBeforeCursorPosition)) { + openMentions() + return + } + + // If the user begins typing their filter with a space, or if they are deleting + // characters after activation and the '@' is no longer there, + // clear the search term and close the mentions menu. + if ( + filterStartsWithSpaceChar || + (isDeleteText && + (focusSpan?.text.length === 1 || lastIndexOfAt === (focusSpan?.text.length || 0) - 1)) + ) { + closeMentions() + return + } - setSelectionAtMentionInsert(PortableTextEditor.getSelection(editor)) + // Update the search term + if (isPortableTextSpan(focusChild)) { + // Term starts with the @ char in the value until the cursor offset + let term = focusChild.text.substring(lastIndexOfAt + 1, cursorOffset) + // Add the char to the mentions search term + if (isInsertText) { + term += event.data + } + // Exclude the char from the mentions search term + if (isDeleteText) { + term = term.substring(0, term.length - 1) + } + // Set the updated mentions search term + setMentionsSearchTerm(term) } }, - [editor], + [closeMentions, editor, mentionsSearchTerm, openMentions], ) - const closeMentions = useCallback(() => { - if (!mentionsMenuOpen) return - setActiveCaretElement(null) - setMentionsMenuOpen(false) - focusEditor() - setSelectionAtMentionInsert(null) - }, [focusEditor, mentionsMenuOpen]) - - // TODO: check that the editor is focused before opening mentions so that the - // menu is positioned correctly in the editor - const openMentions = useCallback(() => { - focusEditor() - setTimeout(() => setMentionsMenuOpen(true), 0) - }, [focusEditor]) + const insertAtChar = useCallback(() => { + setMentionsMenuOpen(true) + PortableTextEditor.insertChild(editor, editor.schemaTypes.span, {text: '@'}) + setSelectionAtMentionInsert(PortableTextEditor.getSelection(editor)) + }, [editor]) useDidUpdate(mentionsMenuOpen, () => onMentionMenuOpenChange?.(mentionsMenuOpen)) const insertMention = useCallback( (userId: string) => { const mentionSchemaType = editor.schemaTypes.inlineObjects.find((t) => t.name === 'mention') - const spanSchemaType = editor.schemaTypes.span + let mentionPath: Path | undefined const [span, spanPath] = (selectionAtMentionInsert && PortableTextEditor.findByPath(editor, selectionAtMentionInsert.focus.path)) || [] - if (span && isPortableTextSpan(span) && spanPath && mentionSchemaType) { - PortableTextEditor.delete(editor, { - focus: {path: spanPath, offset: 0}, - anchor: {path: spanPath, offset: span.text.length}, - }) - - PortableTextEditor.insertChild(editor, spanSchemaType, { - ...span, - text: span.text.substring(0, span.text.length - 1), - }) - - PortableTextEditor.insertChild(editor, mentionSchemaType, { - _type: 'mention', - userId: userId, - }) + PortableTextEditor.focus(editor) + const offset = PortableTextEditor.getSelection(editor)?.focus.offset + if (typeof offset !== 'undefined') { + PortableTextEditor.delete( + editor, + { + anchor: {path: spanPath, offset: span.text.lastIndexOf('@')}, + focus: {path: spanPath, offset}, + }, + {mode: 'selected'}, + ) + mentionPath = PortableTextEditor.insertChild(editor, mentionSchemaType, { + _type: 'mention', + userId: userId, + }) + } const focusBlock = PortableTextEditor.focusBlock(editor) - if (focusBlock && isPortableTextTextBlock(focusBlock)) { - const lastChildKey = focusBlock.children.slice(-1)[0]?._key - - if (lastChildKey) { - const path: Path = [{_key: focusBlock._key}, 'children', {_key: lastChildKey}] + // Set the focus on the next text node after the mention object + if (focusBlock && isPortableTextTextBlock(focusBlock) && mentionPath) { + const mentionKeyPathSegment = mentionPath?.slice(-1)[0] + const nextChildKey = + focusBlock.children[ + focusBlock.children.findIndex( + (c) => isKeySegment(mentionKeyPathSegment) && c._key === mentionKeyPathSegment._key, + ) + 1 + ]?._key + + if (nextChildKey) { + const path: Path = [{_key: focusBlock._key}, 'children', {_key: nextChildKey}] const sel: EditorSelection = { anchor: {path, offset: 0}, focus: {path, offset: 0}, } PortableTextEditor.select(editor, sel) + PortableTextEditor.focus(editor) } } - - // todo: improve - // This is needed when the user clicks the mention button in the toolbar - } else if (mentionSchemaType) { - PortableTextEditor.insertChild(editor, mentionSchemaType, { - _type: 'mention', - userId: userId, - }) } closeMentions() @@ -188,7 +215,6 @@ export function CommentInputProvider(props: CommentInputProviderProps) { const ctxValue = useMemo( () => ({ - activeCaretElement, canSubmit, closeMentions, editor, @@ -198,15 +224,16 @@ export function CommentInputProvider(props: CommentInputProviderProps) { focusEditor, focusOnMount, hasChanges, + insertAtChar, insertMention, + mentionOptions, mentionsMenuOpen, + mentionsSearchTerm, onBeforeInput, openMentions, value, - mentionOptions, }) satisfies CommentInputContextValue, [ - activeCaretElement, canSubmit, closeMentions, editor, @@ -216,8 +243,10 @@ export function CommentInputProvider(props: CommentInputProviderProps) { focusEditor, focusOnMount, hasChanges, + insertAtChar, insertMention, mentionsMenuOpen, + mentionsSearchTerm, onBeforeInput, openMentions, value, diff --git a/packages/sanity/src/core/comments/components/pte/comment-input/Editable.tsx b/packages/sanity/src/core/comments/components/pte/comment-input/Editable.tsx index 0458dc6dbc9c..69d13a3b99d6 100644 --- a/packages/sanity/src/core/comments/components/pte/comment-input/Editable.tsx +++ b/packages/sanity/src/core/comments/components/pte/comment-input/Editable.tsx @@ -1,11 +1,13 @@ -import React, {useCallback, useRef, useState} from 'react' +import React, {KeyboardEvent, useCallback, useEffect, useRef, useState} from 'react' import {Popover, PortalProvider, Stack, useClickOutside, useGlobalKeyDown} from '@sanity/ui' -import {PortableTextEditable} from '@sanity/portable-text-editor' +import {PortableTextEditable, usePortableTextEditorSelection} from '@sanity/portable-text-editor' import styled, {css} from 'styled-components' -import {MentionsMenu} from '../../mentions' +import {isEqual} from 'lodash' +import {MentionsMenu, MentionsMenuHandle} from '../../mentions' import {useDidUpdate} from '../../../../form' import {renderBlock, renderChild} from '../render' import {useCommentInput} from './useCommentInput' +import {useCursorElement} from './useCursorElement' const INLINE_STYLE: React.CSSProperties = {outline: 'none'} @@ -22,7 +24,7 @@ const EditableWrapStack = styled(Stack)(() => { export const StyledPopover = styled(Popover)(() => { return css` // Position the Popover relative to the @ - transform: translateX(6px); // todo: improve + transform: translate(6px, 6px); // todo: improve [data-ui='Popover__wrapper'] { border-radius: ${({theme}) => theme.sanity.radius[3]}px; @@ -50,9 +52,10 @@ export function Editable(props: EditableProps) { const [popoverElement, setPopoverElement] = useState(null) const rootElementRef = useRef(null) const editableRef = useRef(null) + const mentionsMenuRef = useRef(null) + const selection = usePortableTextEditorSelection() const { - activeCaretElement, closeMentions, expanded, focusEditor, @@ -60,15 +63,12 @@ export function Editable(props: EditableProps) { insertMention, mentionOptions, mentionsMenuOpen, + mentionsSearchTerm, onBeforeInput, } = useCommentInput() const renderPlaceholder = useCallback(() => {placeholder}, [placeholder]) - useGlobalKeyDown( - useCallback((event) => event.key === 'Escape' && closeMentions(), [closeMentions]), - ) - useClickOutside( useCallback(() => { if (mentionsMenuOpen) { @@ -90,31 +90,80 @@ export function Editable(props: EditableProps) { } }) + useEffect(() => { + mentionsMenuRef.current?.setSearchTerm(mentionsSearchTerm) + }, [mentionsSearchTerm]) + + // Close mentions if the user selects text + useEffect(() => { + if (mentionsMenuOpen && selection && !isEqual(selection.anchor, selection.focus)) { + closeMentions() + } + }, [mentionsMenuOpen, closeMentions, selection]) + + // Close mentions if the menu itself has focus and user press Escape + useGlobalKeyDown((event) => { + if (event.code === 'Escape' && mentionsMenuOpen) { + closeMentions() + } + }) + + const cursorElement = useCursorElement({ + disabled: !mentionsMenuOpen, + rootElement: rootElementRef.current, + }) + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + switch (event.code) { + case 'Enter': + if (mentionsMenuOpen) { + event.preventDefault() + event.stopPropagation() + } + break + case 'Escape': + case 'ArrowLeft': + case 'ArrowRight': + if (mentionsMenuOpen) { + closeMentions() + focusEditor() + } + break + default: + } + }, + [closeMentions, focusEditor, mentionsMenuOpen], + ) + return ( <> } open={mentionsMenuOpen} - placement="bottom-start" + placement="bottom-end" portal ref={setPopoverElement} - referenceElement={activeCaretElement} + referenceElement={cursorElement} /> - (null) + + const cursorElement = useMemo(() => { + if (!cursorRect) { + return null + } + return { + getBoundingClientRect: () => { + return cursorRect + }, + } as HTMLElement + }, [cursorRect]) + + const handleSelectionChange = useCallback(() => { + if (disabled) { + setCursorRect(null) + return + } + + const sel = window.getSelection() + + if (!sel || !sel.isCollapsed || sel.rangeCount === 0) return + + const range = sel.getRangeAt(0) + const isWithinRoot = rootElement?.contains(range.commonAncestorContainer) + + if (!isWithinRoot) { + setCursorRect(null) + return + } + const rect = range?.getBoundingClientRect() + if (rect) { + setCursorRect(rect) + } + }, [disabled, rootElement]) + + useEffect(() => { + document.addEventListener('selectionchange', handleSelectionChange, EVENT_LISTENER_OPTIONS) + + return () => { + document.removeEventListener('selectionchange', handleSelectionChange) + } + }, [handleSelectionChange]) + + return cursorElement +}