diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index acec61516a..73fdbde941 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -30,6 +30,7 @@ import {createSuggestion} from './web/Autocomplete' import {Emoji} from './web/EmojiPicker.web' import {LinkDecorator} from './web/LinkDecorator' import {TagDecorator} from './web/TagDecorator' +import {Tags, createTagsAutocomplete} from './web/Tags' export interface TextInputRef { focus: () => void @@ -71,7 +72,14 @@ export const TextInput = React.forwardRef(function TextInputImpl( () => [ Document, LinkDecorator, - TagDecorator, + // TagDecorator, + Tags.configure({ + HTMLAttributes: { + class: 'inline-tag', + }, + suggestion: createTagsAutocomplete({ + }), + }), Mention.configure({ HTMLAttributes: { class: 'mention', diff --git a/src/view/com/composer/text-input/web/Tags/plugin.tsx b/src/view/com/composer/text-input/web/Tags/plugin.tsx index 851ed3e1fe..d588c5ffc2 100644 --- a/src/view/com/composer/text-input/web/Tags/plugin.tsx +++ b/src/view/com/composer/text-input/web/Tags/plugin.tsx @@ -1,40 +1,108 @@ /** * 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 + * @see https://github.com/ueberdosis/tiptap/blob/ec6121da1c3f808987d32de7a8c56b52520bfea8/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 { mergeAttributes, Node } from '@tiptap/core' +import { DOMOutputSpec, 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 +// See `addAttributes` below +export interface MentionNodeAttrs { + /** + * The identifier for the selected item that was mentioned, stored as a `data-id` + * attribute. + */ + id: string | null; + /** + * The label to be rendered by the editor as the displayed text for this mentioned + * item, if provided. Stored as a `data-label` attribute. See `renderLabel`. + */ + label?: string | null; } +export type MentionOptions = MentionNodeAttrs> = { + /** + * The HTML attributes for a mention node. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record + + /** + * A function to render the label of a mention. + * @deprecated use renderText and renderHTML instead + * @param props The render props + * @returns The label + * @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + */ + renderLabel?: (props: { options: MentionOptions; node: ProseMirrorNode }) => string + + /** + * A function to render the text of a mention. + * @param props The render props + * @returns The text + * @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + */ + renderText: (props: { options: MentionOptions; node: ProseMirrorNode }) => string + + /** + * A function to render the HTML of a mention. + * @param props The render props + * @returns The HTML as a ProseMirror DOM Output Spec + * @example ({ options, node }) => ['span', { 'data-type': 'mention' }, `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`] + */ + renderHTML: (props: { options: MentionOptions; node: ProseMirrorNode }) => DOMOutputSpec + + /** + * Whether to delete the trigger character with backspace. + * @default false + */ + deleteTriggerWithBackspace: boolean + + /** + * The suggestion options. + * @default {} + * @example { char: '@', pluginKey: MentionPluginKey, command: ({ editor, range, props }) => { ... } } + */ + suggestion: Omit, 'editor'> +} + +/** + * The plugin key for the mention plugin. + * @default 'mention' + */ export const TagsPluginKey = new PluginKey('tags') -export const Tags = Node.create({ - name: 'tag', +/** + * This extension allows you to insert mentions into the editor. + * @see https://www.tiptap.dev/api/extensions/mention + */ +export const Tags = Node.create({ + name: 'tags', addOptions() { return { HTMLAttributes: {}, - renderLabel({node}) { - return `#${node.attrs.id}` + renderText({ options, node }) { + return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + }, + deleteTriggerWithBackspace: false, + renderHTML({ options, node }) { + return [ + 'span', + mergeAttributes(this.HTMLAttributes, options.HTMLAttributes), + `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`, + ] }, suggestion: { char: '#', - allowSpaces: true, pluginKey: TagsPluginKey, - command: ({editor, range, props}) => { - const {tag, punctuation} = props - + 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 @@ -50,38 +118,30 @@ export const Tags = Node.create({ .insertContentAt(range, [ { type: this.name, - attrs: {id: tag}, + attrs: props, }, { type: 'text', - text: `${punctuation || ''} `, + text: ' ', }, ]) .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}) => { + 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) + console.log({ + allow, + $from, + state, + }) + return allow }, - findSuggestionMatch({$position}) { - const text = $position.nodeBefore?.isText && $position.nodeBefore.text - const cursorPosition = $position.pos - - if (!text) { - return null - } - - return findSuggestionMatch({text, cursorPosition}) - }, + findSuggestionMatch: findSuggestionMatch }, } }, @@ -90,9 +150,9 @@ export const Tags = Node.create({ inline: true, - atom: true, + selectable: false, - selectable: true, + atom: true, addAttributes() { return { @@ -134,23 +194,45 @@ export const Tags = Node.create({ ] }, - renderHTML({node, HTMLAttributes}) { - return [ - 'span', - mergeAttributes( - {'data-type': this.name}, - this.options.HTMLAttributes, - HTMLAttributes, - ), - this.options.renderLabel({ - options: this.options, - node, - }), - ] + renderHTML({ node, HTMLAttributes }) { + if (this.options.renderLabel !== undefined) { + console.warn('renderLabel is deprecated use renderText and renderHTML instead') + return [ + 'span', + mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes), + this.options.renderLabel({ + options: this.options, + node, + }), + ] + } + const mergedOptions = { ...this.options } + + mergedOptions.HTMLAttributes = mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes) + const html = this.options.renderHTML({ + options: mergedOptions, + node, + }) + + if (typeof html === 'string') { + return [ + 'span', + mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes), + html, + ] + } + return html }, - renderText({node}) { - return this.options.renderLabel({ + renderText({ node }) { + if (this.options.renderLabel !== undefined) { + console.warn('renderLabel is deprecated use renderText and renderHTML instead') + return this.options.renderLabel({ + options: this.options, + node, + }) + } + return this.options.renderText({ options: this.options, node, }) @@ -158,31 +240,30 @@ export const Tags = Node.create({ addKeyboardShortcuts() { return { - Backspace: () => - this.editor.commands.command(({tr, state}) => { - let isTag = false - const {selection} = state - const {empty, anchor} = selection + Backspace: () => this.editor.commands.command(({ tr, state }) => { + let isMention = 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) { + isMention = true + tr.insertText( + this.options.deleteTriggerWithBackspace ? '' : this.options.suggestion.char || '', + pos, + pos + node.nodeSize, + ) - 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 - }), + return isMention + }), } }, diff --git a/src/view/com/composer/text-input/web/Tags/utils.ts b/src/view/com/composer/text-input/web/Tags/utils.ts index 6894d2ba04..ae474ca4cc 100644 --- a/src/view/com/composer/text-input/web/Tags/utils.ts +++ b/src/view/com/composer/text-input/web/Tags/utils.ts @@ -1,8 +1,5 @@ -import { - HASHTAG_WITH_TRAILING_PUNCTUATION_REGEX, - TRAILING_PUNCTUATION_REGEX, - LEADING_HASH_REGEX, -} from '@atproto/api' +import {TAG_REGEX, TRAILING_PUNCTUATION_REGEX} from '@atproto/api' +import {findSuggestionMatch as defaultFindSuggestionMatch} from '@tiptap/suggestion' /** * This method eventually receives the `query` property from the result of @@ -22,51 +19,42 @@ export function parsePunctuationFromTag(value: string) { * * That's why we use the loose regex form that includes trialing punctuation. * We strip that our later. + * + * @see https://github.com/ueberdosis/tiptap/blob/cf2067906f506486c6613f872be8b1fd318526c9/packages/suggestion/src/findSuggestionMatch.ts */ export function findSuggestionMatch({ - text, - cursorPosition, -}: { - text: string - cursorPosition: number -}) { - const match = Array.from( - text.matchAll(HASHTAG_WITH_TRAILING_PUNCTUATION_REGEX), - ).pop() + $position, +}: Parameters[0]) { + const text = $position.nodeBefore?.isText && $position.nodeBefore.text + + if (!text) { + return null + } + + const textFrom = $position.pos - text.length + const match = Array.from(text.matchAll(TAG_REGEX)).pop() if (!match || match.input === undefined || match.index === undefined) { return null } - const startIndex = cursorPosition - text.length - let [matchedString, tagWithTrailingPunctuation] = match + const [fullMatch, , tag] = match - const sanitized = tagWithTrailingPunctuation - .replace(TRAILING_PUNCTUATION_REGEX, '') - .replace(LEADING_HASH_REGEX, '') + if (!tag || tag.length === 0 || tag.length > 64) return null - // one of our hashtag spec rules - if (sanitized.length > 64) return null + // The absolute position of the match in the document + const from = textFrom + fullMatch.indexOf(tag) + const to = from + tag.length + 1 - const from = - startIndex + match.index + matchedString.indexOf(tagWithTrailingPunctuation) - const to = from + tagWithTrailingPunctuation.length - - if (from < cursorPosition && to >= cursorPosition) { + // If the $position is located within the matched substring, return that range + if (from < $position.pos && to >= $position.pos) { 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, + query: tag.replace(TRAILING_PUNCTUATION_REGEX, ''), + text: fullMatch, } } diff --git a/src/view/com/composer/text-input/web/Tags/view.tsx b/src/view/com/composer/text-input/web/Tags/view.tsx index dce26e0596..76d0b8a2b8 100644 --- a/src/view/com/composer/text-input/web/Tags/view.tsx +++ b/src/view/com/composer/text-input/web/Tags/view.tsx @@ -8,7 +8,7 @@ import { SuggestionKeyDownProps, } from '@tiptap/suggestion' -import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' +// import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' import {usePalette} from 'lib/hooks/usePalette' import {Text} from 'view/com/util/text/Text' @@ -16,25 +16,25 @@ import {parsePunctuationFromTag} from './utils' type AutocompleteResult = string type ListProps = SuggestionProps & { - autocompleteModel: TagsAutocompleteModel + // autocompleteModel: TagsAutocompleteModel } type AutocompleteRef = { onKeyDown: (props: SuggestionKeyDownProps) => boolean } export function createTagsAutocomplete({ - autocompleteModel, + // autocompleteModel, }: { - autocompleteModel: TagsAutocompleteModel + // 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) + // autocompleteModel.setActive(true) + // await autocompleteModel.search(query) + return ['tag']//autocompleteModel.suggestions.slice(0, 8) }, render() { let component: ReactRenderer | undefined @@ -45,7 +45,7 @@ export function createTagsAutocomplete({ component = new ReactRenderer(Autocomplete, { props: { ...props, - autocompleteModel, + //autocompleteModel, }, editor: props.editor, }) @@ -95,7 +95,7 @@ export function createTagsAutocomplete({ const Autocomplete = forwardRef( function AutocompleteImpl(props, ref) { - const {items, command, autocompleteModel} = props + const {items, command} = props const pal = usePalette('default') const [selectedIndex, setSelectedIndex] = useState(0) @@ -141,7 +141,7 @@ const Autocomplete = forwardRef( if (event.key === 'Enter') { if (!props.items.length) { // no items, use whatever the user typed - commit(autocompleteModel.query) + // commit(autocompleteModel.query) } else { selectItem(selectedIndex) } @@ -149,7 +149,7 @@ const Autocomplete = forwardRef( } if (event.key === ' ') { - commit(autocompleteModel.query) + // commit(autocompleteModel.query) return true }