Improve autocomplete

This commit is contained in:
Eric Bailey
2024-10-22 16:03:17 -05:00
parent 91d50e70dc
commit 43409aaba6
3 changed files with 82 additions and 64 deletions
@@ -17,8 +17,6 @@ import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {blobToDataUri, isUriImage} from '#/lib/media/util' import {blobToDataUri, isUriImage} from '#/lib/media/util'
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {useSession} from '#/state/session'
import {tagAutocompleteModel} from '#/view/com/composer/text-input/tagsAutocompleteState'
import { import {
LinkFacetMatch, LinkFacetMatch,
suggestLinkCardUri, suggestLinkCardUri,
@@ -66,7 +64,6 @@ export const TextInput = React.forwardRef(function TextInputImpl(
const autocomplete = useActorAutocompleteFn() const autocomplete = useActorAutocompleteFn()
const pal = usePalette('default') const pal = usePalette('default')
const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark') const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
const {currentAccount} = useSession()
const [isDropping, setIsDropping] = React.useState(false) const [isDropping, setIsDropping] = React.useState(false)
@@ -79,9 +76,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
HTMLAttributes: { HTMLAttributes: {
class: 'inline-tag', class: 'inline-tag',
}, },
suggestion: createTagsAutocomplete({ suggestion: createTagsAutocomplete(),
model: tagAutocompleteModel({currentDid: currentAccount?.did!}),
}),
}), }),
Mention.configure({ Mention.configure({
HTMLAttributes: { HTMLAttributes: {
@@ -97,7 +92,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
History, History,
Hardbreak, Hardbreak,
], ],
[autocomplete, placeholder, currentAccount], [autocomplete, placeholder],
) )
React.useEffect(() => { React.useEffect(() => {
@@ -1,3 +1,7 @@
import React from 'react'
import Fuse from 'fuse.js'
import {useSession} from '#/state/session'
import {account} from '#/storage' import {account} from '#/storage'
export type Result = { export type Result = {
@@ -5,31 +9,65 @@ export type Result = {
} }
export type Model = { export type Model = {
search(query: string): Promise<Result[]> readonly suggestions: Result[]
setQuery(query: string): void
save(tag: string): void save(tag: string): void
} }
export function tagAutocompleteModel({ export function useTagAutocomplete() {
currentDid, const {currentAccount} = useSession()
}: { const [query, setQuery] = React.useState('')
currentDid: string const [searchSuggestions, setSearchSuggestions] = React.useState<Result[]>([])
}): Model {
let recentTags = account.get([currentDid, 'recentTags']) || [] const search = React.useCallback(
async (_query: string) => {
// TODO actually search
// TODO debounce/abort controller
setSearchSuggestions([])
},
[setSearchSuggestions],
)
const onSetQuery = React.useCallback(
(query: string) => {
setQuery(query)
search(query)
},
[setQuery, search],
)
const saveRecentTag = React.useCallback(
(tag: string) => {
if (!currentAccount) {
throw new Error('No current account')
}
const recentTags = account.get([currentAccount.did, 'recentTags']) || []
account.set(
[currentAccount.did, 'recentTags'],
[{value: tag}, ...recentTags.filter(t => t.value !== tag)].slice(0, 40),
)
},
[currentAccount],
)
const suggestions: Result[] = React.useMemo(() => {
if (!currentAccount) {
throw new Error('No current account')
}
const recentTags = account.get([currentAccount.did, 'recentTags']) || []
const items = [
...recentTags.map(t => t.value),
...searchSuggestions.map(s => s.value),
]
const fuse = new Fuse(items)
// search amongst mixed set of tags
const results = fuse.search(query).map(r => r.item)
return results.map(value => ({value}))
}, [currentAccount, query, searchSuggestions])
return { return {
async search(query: string) { suggestions,
if (!query) return [{value: query}] setQuery: onSetQuery,
return [ saveRecentTag,
{value: query},
...recentTags.filter(t => t.value.includes(query)),
]
},
save(tag: string) {
recentTags = [
{value: tag},
...recentTags.filter(t => t.value !== tag),
].slice(0, 40)
account.set([currentDid, 'recentTags'], recentTags)
},
} }
} }
@@ -8,34 +8,17 @@ import {
} from '@tiptap/suggestion' } from '@tiptap/suggestion'
import tippy, {Instance as TippyInstance} from 'tippy.js' import tippy, {Instance as TippyInstance} from 'tippy.js'
// import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import { import {useTagAutocomplete} from '#/view/com/composer/text-input/tagsAutocompleteState'
Model,
Result,
} from '#/view/com/composer/text-input/tagsAutocompleteState'
import {Text} from '#/view/com/util/text/Text' import {Text} from '#/view/com/util/text/Text'
import {parsePunctuationFromTag} from './utils' import {parsePunctuationFromTag} from './utils'
type ListProps = SuggestionProps<Result> & {
model: Model
}
type AutocompleteRef = { type AutocompleteRef = {
onKeyDown: (props: SuggestionKeyDownProps) => boolean onKeyDown: (props: SuggestionKeyDownProps) => boolean
} }
export function createTagsAutocomplete({ export function createTagsAutocomplete(): Omit<SuggestionOptions, 'editor'> {
model,
}: {
model: Model
}): Omit<SuggestionOptions, 'editor'> {
return { return {
/**
* This `query` param comes from the result of `findSuggestionMatch`
*/
async items({query}) {
return await model.search(query)
},
render() { render() {
let component: ReactRenderer<AutocompleteRef> | undefined let component: ReactRenderer<AutocompleteRef> | undefined
let popup: TippyInstance[] | undefined let popup: TippyInstance[] | undefined
@@ -43,10 +26,7 @@ export function createTagsAutocomplete({
return { return {
onStart: props => { onStart: props => {
component = new ReactRenderer(Autocomplete, { component = new ReactRenderer(Autocomplete, {
props: { props,
...props,
model,
},
editor: props.editor, editor: props.editor,
}) })
@@ -93,12 +73,17 @@ export function createTagsAutocomplete({
} }
} }
const Autocomplete = forwardRef<AutocompleteRef, ListProps>( const Autocomplete = forwardRef<AutocompleteRef, SuggestionProps>(
function AutocompleteImpl(props, ref) { function AutocompleteImpl(props, ref) {
const {items, command, model} = props const {command, query} = props
const {suggestions, setQuery, saveRecentTag} = useTagAutocomplete()
const pal = usePalette('default') const pal = usePalette('default')
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
React.useEffect(() => {
setQuery(query)
}, [query, setQuery])
const commit = React.useCallback( const commit = React.useCallback(
(query: string) => { (query: string) => {
const {tag, punctuation} = parsePunctuationFromTag(query) const {tag, punctuation} = parsePunctuationFromTag(query)
@@ -111,36 +96,36 @@ const Autocomplete = forwardRef<AutocompleteRef, ListProps>(
* only want to `commitRecentTag` with the sanitized tag. * only want to `commitRecentTag` with the sanitized tag.
*/ */
command({tag, punctuation}) command({tag, punctuation})
model.save(tag) saveRecentTag(tag)
}, },
[command, model], [command, saveRecentTag],
) )
const selectItem = React.useCallback( const selectItem = React.useCallback(
(index: number) => { (index: number) => {
const item = items[index] const item = suggestions[index]
if (item) commit(item.value) if (item) commit(item.value)
}, },
[items, commit], [suggestions, commit],
) )
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
onKeyDown: ({event}) => { onKeyDown: ({event}) => {
if (event.key === 'ArrowUp') { if (event.key === 'ArrowUp') {
setSelectedIndex( setSelectedIndex(
(selectedIndex + props.items.length - 1) % props.items.length, (selectedIndex + suggestions.length - 1) % suggestions.length,
) )
return true return true
} }
if (event.key === 'ArrowDown') { if (event.key === 'ArrowDown') {
setSelectedIndex((selectedIndex + 1) % props.items.length) setSelectedIndex((selectedIndex + 1) % suggestions.length)
return true return true
} }
if (event.key === 'Enter') { if (event.key === 'Enter') {
if (!props.items.length) { if (!suggestions.length) {
// no items, use whatever the user typed // no suggestions, use whatever the user typed
commit(props.query) commit(props.query)
} else { } else {
selectItem(selectedIndex) selectItem(selectedIndex)
@@ -158,16 +143,16 @@ const Autocomplete = forwardRef<AutocompleteRef, ListProps>(
})) }))
// hide entirely if no suggestions // hide entirely if no suggestions
if (!items.length) return null if (!suggestions.length) return null
return ( return (
<div className="items"> <div className="items">
<View style={[pal.borderDark, pal.view, styles.container]}> <View style={[pal.borderDark, pal.view, styles.container]}>
{items.map(({value}, index) => { {suggestions.map(({value}, index) => {
const {tag} = parsePunctuationFromTag(value) const {tag} = parsePunctuationFromTag(value)
const isSelected = selectedIndex === index const isSelected = selectedIndex === index
const isFirst = index === 0 const isFirst = index === 0
const isLast = index === items.length - 1 const isLast = index === suggestions.length - 1
return ( return (
<Pressable <Pressable