Improve autocomplete
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user