From cd5f0d05063ceab296a3b22397af2567cc031899 Mon Sep 17 00:00:00 2001 From: Per-Kristian Nordnes Date: Fri, 15 Sep 2023 11:11:40 +0200 Subject: [PATCH 1/5] fix(portable-text-editor): allow overriding onKeyDown, onFocus, onBlur for Editable These props should be able to be overridden and potentially stopped before performing our internal behaviour. --- .../src/editor/Editable.tsx | 50 +++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/packages/@sanity/portable-text-editor/src/editor/Editable.tsx b/packages/@sanity/portable-text-editor/src/editor/Editable.tsx index e8292fdc256..8c82c3b032c 100644 --- a/packages/@sanity/portable-text-editor/src/editor/Editable.tsx +++ b/packages/@sanity/portable-text-editor/src/editor/Editable.tsx @@ -1,5 +1,5 @@ import {BaseRange, Transforms, Text} from 'slate' -import React, {useCallback, useMemo, useEffect, forwardRef, useState} from 'react' +import React, {useCallback, useMemo, useEffect, forwardRef, useState, KeyboardEvent} from 'react' import { Editable as SlateEditable, ReactEditor, @@ -81,6 +81,8 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable( ) { const { hotkeys, + onBlur, + onFocus, onBeforeInput, onPaste, onCopy, @@ -294,25 +296,35 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable( const handleOnFocus: React.FocusEventHandler = useCallback( (event) => { - const selection = PortableTextEditor.getSelection(portableTextEditor) - change$.next({type: 'focus', event}) - const newSelection = PortableTextEditor.getSelection(portableTextEditor) - // If the selection is the same, emit it explicitly here as there is no actual onChange event triggered. - if (selection === newSelection) { - change$.next({ - type: 'selection', - selection, - }) + if (onFocus) { + onFocus(event) + } + if (!event.isDefaultPrevented()) { + const selection = PortableTextEditor.getSelection(portableTextEditor) + change$.next({type: 'focus', event}) + const newSelection = PortableTextEditor.getSelection(portableTextEditor) + // If the selection is the same, emit it explicitly here as there is no actual onChange event triggered. + if (selection === newSelection) { + change$.next({ + type: 'selection', + selection, + }) + } } }, - [change$, portableTextEditor], + [change$, portableTextEditor, onFocus], ) const handleOnBlur: React.FocusEventHandler = useCallback( (event) => { - change$.next({type: 'blur', event}) + if (onBlur) { + onBlur(event) + } + if (!event.isPropagationStopped()) { + change$.next({type: 'blur', event}) + } }, - [change$], + [change$, onBlur], ) const handleOnBeforeInput = useCallback( @@ -354,7 +366,17 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable( } }, [handleDOMChange, ref]) - const handleKeyDown = slateEditor.pteWithHotKeys + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (props.onKeyDown) { + props.onKeyDown(event) + } + if (!event.isDefaultPrevented()) { + slateEditor.pteWithHotKeys(event) + } + }, + [props, slateEditor], + ) const scrollSelectionIntoViewToSlate = useMemo(() => { // Use slate-react default scroll into view From b436358b48a8df12f2aa77be20d6eea59ec50b46 Mon Sep 17 00:00:00 2001 From: Per-Kristian Nordnes Date: Tue, 26 Sep 2023 13:59:54 +0200 Subject: [PATCH 2/5] fix(portable-text-editor): specify InputEvent event type for TS-type --- .../portable-text-editor/src/editor/Editable.tsx | 10 +++++----- .../@sanity/portable-text-editor/src/types/editor.ts | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/@sanity/portable-text-editor/src/editor/Editable.tsx b/packages/@sanity/portable-text-editor/src/editor/Editable.tsx index 8c82c3b032c..ea3b7f5801f 100644 --- a/packages/@sanity/portable-text-editor/src/editor/Editable.tsx +++ b/packages/@sanity/portable-text-editor/src/editor/Editable.tsx @@ -12,7 +12,6 @@ import {PortableTextBlock} from '@sanity/types' import { EditorChange, EditorSelection, - OnBeforeInputFn, OnCopyFn, OnPasteFn, OnPasteResult, @@ -54,10 +53,10 @@ const EMPTY_DECORATORS: BaseRange[] = [] */ export type PortableTextEditableProps = Omit< React.TextareaHTMLAttributes, - 'onPaste' | 'onCopy' + 'onPaste' | 'onCopy' | 'onBeforeInput' > & { hotkeys?: HotkeyOptions - onBeforeInput?: OnBeforeInputFn + onBeforeInput?: (event: InputEvent) => void onPaste?: OnPasteFn onCopy?: OnCopyFn renderAnnotation?: RenderAnnotationFunction @@ -76,7 +75,8 @@ export type PortableTextEditableProps = Omit< * @public */ export const PortableTextEditable = forwardRef(function PortableTextEditable( - props: PortableTextEditableProps & Omit, 'as' | 'onPaste'>, + props: PortableTextEditableProps & + Omit, 'as' | 'onPaste' | 'onBeforeInput'>, forwardedRef: React.ForwardedRef, ) { const { @@ -328,7 +328,7 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable( ) const handleOnBeforeInput = useCallback( - (event: Event) => { + (event: InputEvent) => { if (onBeforeInput) { onBeforeInput(event) } diff --git a/packages/@sanity/portable-text-editor/src/types/editor.ts b/packages/@sanity/portable-text-editor/src/types/editor.ts index 25e3484d87d..c76f1563ba1 100644 --- a/packages/@sanity/portable-text-editor/src/types/editor.ts +++ b/packages/@sanity/portable-text-editor/src/types/editor.ts @@ -18,7 +18,7 @@ import { import {Subject, Observable} from 'rxjs' import {Descendant, Node as SlateNode, Operation as SlateOperation} from 'slate' import {ReactEditor} from 'slate-react' -import {FocusEvent} from 'react' +import {FocusEvent, FormEventHandler} from 'react' import type {Patch} from '../types/patch' import {PortableTextEditor} from '../editor/PortableTextEditor' @@ -362,7 +362,7 @@ export interface PasteData { export type OnPasteFn = (data: PasteData) => OnPasteResultOrPromise /** @beta */ -export type OnBeforeInputFn = (event: Event) => void +export type OnBeforeInputFn = (event: InputEvent) => void /** @beta */ export type OnCopyFn = ( From 9478e0b8a3071a51b4ffe16332b8c4b92d795321 Mon Sep 17 00:00:00 2001 From: Per-Kristian Nordnes Date: Fri, 15 Sep 2023 17:38:04 +0200 Subject: [PATCH 3/5] refactor(comments): inline text filtering for mentions menu --- .../components/mentions/MentionsMenu.tsx | 67 +++--- .../pte/comment-input/CommentInputInner.tsx | 10 +- .../comment-input/CommentInputProvider.tsx | 199 ++++++++++-------- .../components/pte/comment-input/Editable.tsx | 74 +++++-- .../pte/comment-input/getCaretElement.ts | 29 --- .../pte/comment-input/useCursorElement.ts | 57 +++++ 6 files changed, 270 insertions(+), 166 deletions(-) delete mode 100644 packages/sanity/src/core/comments/components/pte/comment-input/getCaretElement.ts create mode 100644 packages/sanity/src/core/comments/components/pte/comment-input/useCursorElement.ts diff --git a/packages/sanity/src/core/comments/components/mentions/MentionsMenu.tsx b/packages/sanity/src/core/comments/components/mentions/MentionsMenu.tsx index 3f0454e29e7..77150e5a05a 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 673db7d0d3d..f03ab2af603 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 cb14124ed13..d83eb79aba6 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.substring(0, cursorOffset).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 0458dc6dbc9..37f1e24d551 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,81 @@ export function Editable(props: EditableProps) { } }) + // Update the mentions search term in the mentions menu + 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} /> - rect, - } as HTMLElement - return element - } - } catch (_) { - return null - } - } - - return null -} diff --git a/packages/sanity/src/core/comments/components/pte/comment-input/useCursorElement.ts b/packages/sanity/src/core/comments/components/pte/comment-input/useCursorElement.ts new file mode 100644 index 00000000000..5970a8fdef0 --- /dev/null +++ b/packages/sanity/src/core/comments/components/pte/comment-input/useCursorElement.ts @@ -0,0 +1,57 @@ +import {useState, useMemo, useEffect, useCallback} from 'react' + +const EVENT_LISTENER_OPTIONS: AddEventListenerOptions = {passive: true} + +interface CursorElementHookOptions { + disabled: boolean + rootElement: HTMLElement | null +} + +export function useCursorElement(opts: CursorElementHookOptions): HTMLElement | null { + const {disabled, rootElement} = opts + const [cursorRect, setCursorRect] = useState(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 +} From 3532db9661ea99167b369816233e425fbb11fecd Mon Sep 17 00:00:00 2001 From: Per-Kristian Nordnes Date: Wed, 27 Sep 2023 16:17:15 +0200 Subject: [PATCH 4/5] refactor(comments): remove unused 'expanded' context This is no longer in use it seems like. Removing for simplicity. --- .../components/pte/comment-input/CommentInputProvider.tsx | 5 ----- .../comments/components/pte/comment-input/Editable.tsx | 7 ------- 2 files changed, 12 deletions(-) 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 d83eb79aba6..1848c12284c 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 @@ -14,7 +14,6 @@ export interface CommentInputContextValue { canSubmit?: boolean closeMentions: () => void editor: PortableTextEditor - expanded?: boolean expandOnFocus?: boolean focused: boolean focusEditor: () => void @@ -34,7 +33,6 @@ export const CommentInputContext = React.createContext { - if (expanded) { - focusEditor() - } - }) - useDidUpdate(focusOnMount, () => { if (focusOnMount) { focusEditor() From e543e5a10612628bdd816f066a194334bb012be7 Mon Sep 17 00:00:00 2001 From: Per-Kristian Nordnes Date: Wed, 27 Sep 2023 16:19:36 +0200 Subject: [PATCH 5/5] refactor(comments): introduce focus to end of content as explicit fn Make a explicit function to focus the editor at the end of the edited comment content as focusEditor fn is now used internally in the context provider. Also simplify an effect that focuses the component this way. --- .../comment-input/CommentInputProvider.tsx | 29 +++++++++++++++++++ .../components/pte/comment-input/Editable.tsx | 10 ++++--- 2 files changed, 35 insertions(+), 4 deletions(-) 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 1848c12284c..b69f7818e70 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 @@ -17,6 +17,7 @@ export interface CommentInputContextValue { expandOnFocus?: boolean focused: boolean focusEditor: () => void + focusEditorEndOfContent: () => void focusOnMount?: boolean hasChanges: boolean insertAtChar: () => void @@ -72,6 +73,32 @@ export function CommentInputProvider(props: CommentInputProviderProps) { const focusEditor = useCallback(() => PortableTextEditor.focus(editor), [editor]) + const focusEditorEndOfContent = useCallback(() => { + const _value = PortableTextEditor.getValue(editor) + const lastBlock = (_value || []).slice(-1)[0] + if (!lastBlock) { + PortableTextEditor.focus(editor) + return + } + const lastChild = isPortableTextTextBlock(lastBlock) + ? lastBlock.children.slice(-1)[0] + : undefined + if (!lastChild) { + PortableTextEditor.focus(editor) + return + } + const point = { + path: [{_key: lastBlock._key}, 'children', {_key: lastChild._key}], + offset: isPortableTextSpan(lastChild) ? lastChild.text.length : 0, + } + const newSelection = { + focus: point, + anchor: point, + } + PortableTextEditor.select(editor, newSelection) + PortableTextEditor.focus(editor) + }, [editor]) + const closeMentions = useCallback(() => { setMentionsMenuOpen(false) setMentionsSearchTerm('') @@ -218,6 +245,7 @@ export function CommentInputProvider(props: CommentInputProviderProps) { expandOnFocus, focused, focusEditor, + focusEditorEndOfContent, focusOnMount, hasChanges, insertAtChar, @@ -236,6 +264,7 @@ export function CommentInputProvider(props: CommentInputProviderProps) { expandOnFocus, focused, focusEditor, + focusEditorEndOfContent, focusOnMount, hasChanges, insertAtChar, 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 28d9cc48a52..b6409ff60df 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 @@ -4,7 +4,6 @@ import {PortableTextEditable, usePortableTextEditorSelection} from '@sanity/port import styled, {css} from 'styled-components' 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' @@ -58,6 +57,7 @@ export function Editable(props: EditableProps) { const { closeMentions, focusEditor, + focusEditorEndOfContent, focusOnMount, insertMention, mentionOptions, @@ -77,11 +77,13 @@ export function Editable(props: EditableProps) { [popoverElement], ) - useDidUpdate(focusOnMount, () => { + // This effect will focus the editor when the component mounts (if focusOnMount context value is true) + useEffect(() => { if (focusOnMount) { - focusEditor() + // Focus to the end of the content + focusEditorEndOfContent() } - }) + }, [focusOnMount, focusEditorEndOfContent]) // Update the mentions search term in the mentions menu useEffect(() => {