From 3098ebb482cfd8b27d2cae4c8d2087a1554bf73c Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 27 Sep 2023 18:01:13 -0500 Subject: [PATCH] WIP autocomplete --- package.json | 1 + src/state/models/ui/tags-autocomplete.ts | 88 ++++++++++ src/view/com/composer/Composer.tsx | 6 + .../com/composer/text-input/TextInput.tsx | 2 + .../com/composer/text-input/TextInput.web.tsx | 12 +- .../text-input/web/TagDecoratorV2.tsx | 163 ++++++++++++++++++ yarn.lock | 5 + 7 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 src/state/models/ui/tags-autocomplete.ts create mode 100644 src/view/com/composer/text-input/web/TagDecoratorV2.tsx diff --git a/package.json b/package.json index 1a8c5992ba..9b1ed9000c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/state/models/ui/tags-autocomplete.ts b/src/state/models/ui/tags-autocomplete.ts new file mode 100644 index 0000000000..61c5df6282 --- /dev/null +++ b/src/state/models/ui/tags-autocomplete.ts @@ -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', + ] + }) + } +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index a589978d95..e89042ad1d 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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( + () => 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} diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index c5d094ea5f..b944aad5a7 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -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 { placeholder: string suggestedLinks: Set autocompleteView: UserAutocompleteModel + tagsAutocompleteView: TagsAutocompleteView setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void onPressPublish: (richtext: RichText) => Promise diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 0dd72a1780..47c3cf70af 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -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 autocompleteView: UserAutocompleteModel + tagsAutocompleteView: TagsAutocompleteView setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void onPressPublish: (richtext: RichText) => Promise @@ -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', diff --git a/src/view/com/composer/text-input/web/TagDecoratorV2.tsx b/src/view/com/composer/text-input/web/TagDecoratorV2.tsx new file mode 100644 index 0000000000..8a7eb5cac1 --- /dev/null +++ b/src/view/com/composer/text-input/web/TagDecoratorV2.tsx @@ -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 + renderLabel: (props: { options: TagOptions; node: ProseMirrorNode }) => string + suggestion: Omit +} + +export const TagsPluginKey = new PluginKey('tags') + +export const Tags = Node.create({ + 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, + }), + ] + }, +}) diff --git a/yarn.lock b/yarn.lock index 98b0519b2c..7e29758b79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"