Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inline text filtering of mentions menu #4936

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 41 additions & 19 deletions packages/@sanity/portable-text-editor/src/editor/Editable.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -12,7 +12,6 @@ import {PortableTextBlock} from '@sanity/types'
import {
EditorChange,
EditorSelection,
OnBeforeInputFn,
OnCopyFn,
OnPasteFn,
OnPasteResult,
Expand Down Expand Up @@ -54,10 +53,10 @@ const EMPTY_DECORATORS: BaseRange[] = []
*/
export type PortableTextEditableProps = Omit<
React.TextareaHTMLAttributes<HTMLDivElement>,
'onPaste' | 'onCopy'
'onPaste' | 'onCopy' | 'onBeforeInput'
> & {
hotkeys?: HotkeyOptions
onBeforeInput?: OnBeforeInputFn
onBeforeInput?: (event: InputEvent) => void
onPaste?: OnPasteFn
onCopy?: OnCopyFn
renderAnnotation?: RenderAnnotationFunction
Expand All @@ -76,11 +75,14 @@ export type PortableTextEditableProps = Omit<
* @public
*/
export const PortableTextEditable = forwardRef(function PortableTextEditable(
props: PortableTextEditableProps & Omit<React.HTMLProps<HTMLDivElement>, 'as' | 'onPaste'>,
props: PortableTextEditableProps &
Omit<React.HTMLProps<HTMLDivElement>, 'as' | 'onPaste' | 'onBeforeInput'>,
forwardedRef: React.ForwardedRef<HTMLDivElement>,
) {
const {
hotkeys,
onBlur,
onFocus,
onBeforeInput,
onPaste,
onCopy,
Expand Down Expand Up @@ -294,29 +296,39 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(

const handleOnFocus: React.FocusEventHandler<HTMLDivElement> = 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<HTMLDivElement> = 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(
(event: Event) => {
(event: InputEvent) => {
if (onBeforeInput) {
onBeforeInput(event)
}
Expand Down Expand Up @@ -354,7 +366,17 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
}
}, [handleDOMChange, ref])

const handleKeyDown = slateEditor.pteWithHotKeys
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (props.onKeyDown) {
props.onKeyDown(event)
}
if (!event.isDefaultPrevented()) {
slateEditor.pteWithHotKeys(event)
}
},
[props, slateEditor],
)

const scrollSelectionIntoViewToSlate = useMemo(() => {
// Use slate-react default scroll into view
Expand Down
4 changes: 2 additions & 2 deletions packages/@sanity/portable-text-editor/src/types/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 = (
Expand Down
Original file line number Diff line number Diff line change
@@ -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[] = []
Expand All @@ -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
Expand All @@ -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<HTMLDivElement>,
ref: React.Ref<MentionsMenuHandle>,
) {
const {loading, onSelect, options = []} = props
const [inputElement, setInputElement] = useState<HTMLInputElement | null>(null)
const {loading, onSelect, options = [], inputElement} = props
const [searchTerm, setSearchTerm] = useState<string>('')

const handleInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(event.target.value)
}, [])
const commandListRef = useRef<CommandListHandle>(null)

useImperativeHandle(
ref,
() => {
return {
setSearchTerm(term: string) {
setSearchTerm(term)
},
}
},
[],
)

const renderItem = useCallback(
(itemProps: MentionOptionUser) => {
Expand All @@ -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 (
<Root>
Expand All @@ -85,19 +83,12 @@ export const MentionsMenu = React.forwardRef(function MentionsMenu(
)
}

return (
<Flex direction="column" height="fill" ref={ref}>
<HeaderBox padding={1}>
<TextInput
fontSize={1}
icon={SearchIcon}
onChange={handleInputChange}
placeholder="Search for a user"
radius={2}
ref={setInputElement}
/>
</HeaderBox>
// 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 (
<Flex direction="column" height="fill">
{filteredOptions.length === 0 && (
<Box padding={5}>
<Text align="center" size={1} muted>
Expand All @@ -111,13 +102,13 @@ export const MentionsMenu = React.forwardRef(function MentionsMenu(
<CommandList
activeItemDataAttr="data-hovered"
ariaLabel="List of users to mention"
autoFocus="input"
fixedHeight
getItemDisabled={getItemDisabled}
inputElement={inputElement}
inputElement={_inputElement}
itemHeight={41}
items={filteredOptions}
padding={1}
ref={commandListRef}
renderItem={renderItem}
/>
</FlexWrap>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ export function CommentInputInner(props: CommentInputInnerProps) {
const [discardButtonElement, setDiscardButtonElement] = useState<HTMLButtonElement | null>(null)

const [user] = useUser(currentUser.id)
const {openMentions, focused, expandOnFocus, canSubmit, hasChanges} = useCommentInput()
const {openMentions, focused, expandOnFocus, canSubmit, hasChanges, insertAtChar} =
useCommentInput()

const avatar = withAvatar ? <CommentsAvatar user={user} /> : null

Expand All @@ -96,6 +97,11 @@ export function CommentInputInner(props: CommentInputInnerProps) {
discardButtonElement?.blur()
}, [discardButtonElement, onEditDiscard])

const handleMentionButtonClicked = useCallback(() => {
insertAtChar()
openMentions()
}, [insertAtChar, openMentions])

return (
<Flex align="flex-start" gap={2}>
{avatar}
Expand All @@ -121,7 +127,7 @@ export function CommentInputInner(props: CommentInputInnerProps) {
aria-label="Mention user"
icon={MentionIcon}
mode="bleed"
onClick={openMentions}
onClick={handleMentionButtonClicked}
/>

<ButtonDivider />
Expand Down
Loading