From 7bef0eca6a0317349ac7eb4167d9467741ba1ce9 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 9 Oct 2023 13:36:01 -0500 Subject: [PATCH] add tags autocomplete --- .../composer/text-input/web/Tags/index.tsx | 2 + .../composer/text-input/web/Tags/plugin.tsx | 235 ++++++++++++++++++ .../com/composer/text-input/web/Tags/view.tsx | 214 ++++++++++++++++ 3 files changed, 451 insertions(+) create mode 100644 src/view/com/composer/text-input/web/Tags/index.tsx create mode 100644 src/view/com/composer/text-input/web/Tags/plugin.tsx create mode 100644 src/view/com/composer/text-input/web/Tags/view.tsx diff --git a/src/view/com/composer/text-input/web/Tags/index.tsx b/src/view/com/composer/text-input/web/Tags/index.tsx new file mode 100644 index 0000000000..8df8f65d0b --- /dev/null +++ b/src/view/com/composer/text-input/web/Tags/index.tsx @@ -0,0 +1,2 @@ +export {Tags} from './plugin' +export {createTagsAutocomplete} from './view' diff --git a/src/view/com/composer/text-input/web/Tags/plugin.tsx b/src/view/com/composer/text-input/web/Tags/plugin.tsx new file mode 100644 index 0000000000..7bb8f76829 --- /dev/null +++ b/src/view/com/composer/text-input/web/Tags/plugin.tsx @@ -0,0 +1,235 @@ +/** @see https://github.com/ueberdosis/tiptap/blob/main/packages/extension-mention/src/mention.ts */ + +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: 'tag', + + addOptions() { + return { + HTMLAttributes: {}, + renderLabel({options, node}) { + return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + }, + suggestion: { + char: '#', + allowSpaces: true, + 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 + }, + findSuggestionMatch({$position}) { + const text = $position.nodeBefore?.isText && $position.nodeBefore.text + + if (!text) { + return null + } + + const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g + const puncRegex = /\p{P}+$/gu + const match = Array.from(text.matchAll(regex)).pop() + + if ( + !match || + match.input === undefined || + match.index === undefined + ) { + return null + } + + const cursorPosition = $position.pos + const startIndex = cursorPosition - text.length + let [matchedString, tag] = match + + const tagWithoutPunctuation = tag.replace(puncRegex, '') + // allow for multiple ending punctuation marks + const punctuationIndexOffset = + tag.length - tagWithoutPunctuation.length + + if (tagWithoutPunctuation.length > 66) return null + + const from = startIndex + match.index + matchedString.indexOf(tag) + // `to` should not include ending punctuation + const to = from + tagWithoutPunctuation.length + + if ( + from < cursorPosition && + to >= cursorPosition - punctuationIndexOffset + ) { + return { + range: { + from, + to, + }, + // should not include ending punctuation + query: tagWithoutPunctuation.replace(/^#/, ''), + // raw text string + text: matchedString, + } + } + + return null + }, + }, + } + }, + + group: 'inline', + + inline: true, + + atom: true, + + selectable: 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}) { + console.log( + 'renderText', + node, + this.options.renderLabel({ + options: this.options, + node, + }), + ) + 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/src/view/com/composer/text-input/web/Tags/view.tsx b/src/view/com/composer/text-input/web/Tags/view.tsx new file mode 100644 index 0000000000..4a07093ec9 --- /dev/null +++ b/src/view/com/composer/text-input/web/Tags/view.tsx @@ -0,0 +1,214 @@ +import React, {forwardRef, useImperativeHandle, useState} from 'react' +import {Pressable, StyleSheet, View} from 'react-native' +import {ReactRenderer} from '@tiptap/react' +import tippy, {Instance as TippyInstance} from 'tippy.js' +import { + SuggestionOptions, + SuggestionProps, + SuggestionKeyDownProps, +} from '@tiptap/suggestion' + +import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' +import {usePalette} from 'lib/hooks/usePalette' +import {Text} from 'view/com/util/text/Text' + +type AutocompleteResult = string +type ListProps = SuggestionProps & { + autocompleteModel: TagsAutocompleteModel +} +type AutocompleteRef = { + onKeyDown: (props: SuggestionKeyDownProps) => boolean +} + +export function createTagsAutocomplete({ + autocompleteModel, +}: { + autocompleteModel: TagsAutocompleteModel +}): Omit { + return { + async items({query}) { + autocompleteModel.setActive(true) + await autocompleteModel.search(query) + return autocompleteModel.suggestions.slice(0, 8) + }, + render() { + let component: ReactRenderer | undefined + let popup: TippyInstance[] | undefined + + return { + onStart: props => { + component = new ReactRenderer(Autocomplete, { + props: { + ...props, + autocompleteModel, + }, + editor: props.editor, + }) + + if (!props.clientRect) return + + // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }) + }, + + onUpdate(props) { + component?.updateProps(props) + + if (!props.clientRect) return + + popup?.[0]?.setProps({ + // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf + getReferenceClientRect: props.clientRect, + }) + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup?.[0]?.hide() + + return true + } + + return component?.ref?.onKeyDown(props) || false + }, + onExit() { + popup?.[0]?.destroy() + component?.destroy() + }, + } + }, + } +} + +const Autocomplete = forwardRef( + function AutocompleteImpl(props, ref) { + const {items, command, autocompleteModel} = props + const pal = usePalette('default') + const [selectedIndex, setSelectedIndex] = useState(0) + + const commit = React.useCallback( + (tag: string) => { + // @ts-ignore we're dealing with strings here not mentions + command({id: tag}) + autocompleteModel.commitRecentTag(tag) + }, + [command, autocompleteModel], + ) + + const selectItem = React.useCallback( + (index: number) => { + const item = items[index] + if (item) commit(item) + }, + [items, commit], + ) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({event}) => { + if (event.key === 'ArrowUp') { + setSelectedIndex( + (selectedIndex + props.items.length - 1) % props.items.length, + ) + return true + } + + if (event.key === 'ArrowDown') { + setSelectedIndex((selectedIndex + 1) % props.items.length) + return true + } + + if (event.key === 'Enter') { + if (!props.items.length) { + // no items, use whatever the user typed + commit(props.autocompleteModel.query) + } else { + selectItem(selectedIndex) + } + return true + } + + if (event.key === ' ') { + commit(props.autocompleteModel.query) + return true + } + + return false + }, + })) + + // hide entirely if no suggestions + if (!items.length) return null + + return ( +
+ + {items.map((tag, index) => { + const isSelected = selectedIndex === index + const isFirst = index === 0 + const isLast = index === items.length - 1 + + return ( + [ + styles.resultContainer, + { + backgroundColor: state.hovered + ? pal.viewLight.backgroundColor + : undefined, + }, + isSelected ? pal.viewLight : undefined, + isFirst + ? styles.firstResult + : isLast + ? styles.lastResult + : undefined, + ]} + onPress={() => selectItem(index)} + accessibilityRole="button"> + + #{tag} + + + ) + })} + +
+ ) + }, +) + +const styles = StyleSheet.create({ + container: { + width: 500, + borderRadius: 6, + borderWidth: 1, + borderStyle: 'solid', + padding: 4, + }, + resultContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexDirection: 'row', + paddingHorizontal: 12, + paddingVertical: 8, + gap: 4, + }, + firstResult: { + borderTopLeftRadius: 2, + borderTopRightRadius: 2, + }, + lastResult: { + borderBottomLeftRadius: 2, + borderBottomRightRadius: 2, + }, +})