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 {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',
@@ -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<string, any>
renderLabel: (props: {options: TagOptions; node: ProseMirrorNode}) => string
suggestion: Omit<SuggestionOptions, 'editor'>
// 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<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 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() {
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<TagOptions>({
.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<TagOptions>({
inline: true,
atom: true,
selectable: false,
selectable: true,
atom: true,
addAttributes() {
return {
@@ -134,23 +194,45 @@ export const Tags = Node.create<TagOptions>({
]
},
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<TagOptions>({
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
}),
}
},
@@ -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<typeof defaultFindSuggestionMatch>[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,
}
}
@@ -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<AutocompleteResult> & {
autocompleteModel: TagsAutocompleteModel
// autocompleteModel: TagsAutocompleteModel
}
type AutocompleteRef = {
onKeyDown: (props: SuggestionKeyDownProps) => boolean
}
export function createTagsAutocomplete({
autocompleteModel,
// autocompleteModel,
}: {
autocompleteModel: TagsAutocompleteModel
// autocompleteModel: TagsAutocompleteModel
}): Omit<SuggestionOptions, 'editor'> {
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<AutocompleteRef> | 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<AutocompleteRef, ListProps>(
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<AutocompleteRef, ListProps>(
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<AutocompleteRef, ListProps>(
}
if (event.key === ' ') {
commit(autocompleteModel.query)
// commit(autocompleteModel.query)
return true
}