Skip to content

Commit

Permalink
refactor(comments): inline text filtering for mentions menu
Browse files Browse the repository at this point in the history
  • Loading branch information
skogsmaskin committed Sep 15, 2023
1 parent 711a867 commit 519d6ad
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 133 deletions.
Original file line number Diff line number Diff line change
@@ -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[] = []
Expand All @@ -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
Expand All @@ -25,23 +20,43 @@ const FlexWrap = styled(Flex)({
maxHeight: ITEM_HEIGHT * MAX_ITEMS + LIST_PADDING * 2 + ITEM_HEIGHT / 2,
})

export interface MentionsMenuHandle {
focusMentionsList: () => void
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<HTMLDivElement>,
ref: React.Ref<FIXME>,
) {
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 {
focusMentionsList() {
commandListRef.current?.focusListElement()
},
selectCurrent() {
commandListRef.current?.selectCurrent()
},
setSearchTerm(term: string) {
setSearchTerm(term)
},
}
},
[commandListRef],
)

const renderItem = useCallback(
(itemProps: MentionOptionUser) => {
Expand All @@ -67,14 +82,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 @@ -87,17 +94,6 @@ 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>

{filteredOptions.length === 0 && (
<Box padding={5}>
<Text align="center" size={1} muted>
Expand All @@ -111,13 +107,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 as HTMLInputElement}
itemHeight={41}
items={filteredOptions}
padding={1}
ref={commandListRef}
renderItem={renderItem}
/>
</FlexWrap>
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -80,7 +80,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 = user ? <UserAvatar size={0} user={user} /> : <Avatar size={0} />

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

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

return (
<Flex align="flex-start" gap={1}>
{withAvatar ? avatar : null}
Expand All @@ -114,7 +120,7 @@ export function CommentInputInner(props: CommentInputInnerProps) {
fontSize={1}
icon={MentionIcon}
mode="bleed"
onClick={openMentions}
onClick={handleMentionButtonClicked}
padding={2}
/>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>
openMentions: () => void
mentionsSearchTerm: string
value: CommentMessage
}

Expand Down Expand Up @@ -59,6 +61,7 @@ export function CommentInputProvider(props: CommentInputProviderProps) {
const editor = usePortableTextEditor()

const [mentionsMenuOpen, setMentionsMenuOpen] = useState<boolean>(false)
const [mentionsSearchTerm, setMentionsSearchTerm] = useState<string>('')
const [selectionAtMentionInsert, setSelectionAtMentionInsert] = useState<EditorSelection>(null)

// todo
Expand All @@ -72,77 +75,83 @@ 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 &&
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.delete(
editor,
{
anchor: {path: spanPath, offset: span.text.lastIndexOf('@')},
focus: {path: spanPath, offset: span.text.length},
},
{mode: 'selected'},
)

PortableTextEditor.insertChild(editor, mentionSchemaType, {
_type: 'mention',
Expand All @@ -161,6 +170,7 @@ export function CommentInputProvider(props: CommentInputProviderProps) {
focus: {path, offset: 0},
}
PortableTextEditor.select(editor, sel)
PortableTextEditor.focus(editor)
}
}

Expand Down Expand Up @@ -190,12 +200,14 @@ export function CommentInputProvider(props: CommentInputProviderProps) {
focusEditor,
focusOnMount,
hasChanges,
insertAtChar,
insertMention,
mentionOptions,
mentionsMenuOpen,
mentionsSearchTerm,
onBeforeInput,
openMentions,
value,
mentionOptions,
}) satisfies CommentInputContextValue,
[
canSubmit,
Expand All @@ -207,8 +219,10 @@ export function CommentInputProvider(props: CommentInputProviderProps) {
focusEditor,
focusOnMount,
hasChanges,
insertAtChar,
insertMention,
mentionsMenuOpen,
mentionsSearchTerm,
onBeforeInput,
openMentions,
value,
Expand Down
Loading

0 comments on commit 519d6ad

Please sign in to comment.