WIP, can't add spaces after tag
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user