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..851ed3e1fe --- /dev/null +++ b/src/view/com/composer/text-input/web/Tags/plugin.tsx @@ -0,0 +1,197 @@ +/** + * This is basically a fork of the Mention plugin from Tiptap. + * + * @see https://github.com/ueberdosis/tiptap/blob/025dfff1d9e4796edf3a451f7f53d06a07b95d69/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' + +import {findSuggestionMatch} from './utils' + +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({node}) { + return `#${node.attrs.id}` + }, + suggestion: { + char: '#', + allowSpaces: true, + pluginKey: TagsPluginKey, + command: ({editor, range, props}) => { + const {tag, punctuation} = 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: {id: tag}, + }, + { + type: 'text', + text: `${punctuation || ''} `, + }, + ]) + .run() + + window.getSelection()?.collapseToEnd() + }, + /** + * This method and `findSuggestionMatch` below both have to return a + * truthy value, otherwise the suggestiond plugin will call `onExit` + * and we lose the ability to add a tag + */ + 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 + const cursorPosition = $position.pos + + if (!text) { + return null + } + + return findSuggestionMatch({text, cursorPosition}) + }, + }, + } + }, + + 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}) { + 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/utils.ts b/src/view/com/composer/text-input/web/Tags/utils.ts new file mode 100644 index 0000000000..6894d2ba04 --- /dev/null +++ b/src/view/com/composer/text-input/web/Tags/utils.ts @@ -0,0 +1,74 @@ +import { + HASHTAG_WITH_TRAILING_PUNCTUATION_REGEX, + TRAILING_PUNCTUATION_REGEX, + LEADING_HASH_REGEX, +} from '@atproto/api' + +/** + * This method eventually receives the `query` property from the result of + * `findSuggestionMatch` below. + */ +export function parsePunctuationFromTag(value: string) { + const reg = TRAILING_PUNCTUATION_REGEX + const tag = value.replace(reg, '') + const punctuation = value.match(reg)?.[0] || '' + + return {tag, punctuation} +} + +/** + * A result must be returned from this method in order for the suggestion + * plugin to remain active and allow for the user to select a suggestion. + * + * That's why we use the loose regex form that includes trialing punctuation. + * We strip that our later. + */ +export function findSuggestionMatch({ + text, + cursorPosition, +}: { + text: string + cursorPosition: number +}) { + const match = Array.from( + text.matchAll(HASHTAG_WITH_TRAILING_PUNCTUATION_REGEX), + ).pop() + + if (!match || match.input === undefined || match.index === undefined) { + return null + } + + const startIndex = cursorPosition - text.length + let [matchedString, tagWithTrailingPunctuation] = match + + const sanitized = tagWithTrailingPunctuation + .replace(TRAILING_PUNCTUATION_REGEX, '') + .replace(LEADING_HASH_REGEX, '') + + // one of our hashtag spec rules + if (sanitized.length > 64) return null + + const from = + startIndex + match.index + matchedString.indexOf(tagWithTrailingPunctuation) + const to = from + tagWithTrailingPunctuation.length + + if (from < cursorPosition && to >= cursorPosition) { + return { + range: { + from, + to, + }, + /** + * This is passed to the `items({ query })` method configured in + * `createTagsAutocomplete`. + * + * We parse out the punctuation later. + */ + query: tagWithTrailingPunctuation.replace(LEADING_HASH_REGEX, ''), + // raw text string + text: matchedString, + } + } + + return null +} 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..dce26e0596 --- /dev/null +++ b/src/view/com/composer/text-input/web/Tags/view.tsx @@ -0,0 +1,227 @@ +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' + +import {parsePunctuationFromTag} from './utils' + +type AutocompleteResult = string +type ListProps = SuggestionProps & { + autocompleteModel: TagsAutocompleteModel +} +type AutocompleteRef = { + onKeyDown: (props: SuggestionKeyDownProps) => boolean +} + +export function createTagsAutocomplete({ + autocompleteModel, +}: { + autocompleteModel: TagsAutocompleteModel +}): Omit { + return { + /** + * This `query` param comes from the result of `findSuggestionMatch` + */ + 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( + (query: string) => { + const {tag, punctuation} = parsePunctuationFromTag(query) + /* + * This values here are passed directly to the `command` method + * configured in the `Tags` plugin. + * + * The type error is ignored because we parse the tag and punctuation + * separately above. We could do this in `command` definition, but we + * only want to `commitRecentTag` with the sanitized tag. + */ + // @ts-ignore + command({tag, punctuation}) + }, + [command], + ) + + 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(autocompleteModel.query) + } else { + selectItem(selectedIndex) + } + return true + } + + if (event.key === ' ') { + commit(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, + }, +})