WIP, can't add spaces after tag

This commit is contained in:
Eric Bailey
2024-09-09 09:55:22 -05:00
parent 0ab51794e2
commit bea6627d59
4 changed files with 197 additions and 120 deletions
@@ -30,6 +30,7 @@ import {createSuggestion} from './web/Autocomplete'
import {Emoji} from './web/EmojiPicker.web' import {Emoji} from './web/EmojiPicker.web'
import {LinkDecorator} from './web/LinkDecorator' import {LinkDecorator} from './web/LinkDecorator'
import {TagDecorator} from './web/TagDecorator' import {TagDecorator} from './web/TagDecorator'
import {Tags, createTagsAutocomplete} from './web/Tags'
export interface TextInputRef { export interface TextInputRef {
focus: () => void focus: () => void
@@ -71,7 +72,14 @@ export const TextInput = React.forwardRef(function TextInputImpl(
() => [ () => [
Document, Document,
LinkDecorator, LinkDecorator,
TagDecorator, // TagDecorator,
Tags.configure({
HTMLAttributes: {
class: 'inline-tag',
},
suggestion: createTagsAutocomplete({
}),
}),
Mention.configure({ Mention.configure({
HTMLAttributes: { HTMLAttributes: {
class: 'mention', class: 'mention',
@@ -1,40 +1,108 @@
/** /**
* This is basically a fork of the Mention plugin from Tiptap. * 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 { mergeAttributes, Node } from '@tiptap/core'
import {Node as ProseMirrorNode} from '@tiptap/pm/model' import { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model'
import {PluginKey} from '@tiptap/pm/state' import { PluginKey } from '@tiptap/pm/state'
import Suggestion, {SuggestionOptions} from '@tiptap/suggestion' import Suggestion, { SuggestionOptions } from '@tiptap/suggestion'
import {findSuggestionMatch} from './utils' import {findSuggestionMatch} from './utils'
export type TagOptions = { // See `addAttributes` below
HTMLAttributes: Record<string, any> export interface MentionNodeAttrs {
renderLabel: (props: {options: TagOptions; node: ProseMirrorNode}) => string /**
suggestion: Omit<SuggestionOptions, 'editor'> * 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<SuggestionItem = any, Attrs extends Record<string, any> = MentionNodeAttrs> = {
/**
* The HTML attributes for a mention node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
/**
* 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<SuggestionItem, Attrs>; 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<SuggestionItem, Attrs>; 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<SuggestionItem, Attrs>; 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<SuggestionOptions<SuggestionItem, Attrs>, 'editor'>
}
/**
* The plugin key for the mention plugin.
* @default 'mention'
*/
export const TagsPluginKey = new PluginKey('tags') export const TagsPluginKey = new PluginKey('tags')
export const Tags = Node.create<TagOptions>({ /**
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<MentionOptions>({
name: 'tags',
addOptions() { addOptions() {
return { return {
HTMLAttributes: {}, HTMLAttributes: {},
renderLabel({node}) { renderText({ options, node }) {
return `#${node.attrs.id}` 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: { suggestion: {
char: '#', char: '#',
allowSpaces: true,
pluginKey: TagsPluginKey, pluginKey: TagsPluginKey,
command: ({editor, range, props}) => { command: ({ editor, range, props }) => {
const {tag, punctuation} = props
// increase range.to by one when the next node is of type "text" // increase range.to by one when the next node is of type "text"
// and starts with a space character // and starts with a space character
const nodeAfter = editor.view.state.selection.$to.nodeAfter const nodeAfter = editor.view.state.selection.$to.nodeAfter
@@ -50,38 +118,30 @@ export const Tags = Node.create<TagOptions>({
.insertContentAt(range, [ .insertContentAt(range, [
{ {
type: this.name, type: this.name,
attrs: {id: tag}, attrs: props,
}, },
{ {
type: 'text', type: 'text',
text: `${punctuation || ''} `, text: ' ',
}, },
]) ])
.run() .run()
window.getSelection()?.collapseToEnd() window.getSelection()?.collapseToEnd()
}, },
/** allow: ({ state, range }) => {
* 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 $from = state.doc.resolve(range.from)
const type = state.schema.nodes[this.name] const type = state.schema.nodes[this.name]
const allow = !!$from.parent.type.contentMatch.matchType(type) const allow = !!$from.parent.type.contentMatch.matchType(type)
console.log({
allow,
$from,
state,
})
return allow return allow
}, },
findSuggestionMatch({$position}) { findSuggestionMatch: findSuggestionMatch
const text = $position.nodeBefore?.isText && $position.nodeBefore.text
const cursorPosition = $position.pos
if (!text) {
return null
}
return findSuggestionMatch({text, cursorPosition})
},
}, },
} }
}, },
@@ -90,9 +150,9 @@ export const Tags = Node.create<TagOptions>({
inline: true, inline: true,
atom: true, selectable: false,
selectable: true, atom: true,
addAttributes() { addAttributes() {
return { return {
@@ -134,35 +194,56 @@ export const Tags = Node.create<TagOptions>({
] ]
}, },
renderHTML({node, HTMLAttributes}) { renderHTML({ node, HTMLAttributes }) {
if (this.options.renderLabel !== undefined) {
console.warn('renderLabel is deprecated use renderText and renderHTML instead')
return [ return [
'span', 'span',
mergeAttributes( mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes),
{'data-type': this.name},
this.options.HTMLAttributes,
HTMLAttributes,
),
this.options.renderLabel({ this.options.renderLabel({
options: this.options, options: this.options,
node, 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}) { renderText({ node }) {
if (this.options.renderLabel !== undefined) {
console.warn('renderLabel is deprecated use renderText and renderHTML instead')
return this.options.renderLabel({ return this.options.renderLabel({
options: this.options, options: this.options,
node, node,
}) })
}
return this.options.renderText({
options: this.options,
node,
})
}, },
addKeyboardShortcuts() { addKeyboardShortcuts() {
return { return {
Backspace: () => Backspace: () => this.editor.commands.command(({ tr, state }) => {
this.editor.commands.command(({tr, state}) => { let isMention = false
let isTag = false const { selection } = state
const {selection} = state const { empty, anchor } = selection
const {empty, anchor} = selection
if (!empty) { if (!empty) {
return false return false
@@ -170,9 +251,9 @@ export const Tags = Node.create<TagOptions>({
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => { state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
if (node.type.name === this.name) { if (node.type.name === this.name) {
isTag = true isMention = true
tr.insertText( tr.insertText(
this.options.suggestion.char || '', this.options.deleteTriggerWithBackspace ? '' : this.options.suggestion.char || '',
pos, pos,
pos + node.nodeSize, pos + node.nodeSize,
) )
@@ -181,7 +262,7 @@ export const Tags = Node.create<TagOptions>({
} }
}) })
return isTag return isMention
}), }),
} }
}, },
@@ -1,8 +1,5 @@
import { import {TAG_REGEX, TRAILING_PUNCTUATION_REGEX} from '@atproto/api'
HASHTAG_WITH_TRAILING_PUNCTUATION_REGEX, import {findSuggestionMatch as defaultFindSuggestionMatch} from '@tiptap/suggestion'
TRAILING_PUNCTUATION_REGEX,
LEADING_HASH_REGEX,
} from '@atproto/api'
/** /**
* This method eventually receives the `query` property from the result of * 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. * That's why we use the loose regex form that includes trialing punctuation.
* We strip that our later. * We strip that our later.
*
* @see https://github.com/ueberdosis/tiptap/blob/cf2067906f506486c6613f872be8b1fd318526c9/packages/suggestion/src/findSuggestionMatch.ts
*/ */
export function findSuggestionMatch({ export function findSuggestionMatch({
text, $position,
cursorPosition, }: Parameters<typeof defaultFindSuggestionMatch>[0]) {
}: { const text = $position.nodeBefore?.isText && $position.nodeBefore.text
text: string
cursorPosition: number if (!text) {
}) { return null
const match = Array.from( }
text.matchAll(HASHTAG_WITH_TRAILING_PUNCTUATION_REGEX),
).pop() const textFrom = $position.pos - text.length
const match = Array.from(text.matchAll(TAG_REGEX)).pop()
if (!match || match.input === undefined || match.index === undefined) { if (!match || match.input === undefined || match.index === undefined) {
return null return null
} }
const startIndex = cursorPosition - text.length const [fullMatch, , tag] = match
let [matchedString, tagWithTrailingPunctuation] = match
const sanitized = tagWithTrailingPunctuation if (!tag || tag.length === 0 || tag.length > 64) return null
.replace(TRAILING_PUNCTUATION_REGEX, '')
.replace(LEADING_HASH_REGEX, '')
// one of our hashtag spec rules // The absolute position of the match in the document
if (sanitized.length > 64) return null const from = textFrom + fullMatch.indexOf(tag)
const to = from + tag.length + 1
const from = // If the $position is located within the matched substring, return that range
startIndex + match.index + matchedString.indexOf(tagWithTrailingPunctuation) if (from < $position.pos && to >= $position.pos) {
const to = from + tagWithTrailingPunctuation.length
if (from < cursorPosition && to >= cursorPosition) {
return { return {
range: { range: {
from, from,
to, to,
}, },
/** query: tag.replace(TRAILING_PUNCTUATION_REGEX, ''),
* This is passed to the `items({ query })` method configured in text: fullMatch,
* `createTagsAutocomplete`.
*
* We parse out the punctuation later.
*/
query: tagWithTrailingPunctuation.replace(LEADING_HASH_REGEX, ''),
// raw text string
text: matchedString,
} }
} }
@@ -8,7 +8,7 @@ import {
SuggestionKeyDownProps, SuggestionKeyDownProps,
} from '@tiptap/suggestion' } 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 {usePalette} from 'lib/hooks/usePalette'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
@@ -16,25 +16,25 @@ import {parsePunctuationFromTag} from './utils'
type AutocompleteResult = string type AutocompleteResult = string
type ListProps = SuggestionProps<AutocompleteResult> & { type ListProps = SuggestionProps<AutocompleteResult> & {
autocompleteModel: TagsAutocompleteModel // autocompleteModel: TagsAutocompleteModel
} }
type AutocompleteRef = { type AutocompleteRef = {
onKeyDown: (props: SuggestionKeyDownProps) => boolean onKeyDown: (props: SuggestionKeyDownProps) => boolean
} }
export function createTagsAutocomplete({ export function createTagsAutocomplete({
autocompleteModel, // autocompleteModel,
}: { }: {
autocompleteModel: TagsAutocompleteModel // autocompleteModel: TagsAutocompleteModel
}): Omit<SuggestionOptions, 'editor'> { }): Omit<SuggestionOptions, 'editor'> {
return { return {
/** /**
* This `query` param comes from the result of `findSuggestionMatch` * This `query` param comes from the result of `findSuggestionMatch`
*/ */
async items({query}) { async items({query}) {
autocompleteModel.setActive(true) // autocompleteModel.setActive(true)
await autocompleteModel.search(query) // await autocompleteModel.search(query)
return autocompleteModel.suggestions.slice(0, 8) return ['tag']//autocompleteModel.suggestions.slice(0, 8)
}, },
render() { render() {
let component: ReactRenderer<AutocompleteRef> | undefined let component: ReactRenderer<AutocompleteRef> | undefined
@@ -45,7 +45,7 @@ export function createTagsAutocomplete({
component = new ReactRenderer(Autocomplete, { component = new ReactRenderer(Autocomplete, {
props: { props: {
...props, ...props,
autocompleteModel, //autocompleteModel,
}, },
editor: props.editor, editor: props.editor,
}) })
@@ -95,7 +95,7 @@ export function createTagsAutocomplete({
const Autocomplete = forwardRef<AutocompleteRef, ListProps>( const Autocomplete = forwardRef<AutocompleteRef, ListProps>(
function AutocompleteImpl(props, ref) { function AutocompleteImpl(props, ref) {
const {items, command, autocompleteModel} = props const {items, command} = props
const pal = usePalette('default') const pal = usePalette('default')
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
@@ -141,7 +141,7 @@ const Autocomplete = forwardRef<AutocompleteRef, ListProps>(
if (event.key === 'Enter') { if (event.key === 'Enter') {
if (!props.items.length) { if (!props.items.length) {
// no items, use whatever the user typed // no items, use whatever the user typed
commit(autocompleteModel.query) // commit(autocompleteModel.query)
} else { } else {
selectItem(selectedIndex) selectItem(selectedIndex)
} }
@@ -149,7 +149,7 @@ const Autocomplete = forwardRef<AutocompleteRef, ListProps>(
} }
if (event.key === ' ') { if (event.key === ' ') {
commit(autocompleteModel.query) // commit(autocompleteModel.query)
return true return true
} }