@tiptap/suggestion 3.26.1 → 3.27.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/suggestion.ts CHANGED
@@ -1,243 +1,41 @@
1
- import type { Editor, Range } from '@tiptap/core'
1
+ import type { Range } from '@tiptap/core'
2
2
  import type { EditorState, Transaction } from '@tiptap/pm/state'
3
3
  import { Plugin, PluginKey } from '@tiptap/pm/state'
4
4
  import type { EditorView } from '@tiptap/pm/view'
5
- import { Decoration, DecorationSet } from '@tiptap/pm/view'
6
5
 
7
6
  import type { SuggestionMatch } from './findSuggestionMatch.js'
8
7
  import { findSuggestionMatch as defaultFindSuggestionMatch } from './findSuggestionMatch.js'
8
+ import {
9
+ clientRectFor as clientRectForHelper,
10
+ dispatchExit as dispatchExitHelper,
11
+ shouldKeepDismissed as shouldKeepDismissedHelper,
12
+ } from './helpers.js'
13
+ import { createSuggestionProps } from './plugin/props.js'
14
+ import { createSuggestionState } from './plugin/state.js'
15
+ import { createSuggestionView } from './plugin/view.js'
16
+ import type { SuggestionOptions } from './types.js'
17
+
18
+ export type {
19
+ SuggestionFloatingUiConfig,
20
+ SuggestionFloatingUiOptions,
21
+ SuggestionKeyDownProps,
22
+ SuggestionMount,
23
+ SuggestionMountOptions,
24
+ SuggestionOptions,
25
+ SuggestionPlacement,
26
+ SuggestionPositionData,
27
+ SuggestionProps,
28
+ } from './types.js'
9
29
 
10
- /**
11
- * Returns true if the transaction inserted any whitespace or newline character.
12
- * Used to determine when a dismissed suggestion should become active again.
13
- */
14
- function hasInsertedWhitespace(transaction: Transaction): boolean {
15
- if (!transaction.docChanged) {
16
- return false
17
- }
18
- return transaction.steps.some(step => {
19
- const slice = (step as any).slice
20
- if (!slice?.content) {
21
- return false
22
- }
23
- // textBetween with '\n' as block separator catches both inline spaces and newlines
24
- const inserted = slice.content.textBetween(0, slice.content.size, '\n')
25
- return /\s/.test(inserted)
26
- })
27
- }
28
-
29
- export interface SuggestionOptions<I = any, TSelected = any> {
30
- /**
31
- * The plugin key for the suggestion plugin.
32
- * @default 'suggestion'
33
- * @example 'mention'
34
- */
35
- pluginKey?: PluginKey
36
-
37
- /**
38
- * A function that returns a boolean to indicate if the suggestion should be active.
39
- * This is useful to prevent suggestions from opening for remote users in collaborative environments.
40
- * @param props The props object.
41
- * @param props.editor The editor instance.
42
- * @param props.range The range of the suggestion.
43
- * @param props.query The current suggestion query.
44
- * @param props.text The current suggestion text.
45
- * @param props.transaction The current transaction.
46
- * @returns {boolean}
47
- * @example ({ transaction }) => isChangeOrigin(transaction)
48
- */
49
- shouldShow?: (props: {
50
- editor: Editor
51
- range: Range
52
- query: string
53
- text: string
54
- transaction: Transaction
55
- }) => boolean
56
-
57
- /**
58
- * Controls when a dismissed suggestion becomes active again.
59
- * Return `true` to clear the dismissed context for the current transaction.
60
- */
61
- shouldResetDismissed?: (props: {
62
- editor: Editor
63
- state: EditorState
64
- range: Range
65
- match: Exclude<SuggestionMatch, null>
66
- transaction: Transaction
67
- allowSpaces: boolean
68
- }) => boolean
69
-
70
- /**
71
- * The editor instance.
72
- * @default null
73
- */
74
- editor: Editor
75
-
76
- /**
77
- * The character that triggers the suggestion.
78
- * @default '@'
79
- * @example '#'
80
- */
81
- char?: string
82
-
83
- /**
84
- * Allow spaces in the suggestion query. Not compatible with `allowToIncludeChar`. Will be disabled if `allowToIncludeChar` is set to `true`.
85
- * @default false
86
- * @example true
87
- */
88
- allowSpaces?: boolean
89
-
90
- /**
91
- * Allow the character to be included in the suggestion query. Not compatible with `allowSpaces`.
92
- * @default false
93
- */
94
- allowToIncludeChar?: boolean
95
-
96
- /**
97
- * Allow prefixes in the suggestion query.
98
- * @default [' ']
99
- * @example [' ', '@']
100
- */
101
- allowedPrefixes?: string[] | null
102
-
103
- /**
104
- * Only match suggestions at the start of the line.
105
- * @default false
106
- * @example true
107
- */
108
- startOfLine?: boolean
109
-
110
- /**
111
- * The tag name of the decoration node.
112
- * @default 'span'
113
- * @example 'div'
114
- */
115
- decorationTag?: string
116
-
117
- /**
118
- * The class name of the decoration node.
119
- * @default 'suggestion'
120
- * @example 'mention'
121
- */
122
- decorationClass?: string
123
-
124
- /**
125
- * Creates a decoration with the provided content.
126
- * @param decorationContent - The content to display in the decoration
127
- * @default "" - Creates an empty decoration if no content provided
128
- */
129
- decorationContent?: string
130
-
131
- /**
132
- * The class name of the decoration node when it is empty.
133
- * @default 'is-empty'
134
- * @example 'is-empty'
135
- */
136
- decorationEmptyClass?: string
137
-
138
- /**
139
- * A function that is called when a suggestion is selected.
140
- * @param props The props object.
141
- * @param props.editor The editor instance.
142
- * @param props.range The range of the suggestion.
143
- * @param props.props The props of the selected suggestion.
144
- * @returns void
145
- * @example ({ editor, range, props }) => { props.command(props.props) }
146
- */
147
- command?: (props: { editor: Editor; range: Range; props: TSelected }) => void
148
-
149
- /**
150
- * A function that returns the suggestion items in form of an array.
151
- * @param props The props object.
152
- * @param props.editor The editor instance.
153
- * @param props.query The current suggestion query.
154
- * @returns An array of suggestion items.
155
- * @example ({ editor, query }) => [{ id: 1, label: 'John Doe' }]
156
- */
157
- items?: (props: { query: string; editor: Editor }) => I[] | Promise<I[]>
158
-
159
- /**
160
- * The render function for the suggestion.
161
- * @returns An object with render functions.
162
- */
163
- render?: () => {
164
- onBeforeStart?: (props: SuggestionProps<I, TSelected>) => void
165
- onStart?: (props: SuggestionProps<I, TSelected>) => void
166
- onBeforeUpdate?: (props: SuggestionProps<I, TSelected>) => void
167
- onUpdate?: (props: SuggestionProps<I, TSelected>) => void
168
- onExit?: (props: SuggestionProps<I, TSelected>) => void
169
- onKeyDown?: (props: SuggestionKeyDownProps) => boolean
170
- }
171
-
172
- /**
173
- * A function that returns a boolean to indicate if the suggestion should be active.
174
- * @param props The props object.
175
- * @returns {boolean}
176
- */
177
- allow?: (props: {
178
- editor: Editor
179
- state: EditorState
180
- range: Range
181
- isActive?: boolean
182
- }) => boolean
183
- findSuggestionMatch?: typeof defaultFindSuggestionMatch
184
- }
185
-
186
- export interface SuggestionProps<I = any, TSelected = any> {
187
- /**
188
- * The editor instance.
189
- */
190
- editor: Editor
191
-
192
- /**
193
- * The range of the suggestion.
194
- */
195
- range: Range
196
-
197
- /**
198
- * The current suggestion query.
199
- */
200
- query: string
201
-
202
- /**
203
- * The current suggestion text.
204
- */
205
- text: string
206
-
207
- /**
208
- * The suggestion items array.
209
- */
210
- items: I[]
211
-
212
- /**
213
- * A function that is called when a suggestion is selected.
214
- * @param props The props object.
215
- * @returns void
216
- */
217
- command: (props: TSelected) => void
218
-
219
- /**
220
- * The decoration node HTML element
221
- * @default null
222
- */
223
- decorationNode: Element | null
224
-
225
- /**
226
- * The function that returns the client rect
227
- * @default null
228
- * @example () => new DOMRect(0, 0, 0, 0)
229
- */
230
- clientRect?: (() => DOMRect | null) | null
231
- }
30
+ export const SuggestionPluginKey = new PluginKey('suggestion')
232
31
 
233
- export interface SuggestionKeyDownProps {
234
- view: EditorView
235
- event: KeyboardEvent
236
- range: Range
32
+ type ShouldKeepDismissedProps = {
33
+ match: Exclude<SuggestionMatch, null>
34
+ dismissedRange: Range
35
+ state: EditorState
36
+ transaction: Transaction
237
37
  }
238
38
 
239
- export const SuggestionPluginKey = new PluginKey('suggestion')
240
-
241
39
  /**
242
40
  * This utility allows you to create suggestions.
243
41
  * @see https://tiptap.dev/api/utilities/suggestion
@@ -256,397 +54,90 @@ export function Suggestion<I = any, TSelected = any>({
256
54
  decorationEmptyClass = 'is-empty',
257
55
  command = () => null,
258
56
  items = () => [],
57
+ minQueryLength = 0,
58
+ debounce = 0,
59
+ initialItems,
60
+ placement = 'bottom-start',
61
+ offset: offsetOption = {},
62
+ container,
63
+ flip = true,
64
+ floatingUi,
65
+ dismissOnOutsideClick = true,
259
66
  render = () => ({}),
260
67
  allow = () => true,
261
68
  findSuggestionMatch = defaultFindSuggestionMatch,
262
69
  shouldShow,
263
70
  shouldResetDismissed,
264
71
  }: SuggestionOptions<I, TSelected>) {
265
- let props: SuggestionProps<I, TSelected> | undefined
266
72
  const renderer = render?.()
267
73
  const effectiveAllowSpaces = allowSpaces && !allowToIncludeChar
268
74
 
269
- // Gets the DOM rectangle corresponding to the current editor cursor anchor position
270
- // Calculates screen coordinates based on Tiptap's cursor position and converts to a DOMRect object
271
- const getAnchorClientRect = () => {
272
- const pos = editor.state.selection.$anchor.pos
273
- const coords = editor.view.coordsAtPos(pos)
274
- const { top, right, bottom, left } = coords
275
-
276
- try {
277
- return new DOMRect(left, top, right - left, bottom - top)
278
- } catch {
279
- return null
280
- }
281
- }
282
-
283
- // Helper to create a clientRect callback for a given decoration node.
284
- // Returns null when no decoration node is present. Uses the pluginKey's
285
- // state to resolve the current decoration node on demand, avoiding a
286
- // duplicated implementation in multiple places.
287
- const clientRectFor = (view: EditorView, decorationNode: Element | null) => {
288
- if (!decorationNode) {
289
- return getAnchorClientRect
290
- }
291
-
292
- return () => {
293
- const state = pluginKey.getState(editor.state)
294
- const decorationId = state?.decorationId
295
- const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`)
296
-
297
- return currentDecorationNode?.getBoundingClientRect() || null
298
- }
299
- }
300
-
301
- const shouldKeepDismissed = ({
302
- match,
303
- dismissedRange,
304
- state,
305
- transaction,
306
- }: {
307
- match: Exclude<SuggestionMatch, null>
308
- dismissedRange: Range
309
- state: EditorState
310
- transaction: Transaction
311
- }) => {
312
- if (
313
- shouldResetDismissed?.({
314
- editor,
315
- state,
316
- range: dismissedRange,
317
- match,
318
- transaction,
319
- allowSpaces: effectiveAllowSpaces,
320
- })
321
- ) {
322
- return false
323
- }
324
-
325
- if (effectiveAllowSpaces) {
326
- return match.range.from === dismissedRange.from
327
- }
328
-
329
- return match.range.from === dismissedRange.from && !hasInsertedWhitespace(transaction)
75
+ const clientRectFor = (view: EditorView, decorationNode: Element | null) =>
76
+ clientRectForHelper(editor, view, decorationNode, pluginKey)
77
+
78
+ // helper to check if the dismissed suggestion should stay dismissed, with access to editor and options
79
+ function shouldKeepDismissed(props: ShouldKeepDismissedProps) {
80
+ return shouldKeepDismissedHelper({
81
+ ...props,
82
+ editor,
83
+ shouldResetDismissed,
84
+ effectiveAllowSpaces,
85
+ })
330
86
  }
331
87
 
332
- // small helper used internally by the view to dispatch an exit
333
- function dispatchExit(view: EditorView, pluginKeyRef: PluginKey) {
334
- try {
335
- const state = pluginKey.getState(view.state)
336
- const decorationNode = state?.decorationId
337
- ? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`)
338
- : null
88
+ const dispatchExit = (view: EditorView) =>
89
+ dispatchExitHelper({
90
+ view,
91
+ pluginKeyRef: pluginKey,
92
+ })
339
93
 
340
- const exitProps: SuggestionProps = {
341
- // @ts-ignore editor is available in closure
342
- editor,
343
- range: state?.range || { from: 0, to: 0 },
344
- query: state?.query || null,
345
- text: state?.text || null,
346
- items: [],
347
- command: commandProps => {
348
- return command({
349
- editor,
350
- range: state?.range || { from: 0, to: 0 },
351
- props: commandProps as any,
352
- })
353
- },
354
- decorationNode,
355
- clientRect: clientRectFor(view, decorationNode),
356
- }
357
-
358
- renderer?.onExit?.(exitProps)
359
- } catch {
360
- // ignore errors from consumer renderers
361
- }
362
-
363
- const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })
364
- // Dispatch a metadata-only transaction to signal the plugin to exit
365
- view.dispatch(tr)
366
- }
367
-
368
- const plugin: Plugin<any> = new Plugin({
94
+ return new Plugin({
369
95
  key: pluginKey,
370
96
 
371
- view() {
372
- return {
373
- update: async (view, prevState) => {
374
- const prev = this.key?.getState(prevState)
375
- const next = this.key?.getState(view.state)
376
-
377
- // See how the state changed
378
- const moved = prev.active && next.active && prev.range.from !== next.range.from
379
- const started = !prev.active && next.active
380
- const stopped = prev.active && !next.active
381
- const changed = !started && !stopped && prev.query !== next.query
382
-
383
- const handleStart = started || (moved && changed)
384
- const handleChange = changed || moved
385
- const handleExit = stopped || (moved && changed)
386
-
387
- // Cancel when suggestion isn't active
388
- if (!handleStart && !handleChange && !handleExit) {
389
- return
390
- }
391
-
392
- const state = handleExit && !handleStart ? prev : next
393
- const decorationNode = view.dom.querySelector(
394
- `[data-decoration-id="${state.decorationId}"]`,
395
- )
396
-
397
- props = {
398
- editor,
399
- range: state.range,
400
- query: state.query,
401
- text: state.text,
402
- items: [],
403
- command: commandProps => {
404
- return command({
405
- editor,
406
- range: state.range,
407
- props: commandProps,
408
- })
409
- },
410
- decorationNode,
411
- clientRect: clientRectFor(view, decorationNode),
412
- }
413
-
414
- if (handleStart) {
415
- renderer?.onBeforeStart?.(props)
416
- }
417
-
418
- if (handleChange) {
419
- renderer?.onBeforeUpdate?.(props)
420
- }
421
-
422
- if (handleChange || handleStart) {
423
- props.items = await items({
424
- editor,
425
- query: state.query,
426
- })
427
- }
428
-
429
- if (handleExit) {
430
- renderer?.onExit?.(props)
431
- }
432
-
433
- if (handleChange) {
434
- renderer?.onUpdate?.(props)
435
- }
436
-
437
- if (handleStart) {
438
- renderer?.onStart?.(props)
439
- }
440
- },
441
-
442
- destroy: () => {
443
- if (!props) {
444
- return
445
- }
446
-
447
- renderer?.onExit?.(props)
448
- },
449
- }
450
- },
451
-
452
- state: {
453
- // Initialize the plugin's internal state.
454
- init() {
455
- const state: {
456
- active: boolean
457
- range: Range
458
- query: null | string
459
- text: null | string
460
- composing: boolean
461
- decorationId?: string | null
462
- dismissedRange: Range | null
463
- } = {
464
- active: false,
465
- range: {
466
- from: 0,
467
- to: 0,
468
- },
469
- query: null,
470
- text: null,
471
- composing: false,
472
- dismissedRange: null,
473
- }
474
-
475
- return state
476
- },
477
-
478
- // Apply changes to the plugin state from a view transaction.
479
- apply(transaction, prev, _oldState, state) {
480
- const { isEditable } = editor
481
- const { composing } = editor.view
482
- const { selection } = transaction
483
- const { empty, from } = selection
484
- const next = { ...prev }
485
-
486
- // If a transaction carries the exit meta for this plugin, immediately
487
- // deactivate the suggestion. This allows metadata-only transactions
488
- // (dispatched by escape or programmatic exit) to deterministically
489
- // clear decorations without changing the document.
490
- const meta = transaction.getMeta(pluginKey)
491
- if (meta && meta.exit) {
492
- next.active = false
493
- next.decorationId = null
494
- next.range = { from: 0, to: 0 }
495
- next.query = null
496
- next.text = null
497
- next.dismissedRange = prev.active ? { ...prev.range } : prev.dismissedRange
498
-
499
- return next
500
- }
501
-
502
- next.composing = composing
503
-
504
- if (transaction.docChanged && next.dismissedRange !== null) {
505
- next.dismissedRange = {
506
- from: transaction.mapping.map(next.dismissedRange.from),
507
- to: transaction.mapping.map(next.dismissedRange.to),
508
- }
509
- }
510
-
511
- // We can only be suggesting if the view is editable, and:
512
- // * there is no selection, or
513
- // * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)
514
- if (isEditable && (empty || editor.view.composing)) {
515
- // Reset active state if we just left the previous suggestion range
516
- if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {
517
- next.active = false
518
- }
519
-
520
- // Try to match against where our cursor currently is
521
- const match = findSuggestionMatch({
522
- char,
523
- allowSpaces,
524
- allowToIncludeChar,
525
- allowedPrefixes,
526
- startOfLine,
527
- $position: selection.$from,
528
- })
529
- const decorationId = `id_${Math.floor(Math.random() * 0xffffffff)}`
530
-
531
- // If we found a match, update the current state to show it
532
- if (
533
- match &&
534
- allow({
535
- editor,
536
- state,
537
- range: match.range,
538
- isActive: prev.active,
539
- }) &&
540
- (!shouldShow ||
541
- shouldShow({
542
- editor,
543
- range: match.range,
544
- query: match.query,
545
- text: match.text,
546
- transaction,
547
- }))
548
- ) {
549
- if (
550
- next.dismissedRange !== null &&
551
- !shouldKeepDismissed({
552
- match,
553
- dismissedRange: next.dismissedRange,
554
- state,
555
- transaction,
556
- })
557
- ) {
558
- next.dismissedRange = null
559
- }
560
-
561
- if (next.dismissedRange === null) {
562
- next.active = true
563
- next.decorationId = prev.decorationId ? prev.decorationId : decorationId
564
- next.range = match.range
565
- next.query = match.query
566
- next.text = match.text
567
- } else {
568
- next.active = false
569
- }
570
- } else {
571
- if (!match) {
572
- next.dismissedRange = null
573
- }
574
- next.active = false
575
- }
576
- } else {
577
- next.active = false
578
- }
579
-
580
- // Make sure to empty the range if suggestion is inactive
581
- if (!next.active) {
582
- next.decorationId = null
583
- next.range = { from: 0, to: 0 }
584
- next.query = null
585
- next.text = null
586
- }
587
-
588
- return next
589
- },
590
- },
591
-
592
- props: {
593
- // Call the keydown hook if suggestion is active.
594
- handleKeyDown(view, event) {
595
- const { active, range } = plugin.getState(view.state)
596
-
597
- if (!active) {
598
- return false
599
- }
600
-
601
- // If Escape is pressed, call onExit and dispatch a metadata-only
602
- // transaction to unset the suggestion state. This provides a safe
603
- // and deterministic way to exit the suggestion without altering the
604
- // document (avoids transaction mapping/mismatch issues).
605
- if (event.key === 'Escape' || event.key === 'Esc') {
606
- const state = plugin.getState(view.state)
607
-
608
- // Allow the consumer to react to Escape, but always clear the
609
- // suggestion state afterward so the decoration is removed too.
610
- renderer?.onKeyDown?.({ view, event, range: state.range })
611
-
612
- // dispatch metadata-only transaction to unset the plugin state
613
- dispatchExit(view, pluginKey)
614
-
615
- return true
616
- }
617
-
618
- const handled = renderer?.onKeyDown?.({ view, event, range }) || false
619
- return handled
620
- },
621
-
622
- // Setup decorator on the currently active suggestion.
623
- decorations(state) {
624
- const { active, range, decorationId, query } = plugin.getState(state)
625
-
626
- if (!active) {
627
- return null
628
- }
629
-
630
- const isEmpty = !query?.length
631
- const classNames = [decorationClass]
632
-
633
- if (isEmpty) {
634
- classNames.push(decorationEmptyClass)
635
- }
636
-
637
- return DecorationSet.create(state.doc, [
638
- Decoration.inline(range.from, range.to, {
639
- nodeName: decorationTag,
640
- class: classNames.join(' '),
641
- 'data-decoration-id': decorationId,
642
- 'data-decoration-content': decorationContent,
643
- }),
644
- ])
645
- },
646
- },
97
+ view: () =>
98
+ createSuggestionView({
99
+ editor,
100
+ pluginKey,
101
+ items,
102
+ renderer,
103
+ minQueryLength,
104
+ debounce,
105
+ initialItems,
106
+ placement,
107
+ offset: offsetOption,
108
+ container,
109
+ flip,
110
+ floatingUi,
111
+ dismissOnOutsideClick,
112
+ command,
113
+ clientRectFor,
114
+ dispatchExit,
115
+ }),
116
+
117
+ state: createSuggestionState({
118
+ editor,
119
+ char,
120
+ effectiveAllowSpaces,
121
+ allowToIncludeChar,
122
+ allowedPrefixes,
123
+ startOfLine,
124
+ findSuggestionMatch,
125
+ allow,
126
+ shouldShow,
127
+ shouldKeepDismissed,
128
+ pluginKey,
129
+ }),
130
+
131
+ props: createSuggestionProps({
132
+ pluginKey,
133
+ decorationTag,
134
+ decorationClass,
135
+ decorationContent,
136
+ decorationEmptyClass,
137
+ renderer,
138
+ dispatchExit,
139
+ }),
647
140
  })
648
-
649
- return plugin
650
141
  }
651
142
 
652
143
  /**