WIP autocomplete
This commit is contained in:
@@ -95,6 +95,7 @@
|
||||
"expo-system-ui": "~2.4.0",
|
||||
"expo-updates": "~0.18.12",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"fuse.js": "^6.6.2",
|
||||
"graphemer": "^1.4.0",
|
||||
"history": "^5.3.0",
|
||||
"js-sha256": "^0.9.0",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import AwaitLock from 'await-lock'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
export class TagsAutocompleteView {
|
||||
// state
|
||||
isLoading = false
|
||||
isActive = false
|
||||
prefix = ''
|
||||
lock = new AwaitLock()
|
||||
|
||||
searchedTags: string[] = []
|
||||
recentTags: string[] = [
|
||||
'js',
|
||||
'javascript',
|
||||
'art',
|
||||
'music',
|
||||
]
|
||||
profileTags: string[] = [
|
||||
'bikes',
|
||||
'beer',
|
||||
]
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
get suggestions() {
|
||||
if (!this.isActive) {
|
||||
return []
|
||||
}
|
||||
|
||||
const items = [
|
||||
...this.recentTags,
|
||||
...this.profileTags,
|
||||
...this.searchedTags,
|
||||
]
|
||||
|
||||
if (!this.prefix) {
|
||||
return items.slice(0, 8)
|
||||
}
|
||||
|
||||
const fuse = new Fuse(items)
|
||||
const results = fuse.search(this.prefix)
|
||||
|
||||
return results.slice(0, 8).map(r => r.item)
|
||||
}
|
||||
|
||||
setActive(v: boolean) {
|
||||
this.isActive = v
|
||||
}
|
||||
|
||||
async setPrefix(prefix: string) {
|
||||
this.prefix = prefix.trim()
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
if (this.prefix) {
|
||||
if (this.prefix !== this.prefix) {
|
||||
return // another prefix was set before we got our chance
|
||||
}
|
||||
await this._search()
|
||||
} else {
|
||||
// this.searchRes = []
|
||||
}
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
// internal
|
||||
// =
|
||||
|
||||
async _search() {
|
||||
runInAction(() => {
|
||||
this.searchedTags = [
|
||||
'code',
|
||||
'dev',
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {RichText} from '@atproto/api'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
||||
import {TagsAutocompleteView} from 'state/models/ui/tags-autocomplete'
|
||||
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
|
||||
import {ExternalEmbed} from './ExternalEmbed'
|
||||
import {Text} from '../util/text/Text'
|
||||
@@ -97,6 +98,10 @@ export const ComposePost = observer(function ComposePost({
|
||||
() => new UserAutocompleteModel(store),
|
||||
[store],
|
||||
)
|
||||
const tagAutoCompleteView = useMemo<TagsAutocompleteView>(
|
||||
() => new TagsAutocompleteView(store),
|
||||
[store],
|
||||
)
|
||||
|
||||
const insets = useSafeAreaInsets()
|
||||
const viewStyles = useMemo(
|
||||
@@ -366,6 +371,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
placeholder={selectTextInputPlaceholder}
|
||||
suggestedLinks={suggestedLinks}
|
||||
autocompleteView={autocompleteView}
|
||||
tagsAutocompleteView={tagAutoCompleteView}
|
||||
autoFocus={true}
|
||||
setRichText={setRichText}
|
||||
onPhotoPasted={onPhotoPasted}
|
||||
|
||||
@@ -19,6 +19,7 @@ import PasteInput, {
|
||||
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
|
||||
import isEqual from 'lodash.isequal'
|
||||
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
||||
import {TagsAutocompleteView} from 'state/models/ui/tags-autocomplete'
|
||||
import {Autocomplete} from './mobile/Autocomplete'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
@@ -39,6 +40,7 @@ interface TextInputProps extends ComponentProps<typeof RNTextInput> {
|
||||
placeholder: string
|
||||
suggestedLinks: Set<string>
|
||||
autocompleteView: UserAutocompleteModel
|
||||
tagsAutocompleteView: TagsAutocompleteView
|
||||
setRichText: (v: RichText | ((v: RichText) => RichText)) => void
|
||||
onPhotoPasted: (uri: string) => void
|
||||
onPressPublish: (richtext: RichText) => Promise<void>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {Placeholder} from '@tiptap/extension-placeholder'
|
||||
import {Text} from '@tiptap/extension-text'
|
||||
import isEqual from 'lodash.isequal'
|
||||
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
||||
import {TagsAutocompleteView} from 'state/models/ui/tags-autocomplete'
|
||||
import {createSuggestion} from './web/Autocomplete'
|
||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {isUriImage, blobToDataUri} from 'lib/media/util'
|
||||
@@ -19,6 +20,7 @@ import {Emoji} from './web/EmojiPicker.web'
|
||||
import {LinkDecorator} from './web/LinkDecorator'
|
||||
import {generateJSON} from '@tiptap/html'
|
||||
import {TagDecorator} from './web/TagDecorator'
|
||||
import {Tags, createTagsSuggestion} from './web/Tags'
|
||||
|
||||
export interface TextInputRef {
|
||||
focus: () => void
|
||||
@@ -30,6 +32,7 @@ interface TextInputProps {
|
||||
placeholder: string
|
||||
suggestedLinks: Set<string>
|
||||
autocompleteView: UserAutocompleteModel
|
||||
tagsAutocompleteView: TagsAutocompleteView
|
||||
setRichText: (v: RichText | ((v: RichText) => RichText)) => void
|
||||
onPhotoPasted: (uri: string) => void
|
||||
onPressPublish: (richtext: RichText) => Promise<void>
|
||||
@@ -45,6 +48,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
||||
placeholder,
|
||||
suggestedLinks,
|
||||
autocompleteView,
|
||||
tagsAutocompleteView,
|
||||
setRichText,
|
||||
onPhotoPasted,
|
||||
onPressPublish,
|
||||
@@ -58,7 +62,13 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
||||
() => [
|
||||
Document,
|
||||
LinkDecorator,
|
||||
TagDecorator,
|
||||
// TagDecorator,
|
||||
Tags.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'autolink',
|
||||
},
|
||||
suggestion: createTagsSuggestion({autocompleteView: tagsAutocompleteView}),
|
||||
}),
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'mention',
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { PluginKey } from '@tiptap/pm/state'
|
||||
import Suggestion, { SuggestionOptions } from '@tiptap/suggestion'
|
||||
|
||||
export type TagOptions = {
|
||||
HTMLAttributes: Record<string, any>
|
||||
renderLabel: (props: { options: TagOptions; node: ProseMirrorNode }) => string
|
||||
suggestion: Omit<SuggestionOptions, 'editor'>
|
||||
}
|
||||
|
||||
export const TagsPluginKey = new PluginKey('tags')
|
||||
|
||||
export const Tags = Node.create<TagOptions>({
|
||||
name: 'tags',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
renderLabel({ options, node }) {
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
|
||||
},
|
||||
suggestion: {
|
||||
char: '#',
|
||||
pluginKey: TagsPluginKey,
|
||||
command: ({ editor, range, props }) => {
|
||||
// increase range.to by one when the next node is of type "text"
|
||||
// and starts with a space character
|
||||
const nodeAfter = editor.view.state.selection.$to.nodeAfter
|
||||
const overrideSpace = nodeAfter?.text?.startsWith(' ')
|
||||
|
||||
if (overrideSpace) {
|
||||
range.to += 1
|
||||
}
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{
|
||||
type: this.name,
|
||||
attrs: props,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ' ',
|
||||
},
|
||||
])
|
||||
.run()
|
||||
|
||||
window.getSelection()?.collapseToEnd()
|
||||
},
|
||||
allow: ({ state, range }) => {
|
||||
const $from = state.doc.resolve(range.from)
|
||||
const type = state.schema.nodes[this.name]
|
||||
const allow = !!$from.parent.type.contentMatch.matchType(type)
|
||||
|
||||
return allow
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
group: 'inline',
|
||||
|
||||
inline: true,
|
||||
|
||||
selectable: true,
|
||||
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-id'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.id) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
'data-id': attributes.id,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
label: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-label'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.label) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
'data-label': attributes.label,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `span[data-type="${this.name}"]`,
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes),
|
||||
this.options.renderLabel({
|
||||
options: this.options,
|
||||
node,
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
return this.options.renderLabel({
|
||||
options: this.options,
|
||||
node,
|
||||
})
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: () => this.editor.commands.command(({ tr, state }) => {
|
||||
let isTag = false
|
||||
const { selection } = state
|
||||
const { empty, anchor } = selection
|
||||
|
||||
if (!empty) {
|
||||
return false
|
||||
}
|
||||
|
||||
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
|
||||
if (node.type.name === this.name) {
|
||||
isTag = true
|
||||
tr.insertText(this.options.suggestion.char || '', pos, pos + node.nodeSize)
|
||||
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
return isTag
|
||||
}),
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -9933,6 +9933,11 @@ funpermaproxy@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/funpermaproxy/-/funpermaproxy-1.1.0.tgz#39cb0b8bea908051e4608d8a414f1d87b55bf557"
|
||||
integrity sha512-2Sp1hWuO8m5fqeFDusyhKqYPT+7rGLw34N3qonDcdRP8+n7M7Gl/yKp/q7oCxnnJ6pWCectOmLFJpsMU/++KrQ==
|
||||
|
||||
fuse.js@^6.6.2:
|
||||
version "6.6.2"
|
||||
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.6.2.tgz#fe463fed4b98c0226ac3da2856a415576dc9a111"
|
||||
integrity sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==
|
||||
|
||||
gensync@^1.0.0-beta.2:
|
||||
version "1.0.0-beta.2"
|
||||
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
||||
|
||||
Reference in New Issue
Block a user