From a766a38e1f370cee092b2b110e4d543b3b9238db Mon Sep 17 00:00:00 2001 From: Per-Kristian Nordnes Date: Fri, 15 Sep 2023 17:38:04 +0200 Subject: [PATCH] refactor(comments): inline text filtering for mentions menu --- .../components/mentions/MentionsMenu.tsx | 66 ++++++------- .../pte/comment-input/CommentInputInner.tsx | 12 ++- .../comment-input/CommentInputProvider.tsx | 98 +++++++++++-------- .../components/pte/comment-input/Editable.tsx | 79 ++++++++++++--- .../pte/comment-input/useCursorElement.ts | 43 ++------ 5 files changed, 165 insertions(+), 133 deletions(-) diff --git a/packages/sanity/src/core/comments/components/mentions/MentionsMenu.tsx b/packages/sanity/src/core/comments/components/mentions/MentionsMenu.tsx index 3f0454e29e73..8e8871848398 100644 --- a/packages/sanity/src/core/comments/components/mentions/MentionsMenu.tsx +++ b/packages/sanity/src/core/comments/components/mentions/MentionsMenu.tsx @@ -1,9 +1,9 @@ -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 {FIXME} from '../../../FIXME' import {MentionsMenuItem} from './MentionsMenuItem' const EMPTY_ARRAY: MentionOptionUser[] = [] @@ -12,11 +12,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 +20,39 @@ const FlexWrap = styled(Flex)({ maxHeight: ITEM_HEIGHT * MAX_ITEMS + LIST_PADDING * 2 + ITEM_HEIGHT / 2, }) +export interface MentionsMenuHandle { + selectCurrent: () => void + 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 { + selectCurrent() { + commandListRef.current?.selectCurrent() + }, + setSearchTerm(term: string) { + setSearchTerm(term) + }, + } + }, + [commandListRef], + ) const renderItem = useCallback( (itemProps: MentionOptionUser) => { @@ -67,14 +78,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 ( @@ -87,17 +90,6 @@ export const MentionsMenu = React.forwardRef(function MentionsMenu( return ( - - - - {filteredOptions.length === 0 && ( @@ -111,13 +103,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 eb20a91e1f89..d96b79aa6f21 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 @@ -1,4 +1,4 @@ -import React, {useCallback, useRef, useState} from 'react' +import React, {useCallback, useState} from 'react' import {Avatar, Flex, Button, MenuDivider, Box, Card, Stack} from '@sanity/ui' import styled, {css} from 'styled-components' import {CurrentUser} from '@sanity/types' @@ -80,7 +80,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 = user ? : @@ -89,6 +90,11 @@ export function CommentInputInner(props: CommentInputInnerProps) { discardButtonElement?.blur() }, [discardButtonElement, onEditDiscard]) + const handleMentionButtonClicked = useCallback(() => { + insertAtChar() + openMentions() + }, [insertAtChar, openMentions]) + return ( {withAvatar ? avatar : null} @@ -114,7 +120,7 @@ export function CommentInputInner(props: CommentInputInnerProps) { fontSize={1} icon={MentionIcon} mode="bleed" - onClick={openMentions} + onClick={handleMentionButtonClicked} padding={2} /> 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 aa2f715bcbaa..9e3a714a43cf 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 @@ -23,11 +23,13 @@ export interface CommentInputContextValue { focusEditor: () => void focusOnMount?: boolean hasChanges: boolean + insertAtChar: () => void insertMention: (userId: string) => void mentionOptions: MentionOptionsHookValue mentionsMenuOpen: boolean onBeforeInput: OnBeforeInputFn & FormEventHandler openMentions: () => void + mentionsSearchTerm: string value: CommentMessage } @@ -59,6 +61,7 @@ export function CommentInputProvider(props: CommentInputProviderProps) { const editor = usePortableTextEditor() const [mentionsMenuOpen, setMentionsMenuOpen] = useState(false) + const [mentionsSearchTerm, setMentionsSearchTerm] = useState('') const [selectionAtMentionInsert, setSelectionAtMentionInsert] = useState(null) // todo @@ -72,61 +75,68 @@ export function CommentInputProvider(props: CommentInputProviderProps) { const hasChanges = useCommentHasChanged(value) - const focusLastBlock = useCallback(() => { - const block = PortableTextEditor.focusBlock(editor) - - try { - PortableTextEditor.focus(editor) - } catch (_) { - // ... - } - - if (block && isPortableTextTextBlock(block)) { - const lastChildKey = block.children.slice(-1)[0]?._key - - 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]) - - const focusEditor = useCallback(() => setTimeout(() => focusLastBlock(), 0), [focusLastBlock]) + const focusEditor = useCallback(() => PortableTextEditor.focus(editor), [editor]) const onBeforeInput = useCallback( (event: FIXME): void => { - if (event.inputType === 'insertText' && event.data === '@') { + const isInsertText = event.inputType === 'insertText' + const isInsertingAtChar = isInsertText && event.data === '@' + const focusChild = PortableTextEditor.focusChild(editor) + const focusSpan = (isPortableTextSpan(focusChild) && focusChild) || undefined + const lastIndexOfAt = focusSpan?.text.lastIndexOf('@') || -1 + + // If we are inserting a @ char open the mentions menu and reset the search term + if (isInsertingAtChar) { + setMentionsSearchTerm('') setMentionsMenuOpen(true) setSelectionAtMentionInsert(PortableTextEditor.getSelection(editor)) + return + } + + // If we are deleting text and the '@' is no longer there, + // clear the search term and close the mentions menu. + if ( + event.inputType === 'deleteContentBackward' && + (focusSpan?.text.length === 1 || lastIndexOfAt === (focusSpan?.text.length || 0) - 1) + ) { + setMentionsSearchTerm('') + setMentionsMenuOpen(false) + return + } + + // Update the search term + if (isPortableTextSpan(focusChild)) { + let term = focusChild.text.split('@').slice(-1)[0] + // Add the char which has not made it to the span value yet to the term + if (isInsertText) { + term += event.data + } + setMentionsSearchTerm(term) } }, [editor], ) const closeMentions = useCallback(() => { - if (!mentionsMenuOpen) return setMentionsMenuOpen(false) focusEditor() - setSelectionAtMentionInsert(null) - }, [focusEditor, mentionsMenuOpen]) + }, [focusEditor]) - // TODO: check that the editor is focused before opening mentions so that the - // menu is positioned correctly in the editor const openMentions = useCallback(() => { + setMentionsMenuOpen(true) focusEditor() - setTimeout(() => setMentionsMenuOpen(true), 0) }, [focusEditor]) + const insertAtChar = useCallback(() => { + setMentionsMenuOpen(true) + PortableTextEditor.insertChild(editor, editor.schemaTypes.span, {text: '@'}) + }, [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 const [span, spanPath] = (selectionAtMentionInsert && @@ -134,15 +144,14 @@ export function CommentInputProvider(props: CommentInputProviderProps) { [] 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.delete( + editor, + { + anchor: {path: spanPath, offset: span.text.lastIndexOf('@')}, + focus: {path: spanPath, offset: span.text.length}, + }, + {mode: 'selected'}, + ) PortableTextEditor.insertChild(editor, mentionSchemaType, { _type: 'mention', @@ -161,6 +170,7 @@ export function CommentInputProvider(props: CommentInputProviderProps) { focus: {path, offset: 0}, } PortableTextEditor.select(editor, sel) + PortableTextEditor.focus(editor) } } @@ -190,12 +200,14 @@ export function CommentInputProvider(props: CommentInputProviderProps) { focusEditor, focusOnMount, hasChanges, + insertAtChar, insertMention, + mentionOptions, mentionsMenuOpen, + mentionsSearchTerm, onBeforeInput, openMentions, value, - mentionOptions, }) satisfies CommentInputContextValue, [ canSubmit, @@ -207,8 +219,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 f042b4fe0ea0..b8b9c5fdb26b 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,8 +1,9 @@ -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' @@ -23,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; @@ -51,6 +52,8 @@ 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 { closeMentions, @@ -60,20 +63,12 @@ export function Editable(props: EditableProps) { insertMention, mentionOptions, mentionsMenuOpen, + mentionsSearchTerm, onBeforeInput, } = useCommentInput() - const cursorElement = useCursorElement({ - disabled: mentionsMenuOpen, - rootElement: editableRef.current, - }) - const renderPlaceholder = useCallback(() => {placeholder}, [placeholder]) - useGlobalKeyDown( - useCallback((event) => event.key === 'Escape' && closeMentions(), [closeMentions]), - ) - useClickOutside( useCallback(() => { if (mentionsMenuOpen) { @@ -95,31 +90,85 @@ export function Editable(props: EditableProps) { } }) + const selectCurrentMentionMenuItem = useCallback(() => { + mentionsMenuRef.current?.selectCurrent() + }, []) + + 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() + selectCurrentMentionMenuItem() + } + break + case 'Escape': + case 'ArrowLeft': + case 'ArrowRight': + if (mentionsMenuOpen) { + closeMentions() + focusEditor() + } + break + default: + } + }, + [closeMentions, focusEditor, mentionsMenuOpen, selectCurrentMentionMenuItem], + ) + return ( <> - (null) - const [selection, setSelection] = useState<{ - anchorNode: Node | null - anchorOffset: number - focusNode: Node | null - focusOffset: number - } | null>(null) const cursorElement = useMemo(() => { if (!cursorRect) { @@ -30,49 +24,26 @@ export function useCursorElement(opts: CursorElementHookOptions): HTMLElement | const handleSelectionChange = useCallback(() => { if (disabled) { - setSelection(null) + setCursorRect(null) return } const sel = window.getSelection() - if (!sel || !sel.isCollapsed) return + if (!sel || !sel.isCollapsed || sel.rangeCount === 0) return const range = sel.getRangeAt(0) const isWithinRoot = rootElement?.contains(range.commonAncestorContainer) - if (!isWithinRoot) return - - const {anchorNode, anchorOffset, focusNode, focusOffset} = sel - setSelection({ - anchorNode, - anchorOffset, - focusNode, - focusOffset, - }) - }, [disabled, rootElement]) - - useEffect(() => { - if (!selection || disabled) { + if (!isWithinRoot) { setCursorRect(null) return } - - const {anchorNode, focusNode} = selection - - if (rootElement?.contains(anchorNode) && anchorNode === focusNode) { - try { - const range = window.getSelection()?.getRangeAt(0) - const rect = range?.getBoundingClientRect() - - if (rect) { - setCursorRect(rect) - } - } catch (_) { - setCursorRect(null) - } + const rect = range?.getBoundingClientRect() + if (rect) { + setCursorRect(rect) } - }, [disabled, rootElement, selection]) + }, [disabled, rootElement]) useEffect(() => { document.addEventListener('selectionchange', handleSelectionChange, EVENT_LISTENER_OPTIONS)