@tiptap/extension-mention 2.22.2 → 2.23.0

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,10 @@
1
1
  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'
2
+ import type { DOMOutputSpec } from '@tiptap/pm/model'
3
+ import { Node as ProseMirrorNode } from '@tiptap/pm/model'
4
+ import type { SuggestionOptions } from '@tiptap/suggestion'
5
+ import Suggestion from '@tiptap/suggestion'
6
+
7
+ import { getSuggestionOptions } from './utils/get-default-suggestion-attributes.js'
5
8
 
6
9
  // See `addAttributes` below
7
10
  export interface MentionNodeAttrs {
@@ -14,10 +17,15 @@ export interface MentionNodeAttrs {
14
17
  * The label to be rendered by the editor as the displayed text for this mentioned
15
18
  * item, if provided. Stored as a `data-label` attribute. See `renderLabel`.
16
19
  */
17
- label?: string | null;
20
+ label?: string | null
21
+ /**
22
+ * The character that triggers the suggestion, stored as
23
+ * `data-mention-suggestion-char` attribute.
24
+ */
25
+ mentionSuggestionChar?: string
18
26
  }
19
27
 
20
- export type MentionOptions<SuggestionItem = any, Attrs extends Record<string, any> = MentionNodeAttrs> = {
28
+ export interface MentionOptions<SuggestionItem = any, Attrs extends Record<string, any> = MentionNodeAttrs> {
21
29
  /**
22
30
  * The HTML attributes for a mention node.
23
31
  * @default {}
@@ -32,7 +40,11 @@ export type MentionOptions<SuggestionItem = any, Attrs extends Record<string, an
32
40
  * @returns The label
33
41
  * @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
34
42
  */
35
- renderLabel?: (props: { options: MentionOptions<SuggestionItem, Attrs>; node: ProseMirrorNode }) => string
43
+ renderLabel?: (props: {
44
+ options: MentionOptions<SuggestionItem, Attrs>
45
+ node: ProseMirrorNode
46
+ suggestion: SuggestionOptions | null
47
+ }) => string
36
48
 
37
49
  /**
38
50
  * A function to render the text of a mention.
@@ -40,7 +52,11 @@ export type MentionOptions<SuggestionItem = any, Attrs extends Record<string, an
40
52
  * @returns The text
41
53
  * @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
42
54
  */
43
- renderText: (props: { options: MentionOptions<SuggestionItem, Attrs>; node: ProseMirrorNode }) => string
55
+ renderText: (props: {
56
+ options: MentionOptions<SuggestionItem, Attrs>
57
+ node: ProseMirrorNode
58
+ suggestion: SuggestionOptions | null
59
+ }) => string
44
60
 
45
61
  /**
46
62
  * A function to render the HTML of a mention.
@@ -48,7 +64,11 @@ export type MentionOptions<SuggestionItem = any, Attrs extends Record<string, an
48
64
  * @returns The HTML as a ProseMirror DOM Output Spec
49
65
  * @example ({ options, node }) => ['span', { 'data-type': 'mention' }, `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`]
50
66
  */
51
- renderHTML: (props: { options: MentionOptions<SuggestionItem, Attrs>; node: ProseMirrorNode }) => DOMOutputSpec
67
+ renderHTML: (props: {
68
+ options: MentionOptions<SuggestionItem, Attrs>
69
+ node: ProseMirrorNode
70
+ suggestion: SuggestionOptions | null
71
+ }) => DOMOutputSpec
52
72
 
53
73
  /**
54
74
  * Whether to delete the trigger character with backspace.
@@ -57,7 +77,20 @@ export type MentionOptions<SuggestionItem = any, Attrs extends Record<string, an
57
77
  deleteTriggerWithBackspace: boolean
58
78
 
59
79
  /**
60
- * The suggestion options.
80
+ * The suggestion options, when you want to support multiple triggers.
81
+ *
82
+ * With this parameter, you can define multiple types of mention. For example, you can use the `@` character
83
+ * to mention users and the `#` character to mention tags.
84
+ *
85
+ * @default [{ char: '@', pluginKey: MentionPluginKey }]
86
+ * @example [{ char: '@', pluginKey: MentionPluginKey }, { char: '#', pluginKey: new PluginKey('hashtag') }]
87
+ */
88
+ suggestions: Array<Omit<SuggestionOptions<SuggestionItem, Attrs>, 'editor'>>
89
+
90
+ /**
91
+ * The suggestion options, when you want to support only one trigger. To support multiple triggers, use the
92
+ * `suggestions` parameter instead.
93
+ *
61
94
  * @default {}
62
95
  * @example { char: '@', pluginKey: MentionPluginKey, command: ({ editor, range, props }) => { ... } }
63
96
  */
@@ -65,73 +98,56 @@ export type MentionOptions<SuggestionItem = any, Attrs extends Record<string, an
65
98
  }
66
99
 
67
100
  /**
68
- * The plugin key for the mention plugin.
69
- * @default 'mention'
101
+ * Storage properties or the Mention extension
70
102
  */
71
- export const MentionPluginKey = new PluginKey('mention')
103
+ export interface MentionStorage<SuggestionItem = any, Attrs extends Record<string, any> = MentionNodeAttrs> {
104
+ /**
105
+ * The list of suggestions that will trigger the mention.
106
+ */
107
+ suggestions: Array<SuggestionOptions<SuggestionItem, Attrs>>
108
+
109
+ /**
110
+ * Returns the suggestion options of the mention that has a given character trigger. If not
111
+ * found, it returns the first suggestion.
112
+ *
113
+ * @param char The character that triggers the mention
114
+ * @returns The suggestion options
115
+ */
116
+ getSuggestionFromChar: (char: string) => SuggestionOptions<SuggestionItem, Attrs> | null
117
+ }
72
118
 
73
119
  /**
74
120
  * This extension allows you to insert mentions into the editor.
75
121
  * @see https://www.tiptap.dev/api/extensions/mention
76
122
  */
77
- export const Mention = Node.create<MentionOptions>({
123
+ export const Mention = Node.create<MentionOptions, MentionStorage>({
78
124
  name: 'mention',
79
125
 
80
126
  priority: 101,
81
127
 
128
+ addStorage() {
129
+ return {
130
+ suggestions: [],
131
+ getSuggestionFromChar: () => null,
132
+ }
133
+ },
134
+
82
135
  addOptions() {
83
136
  return {
84
137
  HTMLAttributes: {},
85
- renderText({ options, node }) {
86
- return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
138
+ renderText({ node, suggestion }) {
139
+ return `${suggestion?.char}${node.attrs.label ?? node.attrs.id}`
87
140
  },
88
141
  deleteTriggerWithBackspace: false,
89
- renderHTML({ options, node }) {
142
+ renderHTML({ options, node, suggestion }) {
90
143
  return [
91
144
  'span',
92
145
  mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
93
- `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`,
146
+ `${suggestion?.char}${node.attrs.label ?? node.attrs.id}`,
94
147
  ]
95
148
  },
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
- },
149
+ suggestions: [],
150
+ suggestion: {},
135
151
  }
136
152
  },
137
153
 
@@ -172,6 +188,17 @@ export const Mention = Node.create<MentionOptions>({
172
188
  }
173
189
  },
174
190
  },
191
+
192
+ // When there are multiple types of mentions, this attribute helps distinguish them
193
+ mentionSuggestionChar: {
194
+ default: '@',
195
+ parseHTML: element => element.getAttribute('data-mention-suggestion-char'),
196
+ renderHTML: attributes => {
197
+ return {
198
+ 'data-mention-suggestion-char': attributes.mentionSuggestionChar,
199
+ }
200
+ },
201
+ },
175
202
  }
176
203
  },
177
204
 
@@ -184,6 +211,12 @@ export const Mention = Node.create<MentionOptions>({
184
211
  },
185
212
 
186
213
  renderHTML({ node, HTMLAttributes }) {
214
+ // We cannot use the `this.storage` property here because, when accessed this method,
215
+ // it returns the initial value of the extension storage
216
+ const suggestion = (this.editor?.extensionStorage as unknown as Record<string, MentionStorage>)?.[
217
+ this.name
218
+ ]?.getSuggestionFromChar(node.attrs.mentionSuggestionChar)
219
+
187
220
  if (this.options.renderLabel !== undefined) {
188
221
  console.warn('renderLabel is deprecated use renderText and renderHTML instead')
189
222
  return [
@@ -192,15 +225,22 @@ export const Mention = Node.create<MentionOptions>({
192
225
  this.options.renderLabel({
193
226
  options: this.options,
194
227
  node,
228
+ suggestion,
195
229
  }),
196
230
  ]
197
231
  }
198
232
  const mergedOptions = { ...this.options }
199
233
 
200
- mergedOptions.HTMLAttributes = mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes)
234
+ mergedOptions.HTMLAttributes = mergeAttributes(
235
+ { 'data-type': this.name },
236
+ this.options.HTMLAttributes,
237
+ HTMLAttributes,
238
+ )
239
+
201
240
  const html = this.options.renderHTML({
202
241
  options: mergedOptions,
203
242
  node,
243
+ suggestion,
204
244
  })
205
245
 
206
246
  if (typeof html === 'string') {
@@ -214,17 +254,20 @@ export const Mention = Node.create<MentionOptions>({
214
254
  },
215
255
 
216
256
  renderText({ node }) {
257
+ const args = {
258
+ options: this.options,
259
+ node,
260
+ suggestion: (this.editor?.extensionStorage as unknown as Record<string, MentionStorage>)?.[
261
+ this.name
262
+ ]?.getSuggestionFromChar(node.attrs.mentionSuggestionChar),
263
+ }
264
+
217
265
  if (this.options.renderLabel !== undefined) {
218
266
  console.warn('renderLabel is deprecated use renderText and renderHTML instead')
219
- return this.options.renderLabel({
220
- options: this.options,
221
- node,
222
- })
267
+ return this.options.renderLabel(args)
223
268
  }
224
- return this.options.renderText({
225
- options: this.options,
226
- node,
227
- })
269
+
270
+ return this.options.renderText(args)
228
271
  },
229
272
 
230
273
  addKeyboardShortcuts() {
@@ -251,17 +294,58 @@ export const Mention = Node.create<MentionOptions>({
251
294
  }
252
295
  })
253
296
 
297
+ // Store node and position for later use
298
+ let mentionNode = new ProseMirrorNode()
299
+ let mentionPos = 0
300
+
301
+ state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
302
+ if (node.type.name === this.name) {
303
+ isMention = true
304
+ mentionNode = node
305
+ mentionPos = pos
306
+ return false
307
+ }
308
+ })
309
+
310
+ if (isMention) {
311
+ tr.insertText(
312
+ this.options.deleteTriggerWithBackspace ? '' : mentionNode.attrs.mentionSuggestionChar,
313
+ mentionPos,
314
+ mentionPos + mentionNode.nodeSize,
315
+ )
316
+ }
317
+
254
318
  return isMention
255
319
  }),
256
320
  }
257
321
  },
258
322
 
259
323
  addProseMirrorPlugins() {
260
- return [
261
- Suggestion({
262
- editor: this.editor,
263
- ...this.options.suggestion,
264
- }),
265
- ]
324
+ // Create a plugin for each suggestion configuration
325
+ return this.storage.suggestions.map(Suggestion)
326
+ },
327
+
328
+ onBeforeCreate() {
329
+ this.storage.suggestions = (
330
+ this.options.suggestions.length ? this.options.suggestions : [this.options.suggestion]
331
+ ).map(suggestion => getSuggestionOptions({
332
+ editor: this.editor,
333
+ overrideSuggestionOptions: suggestion,
334
+ extensionName: this.name,
335
+ char: suggestion.char,
336
+ }))
337
+
338
+ this.storage.getSuggestionFromChar = char => {
339
+ const suggestion = this.storage.suggestions.find(s => s.char === char)
340
+
341
+ if (suggestion) {
342
+ return suggestion
343
+ }
344
+ if (this.storage.suggestions.length) {
345
+ return this.storage.suggestions[0]
346
+ }
347
+
348
+ return null
349
+ }
266
350
  },
267
351
  })
@@ -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
+ }