@tiptap/extension-mention 2.22.3 → 2.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mention.ts CHANGED
@@ -1,7 +1,11 @@
1
+ import type { Editor } from '@tiptap/core'
1
2
  import { mergeAttributes, Node } from '@tiptap/core'
2
- import { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model'
3
- import { PluginKey } from '@tiptap/pm/state'
4
- import Suggestion, { SuggestionOptions } from '@tiptap/suggestion'
3
+ import type { DOMOutputSpec } from '@tiptap/pm/model'
4
+ import { Node as ProseMirrorNode } from '@tiptap/pm/model'
5
+ import type { SuggestionOptions } from '@tiptap/suggestion'
6
+ import Suggestion from '@tiptap/suggestion'
7
+
8
+ import { getSuggestionOptions } from './utils/get-default-suggestion-attributes.js'
5
9
 
6
10
  // See `addAttributes` below
7
11
  export interface MentionNodeAttrs {
@@ -14,10 +18,15 @@ export interface MentionNodeAttrs {
14
18
  * The label to be rendered by the editor as the displayed text for this mentioned
15
19
  * item, if provided. Stored as a `data-label` attribute. See `renderLabel`.
16
20
  */
17
- label?: string | null;
21
+ label?: string | null
22
+ /**
23
+ * The character that triggers the suggestion, stored as
24
+ * `data-mention-suggestion-char` attribute.
25
+ */
26
+ mentionSuggestionChar?: string
18
27
  }
19
28
 
20
- export type MentionOptions<SuggestionItem = any, Attrs extends Record<string, any> = MentionNodeAttrs> = {
29
+ export interface MentionOptions<SuggestionItem = any, Attrs extends Record<string, any> = MentionNodeAttrs> {
21
30
  /**
22
31
  * The HTML attributes for a mention node.
23
32
  * @default {}
@@ -32,7 +41,11 @@ export type MentionOptions<SuggestionItem = any, Attrs extends Record<string, an
32
41
  * @returns The label
33
42
  * @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
34
43
  */
35
- renderLabel?: (props: { options: MentionOptions<SuggestionItem, Attrs>; node: ProseMirrorNode }) => string
44
+ renderLabel?: (props: {
45
+ options: MentionOptions<SuggestionItem, Attrs>
46
+ node: ProseMirrorNode
47
+ suggestion: SuggestionOptions | null
48
+ }) => string
36
49
 
37
50
  /**
38
51
  * A function to render the text of a mention.
@@ -40,7 +53,11 @@ export type MentionOptions<SuggestionItem = any, Attrs extends Record<string, an
40
53
  * @returns The text
41
54
  * @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
42
55
  */
43
- renderText: (props: { options: MentionOptions<SuggestionItem, Attrs>; node: ProseMirrorNode }) => string
56
+ renderText: (props: {
57
+ options: MentionOptions<SuggestionItem, Attrs>
58
+ node: ProseMirrorNode
59
+ suggestion: SuggestionOptions | null
60
+ }) => string
44
61
 
45
62
  /**
46
63
  * A function to render the HTML of a mention.
@@ -48,7 +65,11 @@ export type MentionOptions<SuggestionItem = any, Attrs extends Record<string, an
48
65
  * @returns The HTML as a ProseMirror DOM Output Spec
49
66
  * @example ({ options, node }) => ['span', { 'data-type': 'mention' }, `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`]
50
67
  */
51
- renderHTML: (props: { options: MentionOptions<SuggestionItem, Attrs>; node: ProseMirrorNode }) => DOMOutputSpec
68
+ renderHTML: (props: {
69
+ options: MentionOptions<SuggestionItem, Attrs>
70
+ node: ProseMirrorNode
71
+ suggestion: SuggestionOptions | null
72
+ }) => DOMOutputSpec
52
73
 
53
74
  /**
54
75
  * Whether to delete the trigger character with backspace.
@@ -57,18 +78,73 @@ export type MentionOptions<SuggestionItem = any, Attrs extends Record<string, an
57
78
  deleteTriggerWithBackspace: boolean
58
79
 
59
80
  /**
60
- * The suggestion options.
81
+ * The suggestion options, when you want to support multiple triggers.
82
+ *
83
+ * With this parameter, you can define multiple types of mention. For example, you can use the `@` character
84
+ * to mention users and the `#` character to mention tags.
85
+ *
86
+ * @default [{ char: '@', pluginKey: MentionPluginKey }]
87
+ * @example [{ char: '@', pluginKey: MentionPluginKey }, { char: '#', pluginKey: new PluginKey('hashtag') }]
88
+ */
89
+ suggestions: Array<Omit<SuggestionOptions<SuggestionItem, Attrs>, 'editor'>>
90
+
91
+ /**
92
+ * The suggestion options, when you want to support only one trigger. To support multiple triggers, use the
93
+ * `suggestions` parameter instead.
94
+ *
61
95
  * @default {}
62
96
  * @example { char: '@', pluginKey: MentionPluginKey, command: ({ editor, range, props }) => { ... } }
63
97
  */
64
98
  suggestion: Omit<SuggestionOptions<SuggestionItem, Attrs>, 'editor'>
65
99
  }
66
100
 
101
+ interface GetSuggestionsOptions {
102
+ editor?: Editor
103
+ options: MentionOptions
104
+ name: string
105
+ }
106
+
107
+ /**
108
+ * Returns the suggestions for the mention extension.
109
+ *
110
+ * @param options The extension options
111
+ * @returns the suggestions
112
+ */
113
+ function getSuggestions(options: GetSuggestionsOptions) {
114
+ return (options.options.suggestions.length ? options.options.suggestions : [options.options.suggestion]).map(
115
+ suggestion => getSuggestionOptions({
116
+ // @ts-ignore `editor` can be `undefined` when converting the document to HTML with the HTML utility
117
+ editor: options.editor,
118
+ overrideSuggestionOptions: suggestion,
119
+ extensionName: options.name,
120
+ char: suggestion.char,
121
+ }),
122
+ )
123
+ }
124
+
67
125
  /**
68
- * The plugin key for the mention plugin.
69
- * @default 'mention'
126
+ * Returns the suggestion options of the mention that has a given character trigger. If not
127
+ * found, it returns the first suggestion.
128
+ *
129
+ * @param options The extension options
130
+ * @param char The character that triggers the mention
131
+ * @returns The suggestion options
70
132
  */
71
- export const MentionPluginKey = new PluginKey('mention')
133
+ function getSuggestionFromChar(options: GetSuggestionsOptions, char: string) {
134
+ const suggestions = getSuggestions(options)
135
+
136
+ const suggestion = suggestions.find(s => s.char === char)
137
+
138
+ if (suggestion) {
139
+ return suggestion
140
+ }
141
+
142
+ if (suggestions.length) {
143
+ return suggestions[0]
144
+ }
145
+
146
+ return null
147
+ }
72
148
 
73
149
  /**
74
150
  * This extension allows you to insert mentions into the editor.
@@ -82,56 +158,19 @@ export const Mention = Node.create<MentionOptions>({
82
158
  addOptions() {
83
159
  return {
84
160
  HTMLAttributes: {},
85
- renderText({ options, node }) {
86
- return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
161
+ renderText({ node, suggestion }) {
162
+ return `${suggestion?.char ?? '@'}${node.attrs.label ?? node.attrs.id}`
87
163
  },
88
164
  deleteTriggerWithBackspace: false,
89
- renderHTML({ options, node }) {
165
+ renderHTML({ options, node, suggestion }) {
90
166
  return [
91
167
  'span',
92
168
  mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
93
- `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`,
169
+ `${suggestion?.char ?? '@'}${node.attrs.label ?? node.attrs.id}`,
94
170
  ]
95
171
  },
96
- suggestion: {
97
- char: '@',
98
- pluginKey: MentionPluginKey,
99
- command: ({ editor, range, props }) => {
100
- // increase range.to by one when the next node is of type "text"
101
- // and starts with a space character
102
- const nodeAfter = editor.view.state.selection.$to.nodeAfter
103
- const overrideSpace = nodeAfter?.text?.startsWith(' ')
104
-
105
- if (overrideSpace) {
106
- range.to += 1
107
- }
108
-
109
- editor
110
- .chain()
111
- .focus()
112
- .insertContentAt(range, [
113
- {
114
- type: this.name,
115
- attrs: props,
116
- },
117
- {
118
- type: 'text',
119
- text: ' ',
120
- },
121
- ])
122
- .run()
123
-
124
- // get reference to `window` object from editor element, to support cross-frame JS usage
125
- editor.view.dom.ownerDocument.defaultView?.getSelection()?.collapseToEnd()
126
- },
127
- allow: ({ state, range }) => {
128
- const $from = state.doc.resolve(range.from)
129
- const type = state.schema.nodes[this.name]
130
- const allow = !!$from.parent.type.contentMatch.matchType(type)
131
-
132
- return allow
133
- },
134
- },
172
+ suggestions: [],
173
+ suggestion: {},
135
174
  }
136
175
  },
137
176
 
@@ -172,6 +211,17 @@ export const Mention = Node.create<MentionOptions>({
172
211
  }
173
212
  },
174
213
  },
214
+
215
+ // When there are multiple types of mentions, this attribute helps distinguish them
216
+ mentionSuggestionChar: {
217
+ default: '@',
218
+ parseHTML: element => element.getAttribute('data-mention-suggestion-char'),
219
+ renderHTML: attributes => {
220
+ return {
221
+ 'data-mention-suggestion-char': attributes.mentionSuggestionChar,
222
+ }
223
+ },
224
+ },
175
225
  }
176
226
  },
177
227
 
@@ -184,6 +234,8 @@ export const Mention = Node.create<MentionOptions>({
184
234
  },
185
235
 
186
236
  renderHTML({ node, HTMLAttributes }) {
237
+ const suggestion = getSuggestionFromChar(this, node.attrs.mentionSuggestionChar)
238
+
187
239
  if (this.options.renderLabel !== undefined) {
188
240
  console.warn('renderLabel is deprecated use renderText and renderHTML instead')
189
241
  return [
@@ -192,15 +244,22 @@ export const Mention = Node.create<MentionOptions>({
192
244
  this.options.renderLabel({
193
245
  options: this.options,
194
246
  node,
247
+ suggestion,
195
248
  }),
196
249
  ]
197
250
  }
198
251
  const mergedOptions = { ...this.options }
199
252
 
200
- mergedOptions.HTMLAttributes = mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes)
253
+ mergedOptions.HTMLAttributes = mergeAttributes(
254
+ { 'data-type': this.name },
255
+ this.options.HTMLAttributes,
256
+ HTMLAttributes,
257
+ )
258
+
201
259
  const html = this.options.renderHTML({
202
260
  options: mergedOptions,
203
261
  node,
262
+ suggestion,
204
263
  })
205
264
 
206
265
  if (typeof html === 'string') {
@@ -214,17 +273,18 @@ export const Mention = Node.create<MentionOptions>({
214
273
  },
215
274
 
216
275
  renderText({ node }) {
276
+ const args = {
277
+ options: this.options,
278
+ node,
279
+ suggestion: getSuggestionFromChar(this, node.attrs.mentionSuggestionChar),
280
+ }
281
+
217
282
  if (this.options.renderLabel !== undefined) {
218
283
  console.warn('renderLabel is deprecated use renderText and renderHTML instead')
219
- return this.options.renderLabel({
220
- options: this.options,
221
- node,
222
- })
284
+ return this.options.renderLabel(args)
223
285
  }
224
- return this.options.renderText({
225
- options: this.options,
226
- node,
227
- })
286
+
287
+ return this.options.renderText(args)
228
288
  },
229
289
 
230
290
  addKeyboardShortcuts() {
@@ -251,17 +311,34 @@ export const Mention = Node.create<MentionOptions>({
251
311
  }
252
312
  })
253
313
 
314
+ // Store node and position for later use
315
+ let mentionNode = new ProseMirrorNode()
316
+ let mentionPos = 0
317
+
318
+ state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
319
+ if (node.type.name === this.name) {
320
+ isMention = true
321
+ mentionNode = node
322
+ mentionPos = pos
323
+ return false
324
+ }
325
+ })
326
+
327
+ if (isMention) {
328
+ tr.insertText(
329
+ this.options.deleteTriggerWithBackspace ? '' : mentionNode.attrs.mentionSuggestionChar,
330
+ mentionPos,
331
+ mentionPos + mentionNode.nodeSize,
332
+ )
333
+ }
334
+
254
335
  return isMention
255
336
  }),
256
337
  }
257
338
  },
258
339
 
259
340
  addProseMirrorPlugins() {
260
- return [
261
- Suggestion({
262
- editor: this.editor,
263
- ...this.options.suggestion,
264
- }),
265
- ]
341
+ // Create a plugin for each suggestion configuration
342
+ return getSuggestions(this).map(Suggestion)
266
343
  },
267
344
  })
@@ -0,0 +1,89 @@
1
+ import type { Editor } from '@tiptap/core'
2
+ import { PluginKey } from '@tiptap/pm/state'
3
+ import type { SuggestionOptions } from '@tiptap/suggestion'
4
+
5
+ /**
6
+ * Arguments for the `getSuggestionOptions` function
7
+ * @see getSuggestionOptions
8
+ */
9
+ export interface GetSuggestionOptionsOptions {
10
+ /**
11
+ * The Tiptap editor instance.
12
+ */
13
+ editor: Editor
14
+ /**
15
+ * The suggestion options configuration provided to the
16
+ * `Mention` extension.
17
+ */
18
+ overrideSuggestionOptions: Omit<SuggestionOptions, 'editor'>
19
+ /**
20
+ * The name of the Mention extension
21
+ */
22
+ extensionName: string
23
+ /**
24
+ * The character that triggers the suggestion.
25
+ * @default '@'
26
+ */
27
+ char?: string
28
+ }
29
+
30
+ /**
31
+ * Returns the suggestion options for a trigger of the Mention extension. These
32
+ * options are used to create a `Suggestion` ProseMirror plugin. Each plugin lets
33
+ * you define a different trigger that opens the Mention menu. For example,
34
+ * you can define a `@` trigger to mention users and a `#` trigger to mention
35
+ * tags.
36
+ *
37
+ * @param param0 The configured options for the suggestion
38
+ * @returns
39
+ */
40
+ export function getSuggestionOptions({
41
+ editor: tiptapEditor,
42
+ overrideSuggestionOptions,
43
+ extensionName,
44
+ char = '@',
45
+ }: GetSuggestionOptionsOptions): SuggestionOptions {
46
+ const pluginKey = new PluginKey()
47
+
48
+ return {
49
+ editor: tiptapEditor,
50
+ char,
51
+ pluginKey,
52
+ command: ({ editor, range, props }: { editor: any; range: any; props: any }) => {
53
+ // increase range.to by one when the next node is of type "text"
54
+ // and starts with a space character
55
+ const nodeAfter = editor.view.state.selection.$to.nodeAfter
56
+ const overrideSpace = nodeAfter?.text?.startsWith(' ')
57
+
58
+ if (overrideSpace) {
59
+ range.to += 1
60
+ }
61
+
62
+ editor
63
+ .chain()
64
+ .focus()
65
+ .insertContentAt(range, [
66
+ {
67
+ type: extensionName,
68
+ attrs: { ...props, mentionSuggestionChar: char },
69
+ },
70
+ {
71
+ type: 'text',
72
+ text: ' ',
73
+ },
74
+ ])
75
+ .run()
76
+
77
+ // get reference to `window` object from editor element, to support cross-frame JS usage
78
+ editor.view.dom.ownerDocument.defaultView?.getSelection()?.collapseToEnd()
79
+ },
80
+ allow: ({ state, range }: { state: any; range: any }) => {
81
+ const $from = state.doc.resolve(range.from)
82
+ const type = state.schema.nodes[extensionName]
83
+ const allow = !!$from.parent.type.contentMatch.matchType(type)
84
+
85
+ return allow
86
+ },
87
+ ...overrideSuggestionOptions,
88
+ }
89
+ }